20260901222055

This commit is contained in:
oneao committed 2026-09-01 22:20:55 +08:00
1 parent 53aba8b956
commit 6b9cda5424
12 files changed
+2129 -275

No files matched your search

+290
View File
@@ -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' },
],
})
+7
View File
@@ -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 后自动走权限校验
],
},
+1 -1
View File
@@ -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: {