20260901222055
This commit is contained in:
1 parent
53aba8b956
commit
6b9cda5424
12 files changed
+2129
-275
No files matched your search
@@ -0,0 +1,290 @@
|
||||
# FMS 删除规则设计
|
||||
|
||||
> 版本:v1.0。关联:`FMS新系统核心表结构设计.md`。
|
||||
> 一句话:**删之前先查一查,查不过就拒绝;查什么、怎么查,由 IT 配置,不写代码。**
|
||||
|
||||
## 这个设计解决什么问题
|
||||
|
||||
业务上「能不能删」的规矩五花八门:
|
||||
|
||||
- 单据还在审核中,不许删
|
||||
- 已经销账了,不许删
|
||||
- 核销过钱的,不许删
|
||||
- 费用已经做到账单里了,不许删
|
||||
- 删报销单时,下面的费用明细要跟着一起删
|
||||
|
||||
以前这些规矩要么写死在代码里(改一条要发版),要么干脆没有(误删了才发现)。现在的做法:**IT 在界面上配置,配完立即生效**。
|
||||
|
||||
## 1. 整体图景
|
||||
|
||||
一个用户点「删除」,系统背后做四件事:
|
||||
|
||||
```
|
||||
1. 找出连带要删的记录(删报销单 → 连费用明细、附件一起)
|
||||
2. 把配置的所有删除规则挨条查一遍
|
||||
3. 任何一条不过 → 整批拒绝,告诉用户为什么
|
||||
4. 全过 → 先弹预览给用户确认,再一个事务里一起删
|
||||
```
|
||||
|
||||
IT 要做的事只有一件:**配规则**。配的地方在模块管理。
|
||||
|
||||
## 2. 规则:一条就是一句「什么情况不许删」
|
||||
|
||||
每条规则就是一句话:
|
||||
|
||||
> **当【什么条件】时,不许删,提示【什么话】**
|
||||
|
||||
规则有三种写法,按配的难度从易到难:
|
||||
|
||||
### 2.1 勾条件(日常用这个,占绝大多数)
|
||||
|
||||
在模块管理 → 「删除规则」tab 点「新增规则」,弹出来的界面和**查询配置长得一样**:
|
||||
|
||||
```
|
||||
满足以下条件时,不许删除:
|
||||
┌──────────┬────────┬─────────────────┐
|
||||
│ 字段 │ 操作符 │ 值 │
|
||||
│ 单据状态 │ 属于 │ [审核中, 已生效] │
|
||||
│ 已销账 │ 等于 │ 1 │
|
||||
└──────────┴────────┴─────────────────┘
|
||||
```
|
||||
|
||||
- 字段下拉就是该模块字段定义里的字段
|
||||
- 值可以直接填,也可以选另一个字段比(比如「已核销金额 大于 0」)
|
||||
- 提示文案**不用写**——系统自动生成「单据 SKD-2026-001 状态为审核中,不能删除」
|
||||
|
||||
会配查询条件的人,零学习成本。
|
||||
|
||||
### 2.2 勾引用(防误删最常用)
|
||||
|
||||
在**关系配置**上勾一下「有引用时拒绝删除」。
|
||||
|
||||
效果:这模块的记录只要被别的单据引用着(费用被账单引用),就删不掉,提示「费用【海运费 ¥2,000】已被账单 ZD-2026-001 引用」。
|
||||
|
||||
模块的删除规则 tab 里能看到这个勾的只读展示和跳转,不会迷路。
|
||||
|
||||
### 2.3 写 SQL(兜底,藏在角落)
|
||||
|
||||
界面勾不出来的判断——比如「核销金额加起来大于 0」这种要查别的表的——在规则类型的「高级」选项里直接写一句 SQL:
|
||||
|
||||
```sql
|
||||
select b.b_id, b.b_no from bill b
|
||||
join bill_detail d on d.bill_id = b.b_id
|
||||
where b.b_id in (:ids) and d.writeoff > 0
|
||||
```
|
||||
|
||||
约定就两条:`:ids` 会自动换成当前删的这批单据的 id;**查出来有结果就拦**。提示文案自己填,可以 `{b_no}` 这样引用查询结果的列。
|
||||
|
||||
平时用不到,会写的人自己找得到,不会打扰别人。谁改过这条 SQL、每次执行了什么,系统日志里全程有记录(内部系统,不做限制,只做留痕)。
|
||||
|
||||
## 3. 连带删除:删主单时子表跟着删
|
||||
|
||||
这个不用配条件,在模块关系上选一个「删除时连带」的策略:
|
||||
|
||||
| 选项 | 意思 |
|
||||
|---|---|
|
||||
| 连带删除 | 删报销单,费用明细一起物理删掉 |
|
||||
| 连带归档 | 明细不删,打个「已归档」标记,列表里不再显示,账单引用还能追溯 |
|
||||
| 不连带 | 明细自己管自己 |
|
||||
|
||||
关键行为:
|
||||
|
||||
- **查连带时也过规则**:删报销单连带要删费用,但费用被账单引用着 → 整个删除(包括报销单)被拒绝,提示定位到那笔费用和那个账单
|
||||
- **一荣俱荣一损俱损**:任何一条卡住,整批都删不了,不存在删一半的中间状态
|
||||
- 关系套关系(单→明细→附件)会自动一层层找下去;层数太多(比如配置成了环)会直接报配置错误,不会死循环
|
||||
|
||||
## 4. 删除的操作流程(业务用户视角)
|
||||
|
||||
列表页勾几行点删除,系统**先预览后真删**:
|
||||
|
||||
```
|
||||
第一步:系统先算一遍
|
||||
↓ 有问题? → 弹错误列表:「这 3 条审核中删不了」「这笔费用被账单引用」
|
||||
↓ 没问题? → 弹确认框:「将删除 1 张报销单 + 3 条费用明细,确认?」
|
||||
|
||||
第二步:用户确认后才真删(删之前系统再快速查一遍,防止确认的几秒里有变化)
|
||||
```
|
||||
|
||||
错误提示都是人话,不用猜。批量删 500 行时提示按规则归类显示,不会刷屏。
|
||||
|
||||
## 5. 配置入口在哪
|
||||
|
||||
**配规则**(IT 日常):
|
||||
|
||||
- 模块管理 → 找到模块 → 「删除规则」tab → 新增
|
||||
- 每条规则 3 个格子一分钟;改完立即生效,**不用发版**
|
||||
- 常见规则可以**复制到其他模块**(多选目标模块批量克隆)——「审核中不可删」这种几十个模块通用的规矩,配一次、勾一圈目标模块就完事
|
||||
|
||||
**看全貌**(排查、盘点):
|
||||
|
||||
- 系统管理 → 「业务规则」:全系统所有规则的列表——哪个模块的、什么类型、谁最后改的、什么时候。点行跳到对应模块
|
||||
- 排查「这单为什么删不掉」:错误提示里带规则名 → 全局列表找到它 → 看条件/SQL → 完事
|
||||
|
||||
**谁可以配**:规则的增删改走权限控制(和菜单权限同体系)。看规则不受限。
|
||||
|
||||
## 6. 数据落库(给开发看的部分)
|
||||
|
||||
### 6.1 s_rule 规则表(新增)
|
||||
|
||||
```sql
|
||||
create table s_rule (
|
||||
b_id bigint not null,
|
||||
b_code varchar(50) not null, -- 稳定标识(复制/导入导出用)
|
||||
b_name nvarchar(100) not null, -- 规则名(如"审核中不可删")
|
||||
b_scope_type varchar(20) not null, -- module / relation:挂在模块还是关系上
|
||||
b_scope_id bigint not null, -- 挂在哪个模块/哪条关系上
|
||||
b_hook varchar(30) not null, -- delete.pre(一期只做这个)
|
||||
-- save.pre(将来做"已审核不能改")
|
||||
b_predicate nvarchar(max) null, -- 条件(勾条件=JSON;写SQL=SQL文本;勾引用=空)
|
||||
b_message nvarchar(500) null, -- 提示文案(写SQL的必填;勾条件的可留空自动生成)
|
||||
b_canuse int not null default 1,
|
||||
b_xh int not null default 0, -- 多条规则谁先查(并列按 b_xh, b_id)
|
||||
b_inputuser_id bigint null,
|
||||
b_inputdatetime datetime2 null default sysutcdatetime(),
|
||||
b_updateuser_id bigint null,
|
||||
b_updatedatetime datetime2 null,
|
||||
constraint pk_s_rule primary key (b_id),
|
||||
constraint ux_s_rule_code unique (b_code)
|
||||
);
|
||||
|
||||
-- b_scope_type / b_hook 的取值范围不建 CHECK 约束,由服务层校验
|
||||
-- (与全系统「元数据合法性走服务层」的原则一致,新增取值不用改表)
|
||||
|
||||
create index ix_s_rule_scope on s_rule (b_scope_type, b_scope_id, b_hook, b_canuse, b_xh);
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **模块上没有任何规则 = 直接删**(无约束)
|
||||
- 想要旧「restrict 主数据」的效果?一键模板:给所有入边关系批量勾上引用规则
|
||||
- 错误类型不用存——勾条件报「状态不符」、勾引用报「被引用」、写 SQL 报自定义文案,写法本身决定了报什么
|
||||
- 提示文案只存这一列,不重复塞进条件 JSON 里
|
||||
- **删模块/删关系时,服务层把挂着的规则一起删掉**(和字段、权限点同一套清理逻辑,全系统本来就不建物理外键)
|
||||
- 删规则 = 物理删 + 日志记全文;临时不想用某条规则 → 停用(b_canuse=0),不删
|
||||
|
||||
### 6.2 老表的变化
|
||||
|
||||
```sql
|
||||
-- s_module:删掉 b_delete_policy(就是那个 restrict/lifecycle/hard 三选一,表达力不够,废弃)
|
||||
-- s_relation:删掉 b_delete_policy,新增:
|
||||
alter table s_relation add
|
||||
b_on_delete varchar(20) null; -- cascade连带删 / archive连带归档 / none不连带(默认)
|
||||
```
|
||||
|
||||
存量数据转换:旧 cascade→cascade;旧 archive→archive;旧 restrict→不连带+生成引用规则;旧 set-null→丢弃(本就不该有)。
|
||||
|
||||
### 6.3 勾条件那部分的格式(JSON,界面生成,人不手写)
|
||||
|
||||
```json
|
||||
{ "kind": "dsl", "expr":
|
||||
{ "and": [
|
||||
{ "in": [ { "field": "b_status" }, { "const": ["draft", "rejected"] } ] },
|
||||
{ "eq": [ { "field": "b_settled" }, { "const": 0 } ] }
|
||||
] }
|
||||
}
|
||||
```
|
||||
|
||||
开发注意三条:
|
||||
|
||||
- 只能引用本模块的字段(想跨表?去写 SQL 规则),后端翻译成**参数化 SQL**,没有注入风险
|
||||
- 常量按字段类型校验(数字/日期/文本),和查询配置的校验规则共用一套
|
||||
- 防手滑:条件嵌套最多 8 层、最多 100 个节点(界面上分组最多 3 层,比后端还紧)
|
||||
|
||||
### 6.4 后端删除流程
|
||||
|
||||
**引擎拆成两个部件:检查器 和 执行器**。执行器管连带和真删;检查器只查规则(一个方法:`check(连接, 模块, ids, 小闭包)`)。两个入口共用同一个检查器:
|
||||
|
||||
```
|
||||
删除(模块, ids, 是否预览):
|
||||
|
||||
① 找连带:从这批 ids 出发,沿"连带删/连带归档"的关系一层层查,
|
||||
全部装进删除清单(已装过的跳过——防关系成环;超过10层报配置错误)
|
||||
② 查规则:按顺序逐条执行挂在清单上的删除规则
|
||||
勾条件 → 按条件查清单内记录,不符的记一条错误(带实际值)
|
||||
写SQL → 拿这批 ids 跑那条 SQL,查出结果每行记一条错误(全程写日志)
|
||||
勾引用 → 查有没有清单外的单据引用清单内记录,有则记错误(带引用方单号)
|
||||
③ 有错误 → 整批拒绝,返回错误列表(按规则归类,每类带数量和前几条示例)
|
||||
④ 全过 → 只是预览?返回"将删除 X 张单 + Y 条明细"摘要
|
||||
真删?→ 开事务:再快速过一遍规则(防确认间隙有变化)
|
||||
→ 归档的打标记 → 按先子后父的顺序物理删
|
||||
→ 记审计日志(按删的这一批记一条 + 明细)
|
||||
```
|
||||
|
||||
开发注意:
|
||||
|
||||
- 查库全部按批 IN,不逐行循环
|
||||
- ids 数量不设上限——引擎内部把整批 id 装成一个数组参数(`OPENJSON`),不受绑定变量 2100 个的限制
|
||||
- 真删前在事务里重查一遍规则(预览的通过**不算数**),防确认的几秒里有人改了规则或新增了引用
|
||||
- 写 SQL 规则的每次执行:实际 SQL + 参数 + 结果行数都写进技术日志,关联本次请求——排查「为什么删不掉」从错误提示一路能追到原始 SQL
|
||||
|
||||
### 6.5 saveobjt 保存时删行,规则同样生效(重要)
|
||||
|
||||
**问题**:海运编辑这类主子表表单,保存走通用接口 `saveobjt`——前端把主表 + 子表(费用)的增删改 diff 成一个请求数组,一个事务提交。用户在表单里删掉一行费用,就是请求数组里子表的 `deletes` 段。**这条删除路径如果不过规则,配置就白配了**——列表页删除被拦,表单里删行却畅通无阻。
|
||||
|
||||
**接法**(对前端完全透明,入参不变):
|
||||
|
||||
```
|
||||
DataSaveService.saveTable() 的 delete 分支,执行真删之前:
|
||||
|
||||
① 按表名反查模块(b_savetable = 这张表的 data 模块);查不到模块(如日志表)→跳过
|
||||
② 该模块挂有删除规则 → 调检查器,传当前 connection(同一个事务!)
|
||||
+ 待删的这批行
|
||||
③ 任一规则不过 → 抛 BusinessException(文案就是规则生成的提示)
|
||||
→ 整个 saveobjt 回滚——主表的修改和子表的删除一起回滚,
|
||||
用户的前端草稿还在,知道哪行费用删不掉、为什么
|
||||
④ 无规则或全过 → 照常执行 deletes
|
||||
```
|
||||
|
||||
三个关键点:
|
||||
|
||||
- **同事务检查**:检查器用的就是 saveobjt 自己的 connection——表单里先改了主表状态、再删子表行,规则查到的是**改过之后**的值,不会出现「检查用旧数据、删的是新数据」的错位
|
||||
- **小闭包语义**:勾引用规则检查子表行时,要把「本次 saveobjt 所有 deletes 覆盖的记录」当作一个小闭包——引用方也在被删之列就不算外部引用。场景:删主单连带删子表行(前端编排的),子表对主单的引用指向一个同批正在删除的记录,不该拦。实现:把请求数组里所有 deletes 的 id 收集起来传给检查器
|
||||
- **不需要预览**:表单保存是「保存」动作,用户预期就是要么全成功要么报错,不做两阶段确认
|
||||
|
||||
### 6.6 接口
|
||||
|
||||
```
|
||||
POST /data/deleteobj
|
||||
{
|
||||
"moduleCode": "receipt",
|
||||
"ids": [1, 2, 3],
|
||||
"dryRun": true // true=只预览(前端删除按钮的第一步)
|
||||
}
|
||||
|
||||
POST /data/saveobjt // 不变,deletes 分支内部自动走规则检查
|
||||
```
|
||||
|
||||
## 7. 其他约定
|
||||
|
||||
- **字段联动**:规则里用了的字段,删除该字段时会被拦住并提示规则名——不让规则悄悄失效
|
||||
- **配置权限**:改规则要权限点(如 `rule.edit`),和菜单权限同一套体系;SQL 不限制内容,但改它的人必须有配置权限
|
||||
- **归档标记**:连带归档要求子表有通用列 `b_archived` + `b_archive_datetime`(配归档时服务层校验列存在);通用查询默认不显示已归档行
|
||||
- **将来扩展**(表结构已留位,暂不实现):
|
||||
- 「已审核的单不能**改**」——同一套规则换个挂点(save.pre)就行,界面和错误提示全复用
|
||||
- 「删之前先自动红冲」这种要执行动作的——预留了挂 Java 代码的口子
|
||||
- 核销总额想在列表上显示成列——那是展示需求,将来单独做,跟删除无关
|
||||
|
||||
## 8. 开工顺序
|
||||
|
||||
1. 建表 s_rule;删老字段、加 `b_on_delete`(存量按 §6.2 转换);前端基本信息表单同步去掉「删除策略」下拉
|
||||
2. 后端:条件(JSON → 参数化 SQL)+ 检查器 + 执行器(连带展开 → 规则 → 事务删除)
|
||||
3. **saveobjt 接入检查器**:delete 分支反查模块 → 同事务规则检查 → 不过整体回滚(见 §6.5)
|
||||
4. 错误提示:条件自动生成人话 + 字段格式化(金额带 ¥、日期带格式)+ SQL 占位符填充
|
||||
5. 前端:删除先预览后确认、错误列表展示;模块管理「删除规则」tab(条件构造 + 复制到模块 + SQL 角落入口);系统管理全局规则列表页
|
||||
6. 二期:「已审核不能改」、Java 钩子、聚合展示
|
||||
|
||||
## 9. 定案记录
|
||||
|
||||
| # | 事项 | 结论 |
|
||||
|---|---|---|
|
||||
| 1 | 节奏 | 一期只做删除规则;表结构给保存校验留好位 |
|
||||
| 2 | 跨表判断 | 不做计算字段,直接写 SQL 兜底 |
|
||||
| 3 | SQL | 兜底逃生舱:不限制内容、不做体验优化、入口藏角落;**每次执行全程写日志** |
|
||||
| 4 | 旧 set-null | 废弃(财务上不该有悬空引用) |
|
||||
| 5 | 归档 | 通用列 `b_archived` + `b_archive_datetime` |
|
||||
| 6 | 条件规模 | 嵌套 ≤ 8 层、节点 ≤ 100;界面分组 ≤ 3 层 |
|
||||
| 7 | 错误类型 | 不单独存列,由规则写法决定 |
|
||||
| 8 | 级联清理 | 删模块/关系时业务层连带删规则(同权限点模式) |
|
||||
| 9 | 批量 | ids 不设上限;整批一个数组参数 |
|
||||
| 10 | 复制 | 规则可多选模块批量克隆;引用规则除外 |
|
||||
| 11 | saveobjt 集成 | 引擎拆检查器/执行器;saveobjt 的 deletes 分支同事务调检查器,不过则整批回滚(§6.5);前端入参不变 |
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 多语言名称编辑弹窗(通用组件)。
|
||||
* 用于编辑一个对象在不同语言下的名称,支持任意语言列表。
|
||||
*
|
||||
* 用法:
|
||||
* <I18nNameModal
|
||||
* :open="open"
|
||||
* :languages="I18N_LANGUAGES"
|
||||
* :model-value="{ 'zh-CN': '名称', 'en-US': 'Name' }"
|
||||
* @update:model-value="(v) => writeBack(v)"
|
||||
* @update:open="(v) => (open = v)"
|
||||
* />
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { Modal, Button, Input } from '@/components/ui'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps({
|
||||
/** 受控打开状态 */
|
||||
open: { type: Boolean, default: false },
|
||||
/** 弹窗标题 */
|
||||
title: { type: String, default: '多语言名称' },
|
||||
/** 语言列表 [{ code, name, default }] */
|
||||
languages: { type: Array, default: () => [] },
|
||||
/** 各语言值 { [code]: value } */
|
||||
modelValue: { type: Object, default: () => ({}) },
|
||||
/** 底部提示文字 */
|
||||
hint: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'update:modelValue', 'confirm'])
|
||||
|
||||
/** 本地草稿:打开时快照,确定时提交 */
|
||||
const draft = ref({})
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) draft.value = { ...props.modelValue }
|
||||
},
|
||||
)
|
||||
|
||||
function onValueChange(lang, value) {
|
||||
draft.value = { ...draft.value, [lang.code]: value }
|
||||
}
|
||||
|
||||
function onOk() {
|
||||
emit('update:modelValue', { ...draft.value })
|
||||
emit('confirm', { ...draft.value })
|
||||
emit('update:open', false)
|
||||
}
|
||||
function onCancel() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="open" :title="title" :width="520" :mask-closable="false" @cancel="onCancel">
|
||||
<div class="i18n-name-modal">
|
||||
<div class="i18n-name-row" v-for="lang in languages" :key="lang.code">
|
||||
<span class="i18n-name-row__label">{{ lang.name }}</span>
|
||||
<Input
|
||||
class="i18n-name-row__input"
|
||||
:model-value="draft[lang.code] || ''"
|
||||
:placeholder="`请输入${lang.name}名称`"
|
||||
@update:model-value="(v) => onValueChange(lang, v)"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="hint" class="i18n-name-modal__hint">{{ hint }}</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button type="outline" @click="onCancel">取消</Button>
|
||||
<Button type="primary" @click="onOk">确定</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.i18n-name-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.i18n-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.i18n-name-row__label {
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
font-size: 13px;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.i18n-name-row__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.i18n-name-modal__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -56,6 +56,7 @@ const navMain = computed(() => {
|
||||
icon: Settings,
|
||||
items: [
|
||||
{ title: '模块管理', url: '/system/module-management' },
|
||||
{ title: '菜单管理', url: '/system/menu-management' },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -101,6 +101,13 @@ const router = createRouter({
|
||||
component: () => import('@/views/system/module-management/index.vue'),
|
||||
meta: { title: '模块管理', componentName: 'ModuleManagement' },
|
||||
},
|
||||
// 菜单管理:占位页面,待实现
|
||||
{
|
||||
path: '/system/menu-management',
|
||||
name: 'system-menu-management',
|
||||
component: () => import('@/views/system/menu-management/index.vue'),
|
||||
meta: { title: '菜单管理', componentName: 'MenuManagement' },
|
||||
},
|
||||
// 业务页面后续在此追加,配置 meta.moduleId 后自动走权限校验
|
||||
],
|
||||
},
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
|
||||
/* 栅格(grid):默认水平/垂直间距(不随主题变化) */
|
||||
--fms-grid-gutter: 0px;
|
||||
--fms-splitter-bar-size: 1px;
|
||||
--fms-splitter-bar-size: 3px;
|
||||
--fms-splitter-bar-size-hover: 4px;
|
||||
--fms-splitter-handle-size: 20px;
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -201,8 +201,8 @@ watch(
|
||||
|
||||
function buildColumns() {
|
||||
const baseColumns = [
|
||||
{ dataIndex: 'b_field', title: '字段名', width: 150 },
|
||||
{ dataIndex: 'b_name', title: '字段名称', width: 140, editor: { type: 'input' } },
|
||||
{ dataIndex: 'b_field', title: '字段名', width: 150, sorter: true },
|
||||
{ dataIndex: 'b_name', title: '字段名称', width: 140, sorter: true, editor: { type: 'input' } },
|
||||
{
|
||||
dataIndex: 'b_type',
|
||||
title: '控件类型',
|
||||
@@ -251,6 +251,7 @@ function buildColumns() {
|
||||
<FmsTable
|
||||
v-model:columns="columns"
|
||||
:data-source="localFields"
|
||||
:sort-config="{ emptyToBottom: true, stringLocaleCompare: true }"
|
||||
:editable="!loading"
|
||||
row-key="b_id"
|
||||
:active-row-key="activeKey"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import { Button, Input } from '@/components/ui'
|
||||
import { Button } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { createTempId } from '@/utils/tempId'
|
||||
import {
|
||||
@@ -24,8 +24,6 @@ const props = defineProps({
|
||||
module: { type: Object, default: null },
|
||||
/** 字段草稿(b_field / b_name / b_i18n),受管键依赖它 */
|
||||
fields: { type: Array, default: () => [] },
|
||||
/** 权限草稿(b_code / b_name),受管键依赖它 */
|
||||
powers: { type: Array, default: () => [] },
|
||||
/** 分组草稿(b_id / b_title / b_i18n),受管键依赖它 */
|
||||
groups: { type: Array, default: () => [] },
|
||||
/** index.vue 加载的原始 s_i18n 扁平行([{b_id,b_key,b_locale,b_value}]) */
|
||||
@@ -49,13 +47,12 @@ const defaultLocale = computed(() => langOptions.value[0]?.code || '')
|
||||
const localRows = ref([])
|
||||
/** 保存 diff 基线:加载时的原始扁平行 */
|
||||
const originalsFlat = ref([])
|
||||
const searchKey = ref('')
|
||||
const activeKey = ref(null)
|
||||
const tableRef = ref(null)
|
||||
const selectedRowKeys = ref([])
|
||||
|
||||
/* ================= 受管键收集 ================= */
|
||||
// 模块 / 字段 / 权限 / 分组的翻译键是系统自动生成的,必须始终存在:
|
||||
// 模块 / 字段 / 分组的翻译键是系统自动生成的,必须始终存在:
|
||||
// 收集这些键(含默认名称),供 reconcile 补行、跟随名称、清理失效键。
|
||||
const finalI18nItems = computed(() => {
|
||||
const module = props.module
|
||||
@@ -74,10 +71,6 @@ const finalI18nItems = computed(() => {
|
||||
const key = buildModuleItemI18nKey('field', module.b_code, field.b_field)
|
||||
if (key) items.push({ key, managed: true, defaultValue: field.b_name || '', id: `field:${key}` })
|
||||
}
|
||||
for (const power of props.powers || []) {
|
||||
const key = buildModuleItemI18nKey('power', module.b_code, power.b_code)
|
||||
if (key) items.push({ key, managed: true, defaultValue: power.b_name || '', id: `power:${key}` })
|
||||
}
|
||||
// 分组标题多语言:b_i18n 为用户手填 key(空则运行时直接用标题原文,不纳入受管)
|
||||
for (const group of props.groups || []) {
|
||||
const key = String(group.b_i18n || '').trim()
|
||||
@@ -236,18 +229,13 @@ function batchDelete() {
|
||||
if (activeKey.value && delIds.has(String(activeKey.value))) activeKey.value = null
|
||||
}
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const kw = String(searchKey.value || '').trim().toLowerCase()
|
||||
if (!kw) return localRows.value
|
||||
return localRows.value.filter((row) => String(row.b_key || '').toLowerCase().includes(kw))
|
||||
})
|
||||
|
||||
const columns = computed(() => {
|
||||
const cols = [
|
||||
{
|
||||
dataIndex: 'b_key',
|
||||
title: '多语言键',
|
||||
width: 260,
|
||||
sorter: true,
|
||||
editor: { type: 'input', disabled: (row) => isManagedRow(row) },
|
||||
},
|
||||
]
|
||||
@@ -256,6 +244,8 @@ const columns = computed(() => {
|
||||
dataIndex: lang.code,
|
||||
title: lang.name,
|
||||
width: 200,
|
||||
// 默认语言列(简体中文)开启排序
|
||||
sorter: lang.code === defaultLocale.value,
|
||||
editor: { type: 'input' },
|
||||
})
|
||||
}
|
||||
@@ -344,9 +334,6 @@ defineExpose({ buildSaveRequest, applyInsertedIds, removeByManagedIds })
|
||||
<template>
|
||||
<div class="mm-i18n">
|
||||
<div class="mm-panel__toolbar">
|
||||
<div class="mm-i18n__search">
|
||||
<Input v-model="searchKey" placeholder="按多语言键过滤" allow-clear />
|
||||
</div>
|
||||
<Button type="primary" :disabled="loading" @click="addRow"><Plus :size="14" /> 新增多语言</Button>
|
||||
<Button type="danger" :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
|
||||
<Trash2 :size="14" /> 批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
@@ -356,7 +343,8 @@ defineExpose({ buildSaveRequest, applyInsertedIds, removeByManagedIds })
|
||||
<FmsTable
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
:data-source="filteredRows"
|
||||
:data-source="localRows"
|
||||
:sort-config="{ emptyToBottom: true, stringLocaleCompare: true }"
|
||||
:editable="!loading"
|
||||
:row-selection="rowSelection"
|
||||
row-key="_id"
|
||||
@@ -382,10 +370,6 @@ defineExpose({ buildSaveRequest, applyInsertedIds, removeByManagedIds })
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.mm-i18n__search {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.mm-panel__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 权限配置面板(s_power):模块操作权限点(b_power_type=action)。
|
||||
* i18n 键按 power.{moduleCode}.{powerCode} 自动同步。
|
||||
*/
|
||||
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import { Message, Button } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { syncModuleItemI18nKeys } from '@/utils/i18n'
|
||||
import { createTempId } from '@/utils/tempId'
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: String, required: true },
|
||||
moduleCode: { type: String, default: '' },
|
||||
powers: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
/** 权限点类型:模块操作 action / 菜单访问 menu */
|
||||
powerType: { type: String, default: 'action' },
|
||||
/** 资源编码(b_resource):默认取 moduleCode */
|
||||
resource: { type: String, default: '' },
|
||||
/** i18n 键前缀:power / menu */
|
||||
i18nPrefix: { type: String, default: 'power' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const localPowers = ref([])
|
||||
const activeKey = ref(null)
|
||||
const tableRef = ref(null)
|
||||
|
||||
watch(
|
||||
() => [props.powers, props.moduleCode],
|
||||
([val, moduleCode]) => {
|
||||
if (val === localPowers.value) return
|
||||
const rows = val?.map((row) => ({ ...row })) || []
|
||||
const changed = syncModuleItemI18nKeys(rows, props.i18nPrefix, moduleCode, 'b_code')
|
||||
localPowers.value = rows
|
||||
if (changed) emit('update', rows)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function addRow() {
|
||||
if (props.loading) return
|
||||
const newId = createTempId()
|
||||
const newRow = {
|
||||
b_id: newId,
|
||||
b_module_id: props.moduleId,
|
||||
b_code: '',
|
||||
b_name: '',
|
||||
b_i18n: null,
|
||||
b_power_type: props.powerType,
|
||||
b_resource: props.resource || props.moduleCode || null,
|
||||
b_operation: props.powerType === 'menu' ? 'access' : 'execute',
|
||||
b_xh: (localPowers.value.length + 1) * 10,
|
||||
b_canuse: 1,
|
||||
}
|
||||
// stk-table 对同一引用数组的 push 不敏感(内部 shallowRef + 引用 watch),必须换新引用才会渲染新行
|
||||
localPowers.value = [...localPowers.value, newRow]
|
||||
emit('update', localPowers.value)
|
||||
activeKey.value = String(newId)
|
||||
await nextTick()
|
||||
tableRef.value?.editRow?.(String(newId), 'b_code')
|
||||
}
|
||||
|
||||
function deleteRow(record) {
|
||||
if (props.loading) return
|
||||
localPowers.value = localPowers.value.filter((r) => r.b_id !== record.b_id)
|
||||
selectedRowKeys.value = selectedRowKeys.value.filter((x) => x !== String(record.b_id))
|
||||
emit('update', localPowers.value)
|
||||
}
|
||||
|
||||
const selectedRowKeys = ref([])
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
},
|
||||
}))
|
||||
|
||||
function batchDelete() {
|
||||
if (props.loading) return
|
||||
const keys = selectedRowKeys.value
|
||||
if (!keys.length) return
|
||||
const delSet = new Set(keys)
|
||||
localPowers.value = localPowers.value.filter((r) => !delSet.has(String(r.b_id)))
|
||||
selectedRowKeys.value = []
|
||||
if (activeKey.value && delSet.has(activeKey.value)) activeKey.value = ''
|
||||
emit('update', localPowers.value)
|
||||
Message.success(`已删除 ${keys.length} 条权限`)
|
||||
}
|
||||
|
||||
function onCellChange() {
|
||||
syncModuleItemI18nKeys(localPowers.value, props.i18nPrefix, props.moduleCode, 'b_code')
|
||||
emit('update', localPowers.value)
|
||||
}
|
||||
|
||||
const columns = shallowRef([
|
||||
{ dataIndex: 'b_code', title: '操作编码', width: 160, editor: { type: 'input' } },
|
||||
{ dataIndex: 'b_name', title: '操作名称', width: 160, editor: { type: 'input' } },
|
||||
{ dataIndex: 'b_i18n', title: '多语言键', width: 200 },
|
||||
{ dataIndex: 'b_xh', title: '排序', width: 90, editor: { type: 'input', inputType: 'number' } },
|
||||
{
|
||||
dataIndex: 'b_canuse',
|
||||
title: '启用',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
width: 70,
|
||||
actions: [
|
||||
{
|
||||
type: 'delete',
|
||||
label: '删除',
|
||||
onClick: (row) => deleteRow(row),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mm-panel">
|
||||
<div class="mm-panel__toolbar">
|
||||
<Button type="primary" :disabled="loading" @click="addRow"><Plus :size="14" /> 新增权限</Button>
|
||||
<Button type="danger" :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
|
||||
<Trash2 :size="14" /> 批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mm-panel__body">
|
||||
<FmsTable
|
||||
ref="tableRef"
|
||||
v-model:columns="columns"
|
||||
:data-source="localPowers"
|
||||
:editable="!loading"
|
||||
row-key="b_id"
|
||||
:active-row-key="activeKey"
|
||||
:row-selection="rowSelection"
|
||||
@row-click="(_, row) => (activeKey = String(row.b_id))"
|
||||
@change="onCellChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mm-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mm-panel__toolbar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.mm-panel__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -266,7 +266,8 @@ const columns = shallowRef([
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 双栏布局:左字段库(FieldLibrary 自带右侧分割线与内边距)+ 右主区 */
|
||||
/* 双栏布局:左字段库(FieldLibrary 自带右侧分割线与内边距)+ 右主区;
|
||||
外框与列表配置(TableDesignEditor .mm-edit)一致 */
|
||||
.mm-qdesign {
|
||||
display: grid;
|
||||
grid-template-columns: 184px minmax(0, 1fr);
|
||||
@@ -274,6 +275,9 @@ const columns = shallowRef([
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
background: var(--fms-card);
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-qdesign__main {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 模块管理:模块配置(单分区)。
|
||||
* 模块配置:左侧模块树 + 右侧分节面板(基础/字段/列表/编辑/查询/自动编码/权限/多语言)。
|
||||
* 模块配置:左侧模块树 + 右侧分节面板(基础/字段/列表/编辑/查询/自动编码/多语言)。
|
||||
* 数据直接经通用 API(loadDataApi / saveObjectApi / nextIdApi / describeApi)读写元数据表。
|
||||
* 模块树 / 草稿加载与脏检查 / 保存编排逻辑直接内联在下方;确认弹窗状态机仅本页使用。
|
||||
* 多语言面板(i18n)数据由 ModuleI18nPanel 自持 —— 本文件只加载原始扁平行(rawI18nRows)传入,
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
List,
|
||||
Pencil,
|
||||
Hash,
|
||||
Shield,
|
||||
Globe,
|
||||
Info,
|
||||
FolderTree,
|
||||
@@ -61,7 +60,6 @@ import ModuleListConfigPanel from './ModuleListConfigPanel.vue'
|
||||
import ModuleEditPanel from './ModuleEditPanel.vue'
|
||||
import ModuleQueryConfigPanel from './ModuleQueryConfigPanel.vue'
|
||||
import ModuleAutoCodePanel from './ModuleAutoCodePanel.vue'
|
||||
import ModulePowersPanel from './ModulePowersPanel.vue'
|
||||
import ModuleI18nPanel from './ModuleI18nPanel.vue'
|
||||
|
||||
/* ================= 共享状态(index.vue 组装后传入各 composable) ================= */
|
||||
@@ -138,20 +136,14 @@ const configGroups = computed(() => {
|
||||
uiItems.push({ key: 'query', label: '查询配置', icon: Search })
|
||||
groups.push({ key: 'display', label: '界面配置', items: uiItems })
|
||||
|
||||
const capabilityItems = []
|
||||
// 业务能力:仅数据模块有自动编码
|
||||
if (isDataModule.value) {
|
||||
capabilityItems.push({
|
||||
key: 'auto-code',
|
||||
label: '自动编码',
|
||||
icon: Hash,
|
||||
groups.push({
|
||||
key: 'capability',
|
||||
label: '业务能力',
|
||||
items: [{ key: 'auto-code', label: '自动编码', icon: Hash }],
|
||||
})
|
||||
}
|
||||
capabilityItems.push({
|
||||
key: 'powers',
|
||||
label: '权限',
|
||||
icon: Shield,
|
||||
})
|
||||
groups.push({ key: 'capability', label: '业务能力', items: capabilityItems })
|
||||
}
|
||||
|
||||
if (moduleType.value && moduleType.value !== 'category') {
|
||||
@@ -212,7 +204,6 @@ async function saveModule() {
|
||||
if (!moduleData) return false
|
||||
const moduleCode = String(moduleData.b_code || '').trim()
|
||||
syncModuleItemI18nKeys(drafts.value.fields, 'field', moduleCode, 'b_field')
|
||||
syncModuleItemI18nKeys(drafts.value.powers, 'power', moduleCode, 'b_code')
|
||||
moduleData.b_i18n = buildModuleI18nKey(moduleData.b_module_type, moduleCode)
|
||||
|
||||
const isNew = unsavedModuleIds.has(moduleId)
|
||||
@@ -230,7 +221,6 @@ async function saveModule() {
|
||||
['s_field_edit', 'editConfig'],
|
||||
['s_field_query', 'queryConfig'],
|
||||
['s_autocode', 'autoCode'],
|
||||
['s_power', 'powers'],
|
||||
]
|
||||
// 收集字段/分组的临时 id → 真实 id 映射,用于跨表外键重映射
|
||||
const fieldIdMap = new Map()
|
||||
@@ -459,7 +449,6 @@ async function addModule(parentId, moduleType) {
|
||||
editConfig: [],
|
||||
queryConfig: [],
|
||||
autoCode: [],
|
||||
powers: [],
|
||||
})
|
||||
rawI18nRows.value = []
|
||||
originals.value = cloneData(drafts.value)
|
||||
@@ -523,13 +512,110 @@ async function deleteModule(id) {
|
||||
})
|
||||
}
|
||||
|
||||
/* ================= 排序(拖拽) ================= */
|
||||
// 在树中查找节点所在的父数组及索引
|
||||
function findNodePosition(nodes, id) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
|
||||
if (nodes[i].children) {
|
||||
const found = findNodePosition(nodes[i].children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 重排某组兄弟的 b_xh(10 起步、步长 10),收集变化行
|
||||
function renumberSiblings(list, updatedRows) {
|
||||
list.forEach((s, i) => {
|
||||
const nextXh = (i + 1) * 10
|
||||
if (s.b_xh !== nextXh) {
|
||||
s.b_xh = nextXh
|
||||
updatedRows.push({ b_id: s.b_id, b_xh: nextXh })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function onModuleDrop({ dragKey, dropKey, dropPosition }) {
|
||||
if (moduleKeyword.value.trim()) return // 过滤状态下禁止拖拽
|
||||
const dragId = String(dragKey)
|
||||
const dropId = String(dropKey)
|
||||
if (dragId === dropId) return
|
||||
|
||||
const dragPos = findNodePosition(moduleTreeData.value, dragId)
|
||||
const dropPos = findNodePosition(moduleTreeData.value, dropId)
|
||||
if (!dragPos || !dropPos) return
|
||||
|
||||
// 先记录落点节点信息,再从原位置移除被拖节点
|
||||
const dropNode = dropPos.list[dropPos.index]
|
||||
const dropParentId = dropNode.b_parent_id ?? null
|
||||
const [moved] = dragPos.list.splice(dragPos.index, 1)
|
||||
|
||||
const updatedRows = []
|
||||
const renumberSets = new Set()
|
||||
|
||||
if (dropPosition === 'inside') {
|
||||
// 成为落点节点的子节点
|
||||
moved.b_parent_id = dropNode.b_id
|
||||
if (!dropNode.children) {
|
||||
dropNode.children = []
|
||||
dropNode.isLeaf = false
|
||||
}
|
||||
dropNode.children.push(moved)
|
||||
renumberSets.add(dropNode.children)
|
||||
renumberSets.add(dragPos.list)
|
||||
} else {
|
||||
// 同级插入:落点节点前 / 后
|
||||
moved.b_parent_id = dropParentId
|
||||
let targetList = dropPos.list
|
||||
let targetIndex = dropPos.index + (dropPosition === 'after' ? 1 : 0)
|
||||
// 同一组内拖拽且被拖节点在落点之前:移除后索引需前移一位
|
||||
if (dragPos.list === dropPos.list && dragPos.index < dropPos.index) {
|
||||
targetIndex -= 1
|
||||
}
|
||||
targetList.splice(targetIndex, 0, moved)
|
||||
renumberSets.add(targetList)
|
||||
}
|
||||
|
||||
renumberSets.forEach((list) => renumberSiblings(list, updatedRows))
|
||||
|
||||
// 若当前编辑的节点排序号/父级变化,同步到草稿(未保存节点落库时随 inserts 一起生效)
|
||||
if (drafts.value?.module) {
|
||||
const draftRow = updatedRows.find((r) => String(r.b_id) === String(drafts.value.module.b_id))
|
||||
if (draftRow) drafts.value.module.b_xh = draftRow.b_xh
|
||||
if (String(drafts.value.module.b_id) === dragId) {
|
||||
drafts.value.module.b_parent_id = moved.b_parent_id
|
||||
}
|
||||
}
|
||||
|
||||
if (!updatedRows.length) return
|
||||
|
||||
// 未保存草稿不能通过 updates 持久化,仅本地排序,落库后自然生效
|
||||
const persistedRows = updatedRows.filter((r) => !unsavedModuleIds.has(String(r.b_id)))
|
||||
if (!persistedRows.length) return
|
||||
|
||||
try {
|
||||
const reqs = persistedRows.map((r) => ({
|
||||
table: 's_module',
|
||||
key_field: 'b_id',
|
||||
updates: [r],
|
||||
}))
|
||||
await saveObjectApi(reqs)
|
||||
Message.success('排序已更新')
|
||||
} catch (e) {
|
||||
Message.error('排序更新失败:' + (e?.message || e))
|
||||
// Reload tree on failure to restore correct order
|
||||
await loadModuleTree()
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 草稿加载 ================= */
|
||||
async function loadModuleConfig(moduleId) {
|
||||
const id = String(moduleId)
|
||||
const type = findTreeNode(moduleTreeData.value, id)?.b_module_type
|
||||
configLoading.value = true
|
||||
try {
|
||||
let moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, powersRes, i18nRes
|
||||
let moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, i18nRes
|
||||
if (type === 'data') {
|
||||
const responses = await Promise.all([
|
||||
loadDataApi('s_module', `b_id = ${id}`),
|
||||
@@ -539,10 +625,9 @@ async function loadModuleConfig(moduleId) {
|
||||
loadDataApi('s_field_edit', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'),
|
||||
loadDataApi('s_field_query', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'),
|
||||
loadDataApi('s_autocode', `b_module_id = ${id}`, 'b_id ASC'),
|
||||
loadDataApi('s_power', `b_module_id = ${id} AND b_power_type = 'action'`, 'b_xh ASC, b_id ASC'),
|
||||
loadDataApi('s_i18n', `b_module_id = ${id}`, 'b_key ASC, b_locale ASC'),
|
||||
])
|
||||
;[moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, powersRes, i18nRes] = responses
|
||||
;[moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, i18nRes] = responses
|
||||
} else if (type === 'query') {
|
||||
const responses = await Promise.all([
|
||||
loadDataApi('s_module', `b_id = ${id}`),
|
||||
@@ -550,10 +635,9 @@ async function loadModuleConfig(moduleId) {
|
||||
loadDataApi('s_field_group', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'),
|
||||
loadDataApi('s_field_view', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'),
|
||||
loadDataApi('s_field_query', `b_module_id = ${id}`, 'b_xh ASC, b_id ASC'),
|
||||
loadDataApi('s_power', `b_module_id = ${id} AND b_power_type = 'action'`, 'b_xh ASC, b_id ASC'),
|
||||
loadDataApi('s_i18n', `b_module_id = ${id}`, 'b_key ASC, b_locale ASC'),
|
||||
])
|
||||
;[moduleRes, fieldsRes, groupsRes, listRes, queryRes, powersRes, i18nRes] = responses
|
||||
;[moduleRes, fieldsRes, groupsRes, listRes, queryRes, i18nRes] = responses
|
||||
} else {
|
||||
moduleRes = await loadDataApi('s_module', `b_id = ${id}`)
|
||||
}
|
||||
@@ -566,7 +650,6 @@ async function loadModuleConfig(moduleId) {
|
||||
editConfig: editRes?.data || [],
|
||||
queryConfig: queryRes?.data || [],
|
||||
autoCode: autoCodeRes?.data || [],
|
||||
powers: powersRes?.data || [],
|
||||
}
|
||||
originals.value = original
|
||||
drafts.value = cloneData(original)
|
||||
@@ -627,7 +710,6 @@ function hasUnsavedChanges() {
|
||||
'editConfig',
|
||||
'queryConfig',
|
||||
'autoCode',
|
||||
'powers',
|
||||
]
|
||||
return keys.some((key) => JSON.stringify(drafts.value[key]) !== JSON.stringify(originals.value[key]))
|
||||
}
|
||||
@@ -866,8 +948,10 @@ loadModuleTree()
|
||||
@update:expanded-keys="(keys) => (moduleExpandedKeys = keys)"
|
||||
:selected-keys="moduleSelectedId ? [moduleSelectedId] : []"
|
||||
:context-menu="buildModuleContextMenu"
|
||||
:draggable="!moduleKeyword.trim()"
|
||||
@select="onModuleSelect"
|
||||
@context-menu-click="onModuleContextMenuClick"
|
||||
@drop="onModuleDrop"
|
||||
>
|
||||
<template #title="{ node }">
|
||||
<span class="mm-tree-node">
|
||||
@@ -1017,20 +1101,6 @@ loadModuleTree()
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDataModule || isQueryModule"
|
||||
v-show="activeSection === 'powers'"
|
||||
class="mm-config-pane"
|
||||
>
|
||||
<ModulePowersPanel
|
||||
:module-id="String(selectedModule.b_id)"
|
||||
:module-code="drafts?.module?.b_code || ''"
|
||||
:powers="drafts?.powers"
|
||||
:loading="busy"
|
||||
@update="(v) => onModuleUpdate('powers', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="moduleType && moduleType !== 'category'"
|
||||
v-show="activeSection === 'i18n'"
|
||||
@@ -1041,7 +1111,6 @@ loadModuleTree()
|
||||
:module-id="String(selectedModule.b_id)"
|
||||
:module="drafts?.module"
|
||||
:fields="drafts?.fields"
|
||||
:powers="drafts?.powers"
|
||||
:groups="drafts?.groups"
|
||||
:raw-rows="rawI18nRows"
|
||||
:loading="busy"
|
||||
@@ -1120,14 +1189,20 @@ loadModuleTree()
|
||||
/* Splitter 适配:
|
||||
* - 面板内容各自内部滚动,避免双滚动条
|
||||
* - 面板左右留 3px,两张白卡之间形成灰缝(3 + 分隔条 6 + 3 = 12px),无需边框线
|
||||
* - 隐藏分隔条常显的 1px 线(hover 时的手柄仍会显示,可拖拽调宽) */
|
||||
* - 常显线平时透明,hover / 拖拽时显示整条 100% 高度的细线 + 手柄提示 */
|
||||
.mm-body-splitter :deep(> .fms-splitter-panel) {
|
||||
overflow: hidden;
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
.mm-body-splitter :deep(.fms-splitter-bar::before) {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mm-body-splitter :deep(.fms-splitter-bar:hover::before),
|
||||
.mm-body-splitter :deep(.fms-splitter-bar-active::before),
|
||||
.mm-body-splitter :deep(.fms-splitter-bar:focus-visible::before) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---- 左树顶部 header:搜索框在左,操作在右,无边框无标题 ---- */
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { registerFeature, useAreaSelection } from 'stk-table-vue'
|
||||
import ModuleQueryConfigPanel from '@/views/system/module-management/ModuleQueryConfigPanel.vue'
|
||||
import ModulePowersPanel from '@/views/system/module-management/ModulePowersPanel.vue'
|
||||
import ModuleI18nPanel from '@/views/system/module-management/ModuleI18nPanel.vue'
|
||||
|
||||
registerFeature(useAreaSelection)
|
||||
@@ -98,40 +97,6 @@ describe('module-management 删除(单个无确认弹窗 + 批量)', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('权限面板:单个删除不弹弹窗且行被移除', async () => {
|
||||
const wrapper = mount(ModulePowersPanel, {
|
||||
props: { moduleId: 'm1', moduleCode: 'demo', powers: [], loading: false },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await settle()
|
||||
await wrapper.findAll('button').find((b) => b.text().includes('新增权限')).trigger('click')
|
||||
await settle()
|
||||
expect(wrapper.findAll('.stk-tbody-main tr').length).toBe(1)
|
||||
await clickDelete(wrapper)
|
||||
await checkNoModal()
|
||||
expect(wrapper.findAll('.stk-tbody-main tr').length, '单个删除后行应被移除').toBe(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('权限面板:批量删除选中多行', async () => {
|
||||
const wrapper = mount(ModulePowersPanel, {
|
||||
props: { moduleId: 'm1', moduleCode: 'demo', powers: [], loading: false },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await settle()
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await wrapper.findAll('button').find((b) => b.text().includes('新增权限')).trigger('click')
|
||||
await settle()
|
||||
}
|
||||
expect(wrapper.findAll('.stk-tbody-main tr').length).toBe(2)
|
||||
await selectRowCheckbox(wrapper, 0)
|
||||
await selectRowCheckbox(wrapper, 1)
|
||||
await clickToolbarBatch(wrapper)
|
||||
await checkNoModal()
|
||||
expect(wrapper.findAll('.stk-tbody-main tr').length, '批量删除后应无行').toBe(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('多语言面板:单个删除不弹弹窗且行被移除', async () => {
|
||||
const wrapper = mount(ModuleI18nPanel, {
|
||||
props: {
|
||||
|
||||
Reference in new issue
Block a user