diff --git a/code/fms/.tmp-dnd-test.html b/code/fms/.tmp-dnd-test.html deleted file mode 100644 index d43c792f..00000000 --- a/code/fms/.tmp-dnd-test.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - -DnD behavior probe - - - -
source (field 42)
-
group target
-
group target 2
-

-
-
-
diff --git a/code/fms/.tmp-server.js b/code/fms/.tmp-server.js
deleted file mode 100644
index 0e749c24..00000000
--- a/code/fms/.tmp-server.js
+++ /dev/null
@@ -1,20 +0,0 @@
-const http = require('http')
-const fs = require('fs')
-const path = require('path')
-const root = 'd:/workspace/code/fms'
-http
-  .createServer((req, res) => {
-    const urlPath = req.url === '/' ? '/.tmp-dnd-test.html' : decodeURIComponent(req.url.split('?')[0])
-    const fp = path.join(root, urlPath)
-    fs.readFile(fp, (err, data) => {
-      if (err) {
-        res.writeHead(404)
-        res.end('nf')
-        return
-      }
-      res.writeHead(200, { 'Content-Type': fp.endsWith('.html') ? 'text/html; charset=utf-8' : 'text/plain' })
-      res.end(data)
-    })
-  })
-  .listen(8090, '127.0.0.1')
-console.log('server on 8090')
diff --git a/code/fms/FMS新系统核心表结构设计.md b/code/fms/FMS新系统核心表结构设计.md
index 6212b1f1..3461277f 100644
--- a/code/fms/FMS新系统核心表结构设计.md
+++ b/code/fms/FMS新系统核心表结构设计.md
@@ -582,7 +582,7 @@ create index ix_s_i18n_locale on s_i18n (b_locale, b_canuse);
 create table s_log_technical (
     b_id bigint not null,                -- 雪花主键(应用层生成,全局唯一)
     b_user_id bigint null,                              -- 操作用户(逻辑外键)
-    b_account varchar(50) null,                         -- 登录名快照
+    b_account nvarchar(50) null,                        -- 登录名快照
     b_sourcetype varchar(20) null,                      -- 来源类型:web/api
     b_loglevel varchar(16) not null,                    -- 级别:INFO/WARN/ERROR
     b_logcategory varchar(64) null,                     -- 分类:sql/exception/performance
@@ -613,7 +613,7 @@ create index ix_s_log_technical_level on s_log_technical (b_loglevel, b_occurdat
 create table s_log_login (
     b_id bigint not null,                -- 雪花主键(应用层生成,全局唯一)
     b_user_id bigint null,                              -- 用户(逻辑外键)
-    b_account varchar(50) null,                         -- 登录名
+    b_account nvarchar(50) null,                        -- 登录名
     b_login_type varchar(20) not null,                  -- 登录类型:password/token/oidc/ldap
     b_login_result int not null default 0,              -- 登录结果:0失败/1成功
     b_fail_reason nvarchar(200) null,                   -- 失败原因
diff --git a/code/fms/_debug_subtable_designer.png b/code/fms/_debug_subtable_designer.png
deleted file mode 100644
index 2a41078e..00000000
Binary files a/code/fms/_debug_subtable_designer.png and /dev/null differ
diff --git a/code/fms/_debug_subtable_final.png b/code/fms/_debug_subtable_final.png
deleted file mode 100644
index 8c7dc8a6..00000000
Binary files a/code/fms/_debug_subtable_final.png and /dev/null differ
diff --git a/code/fms/_debug_subtable_fixed.png b/code/fms/_debug_subtable_fixed.png
deleted file mode 100644
index eb53dd74..00000000
Binary files a/code/fms/_debug_subtable_fixed.png and /dev/null differ
diff --git a/code/fms/fms-api/config/migrations/003_create_login_log.sql b/code/fms/fms-api/config/migrations/003_create_login_log.sql
index 00b8d65a..ab05c106 100644
--- a/code/fms/fms-api/config/migrations/003_create_login_log.sql
+++ b/code/fms/fms-api/config/migrations/003_create_login_log.sql
@@ -19,7 +19,7 @@ BEGIN TRANSACTION;
         CREATE TABLE dbo.s_log_login (
             b_id              bigint        NOT NULL,
             b_user_id         bigint        NULL,
-            b_account         varchar(50)   NULL,
+            b_account         nvarchar(50)  NULL,
             b_login_type      varchar(20)   NOT NULL, -- 登录类型:password/token/oidc/ldap
             b_login_result    int           NOT NULL CONSTRAINT DF_s_log_login_result    DEFAULT (0),
             b_fail_reason     nvarchar(200) NULL,
diff --git a/code/fms/fms-api/config/migrations/040_log_account_to_nvarchar.sql b/code/fms/fms-api/config/migrations/040_log_account_to_nvarchar.sql
new file mode 100644
index 00000000..351f9d02
--- /dev/null
+++ b/code/fms/fms-api/config/migrations/040_log_account_to_nvarchar.sql
@@ -0,0 +1,46 @@
+/*
+  日志表账号列改为 nvarchar:s_log_login.b_account / s_log_technical.b_account 原为 varchar(50),
+  中文账号写入会因代码页转换变为 '?'(b_user.b_account 本身是 nvarchar,账号支持中文,见
+  100_initialize_fms_new.sql)。改为 nvarchar(50) 与用户表对齐。
+
+  可重复执行:仅在列存在且当前类型为 varchar 时才 ALTER。
+  s_log_login.b_account 上有索引 IX_s_log_login_account,SQL Server 修改列类型前需先删除、改完重建。
+*/
+SET NOCOUNT ON;
+SET XACT_ABORT ON;
+
+BEGIN TRANSACTION;
+
+    IF OBJECT_ID(N'dbo.s_log_login', N'U') IS NOT NULL
+       AND EXISTS (
+           SELECT 1
+           FROM sys.columns c
+           JOIN sys.types t ON t.user_type_id = c.user_type_id
+           WHERE c.object_id = OBJECT_ID(N'dbo.s_log_login')
+             AND c.name = N'b_account'
+             AND t.name = N'varchar'
+       )
+    BEGIN
+        IF OBJECT_ID(N'dbo.IX_s_log_login_account', N'INDEX') IS NOT NULL
+            DROP INDEX IX_s_log_login_account ON dbo.s_log_login;
+
+        ALTER TABLE dbo.s_log_login ALTER COLUMN b_account nvarchar(50) NULL;
+
+        IF OBJECT_ID(N'dbo.IX_s_log_login_account', N'INDEX') IS NULL
+            CREATE INDEX IX_s_log_login_account ON dbo.s_log_login (b_account, b_occurdatetime DESC);
+    END
+
+    IF OBJECT_ID(N'dbo.s_log_technical', N'U') IS NOT NULL
+       AND EXISTS (
+           SELECT 1
+           FROM sys.columns c
+           JOIN sys.types t ON t.user_type_id = c.user_type_id
+           WHERE c.object_id = OBJECT_ID(N'dbo.s_log_technical')
+             AND c.name = N'b_account'
+             AND t.name = N'varchar'
+       )
+    BEGIN
+        ALTER TABLE dbo.s_log_technical ALTER COLUMN b_account nvarchar(50) NULL;
+    END
+
+COMMIT TRANSACTION;
diff --git a/code/fms/fms-api/config/migrations/041_add_s_i18n_module_id.sql b/code/fms/fms-api/config/migrations/041_add_s_i18n_module_id.sql
new file mode 100644
index 00000000..7d7dd2ba
--- /dev/null
+++ b/code/fms/fms-api/config/migrations/041_add_s_i18n_module_id.sql
@@ -0,0 +1,75 @@
+/*
+  为 dbo.s_i18n 增加 b_module_id 列,使多语言数据可按模块级联删除/查询,
+  解决「删除模块后 s_i18n 残留孤儿数据」的问题(前端按 b_key 前缀匹配清理易漏)。
+
+  与 006_add_i18n_module_id.sql 的区别:006 作用于旧表 dbo.b_i18n;
+  本脚本作用于当前前端实际使用的 dbo.s_i18n。
+
+  本脚本可重复执行:
+  - 列不存在时才添加,并建索引;
+  - 对存量数据做一次基于键命名约定的回填(module./menu./field./power./group. 前缀,
+    取第二段作为模块编码关联 s_module.b_code),已回填或无法匹配的行不受影响。
+  - 通用文案键(common.* 等)不关联模块,b_module_id 保持 NULL。
+*/
+SET NOCOUNT ON;
+
+-- 第 1 批:DDL(列与索引)
+BEGIN TRANSACTION;
+
+    IF NOT EXISTS (
+        SELECT 1 FROM sys.columns
+        WHERE object_id = OBJECT_ID(N'dbo.s_i18n') AND name = N'b_module_id'
+    )
+    BEGIN
+        ALTER TABLE dbo.s_i18n
+            ADD b_module_id bigint NULL;
+
+        CREATE INDEX IX_s_i18n_module_id ON dbo.s_i18n (b_module_id)
+            INCLUDE (b_key, b_locale, b_value);
+    END
+
+COMMIT TRANSACTION;
+GO
+
+-- 第 2 批:存量回填(依赖第 1 批列已存在,因此必须 GO 分隔)
+SET NOCOUNT ON;
+
+BEGIN TRANSACTION;
+
+    -- 键形如 module. / menu. / field.. / power..
+    -- / group..,模块编码恒为第二个以 "." 分隔的片段。
+    UPDATE i
+    SET b_module_id = m.b_id
+    FROM dbo.s_i18n i
+    CROSS APPLY (
+        SELECT
+            CASE
+                WHEN CHARINDEX(N'.', i.b_key) > 0 THEN
+                    SUBSTRING(
+                        i.b_key,
+                        CHARINDEX(N'.', i.b_key) + 1,
+                        CASE
+                            WHEN CHARINDEX(N'.', i.b_key, CHARINDEX(N'.', i.b_key) + 1) > 0
+                                THEN CHARINDEX(N'.', i.b_key, CHARINDEX(N'.', i.b_key) + 1)
+                                     - CHARINDEX(N'.', i.b_key) - 1
+                            ELSE LEN(i.b_key) - CHARINDEX(N'.', i.b_key)
+                        END
+                    )
+                ELSE NULL
+            END AS code
+    ) c
+    CROSS APPLY (
+        SELECT TOP (1) sm.b_id
+        FROM dbo.s_module sm
+        WHERE sm.b_code = c.code
+    ) m
+    WHERE i.b_module_id IS NULL
+      AND (
+            i.b_key LIKE N'module.%'
+         OR i.b_key LIKE N'menu.%'
+         OR i.b_key LIKE N'field.%'
+         OR i.b_key LIKE N'power.%'
+         OR i.b_key LIKE N'group.%'
+      );
+
+COMMIT TRANSACTION;
diff --git a/code/fms/fms-api/config/migrations/100_initialize_fms_new.sql b/code/fms/fms-api/config/migrations/100_initialize_fms_new.sql
index 4813c15c..79f9d8b1 100644
--- a/code/fms/fms-api/config/migrations/100_initialize_fms_new.sql
+++ b/code/fms/fms-api/config/migrations/100_initialize_fms_new.sql
@@ -416,7 +416,7 @@ BEGIN
     CREATE TABLE dbo.s_log_technical (
         b_id bigint NOT NULL, -- 雪花主键(应用层生成,全局唯一)
         b_user_id bigint NULL,
-        b_account varchar(50) NULL,
+        b_account nvarchar(50) NULL,
         b_sourcetype varchar(20) NULL,
         b_loglevel varchar(16) NOT NULL,
         b_logcategory varchar(64) NULL,
@@ -443,7 +443,7 @@ BEGIN
     CREATE TABLE dbo.s_log_login (
         b_id bigint NOT NULL, -- 雪花主键(应用层生成,全局唯一)
         b_user_id bigint NULL,
-        b_account varchar(50) NULL,
+        b_account nvarchar(50) NULL,
         b_login_type varchar(20) NOT NULL,
         b_login_result int NOT NULL CONSTRAINT DF_s_log_login_result DEFAULT (0),
         b_fail_reason nvarchar(200) NULL,
diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.java
index 2b251d83..9d566ae9 100644
--- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.java
+++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.java
@@ -42,7 +42,15 @@ public class GlobalExceptionHandler {
         details.put("table", exception.getTable());
         details.put("action", exception.getAction());
         details.put("index", exception.getIndex());
-        return new ApiResponse<>(ApiResponse.SAVE_ERROR_CODE, "保存失败", details);
+        String rootCause = exception.rootCauseMessage();
+        if (rootCause != null) {
+            details.put("cause", rootCause);
+        }
+        return new ApiResponse<>(
+                ApiResponse.SAVE_ERROR_CODE,
+                rootCause == null ? "保存失败" : "保存失败: " + rootCause,
+                details
+        );
     }
 
     @ExceptionHandler({SQLException.class, DataAccessException.class})
diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/SaveObjectException.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/SaveObjectException.java
index 3e2d2c3c..d2f94418 100644
--- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/SaveObjectException.java
+++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/SaveObjectException.java
@@ -29,4 +29,16 @@ public class SaveObjectException extends RuntimeException {
     public int getIndex() {
         return index;
     }
+
+    /**
+     * 根因摘要(如 SQLServerException 的 "Violation of UNIQUE constraint ..."),
+     * 用于接口响应中提示具体失败原因,避免只看到笼统的「保存失败」。
+     */
+    public String rootCauseMessage() {
+        Throwable root = getCause();
+        while (root != null && root.getCause() != null) {
+            root = root.getCause();
+        }
+        return root == null ? null : root.getMessage();
+    }
 }
diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.java
index 7ad9b9bb..84bd86f9 100644
--- a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.java
+++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.java
@@ -39,9 +39,10 @@ class GlobalExceptionHandlerTests {
         ApiResponse> response = handler.handleSaveObjectException(exception);
 
         assertThat(response.code()).isEqualTo(ApiResponse.SAVE_ERROR_CODE);
-        assertThat(response.message()).isEqualTo("保存失败");
+        assertThat(response.message()).isEqualTo("保存失败: update failed");
         assertThat(response.data()).containsEntry("table", "bs_business");
         assertThat(response.data()).containsEntry("action", "update");
         assertThat(response.data()).containsEntry("index", 2);
+        assertThat(response.data()).containsEntry("cause", "update failed");
     }
 }
diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.class
index a8491ec8..6dc0382c 100644
Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.class differ
diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/SaveObjectException.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/SaveObjectException.class
index d85f3b67..b1e39134 100644
Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/SaveObjectException.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/SaveObjectException.class differ
diff --git a/code/fms/fms-api/target/surefire-reports/2026-08-31T17-07-39_468.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-08-31T17-07-39_468.dumpstream
new file mode 100644
index 00000000..0ef48c69
--- /dev/null
+++ b/code/fms/fms-api/target/surefire-reports/2026-08-31T17-07-39_468.dumpstream
@@ -0,0 +1,5 @@
+# Created at 2026-08-31T17:07:39.834
+Boot Manifest-JAR contains absolute paths in classpath 'D:\workspace\code\fms\fms-api\target\test-classes'
+Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true
+'other' has different root
+
diff --git a/code/fms/fms-api/target/surefire-reports/2026-08-31T17-07-58_994.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-08-31T17-07-58_994.dumpstream
new file mode 100644
index 00000000..8a62948d
--- /dev/null
+++ b/code/fms/fms-api/target/surefire-reports/2026-08-31T17-07-58_994.dumpstream
@@ -0,0 +1,5 @@
+# Created at 2026-08-31T17:07:59.323
+Boot Manifest-JAR contains absolute paths in classpath 'D:\workspace\code\fms\fms-api\target\test-classes'
+Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true
+'other' has different root
+
diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.xml
index 84f51e8e..46abc5a8 100644
--- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.xml
+++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.xml
@@ -1,66 +1,66 @@
 
-
+
   
     
     
     
-    
-    
+    
+    
     
     
-    
+    
     
-    
+    
     
     
     
-    
-    
+    
+    
     
-    
+    
+    
     
-    
+    
     
     
-    
-    
+    
+    
     
     
     
     
     
     
-    
+    
     
     
-    
-    
+    
+    
     
     
     
     
     
     
-    
-    
-    
-    
-    
+    
+    
+    
+    
     
     
     
     
     
-    
+    
     
     
-    
-    
+    
+    
     
     
   
-  
-    
+    
   
-  
-    
+    
+/**
+ * 未保存修改确认弹窗(通用)。
+ * 场景:对象(模块/页签等)存在未保存修改时,离开/切换/关闭前询问用户如何处理。
+ * 纯受控展示组件:父组件持有 open/saving 状态,接收 save/discard 事件自行执行
+ * 保存与放弃逻辑,完成后自行关闭(update:open)。
+ *
+ * 用法:
+ *   
+ */
+import { Modal, Button } from '@/components/ui'
+
+defineOptions({ inheritAttrs: false })
+
+const props = defineProps({
+  /** 受控打开状态 */
+  open: { type: Boolean, default: false },
+  /** 当前对象名称(用于「「xxx」有未保存的修改」文案) */
+  name: { type: String, default: '' },
+  /** 保存进行中:主按钮转圈、锁定弹窗(阻止一切关闭入口) */
+  saving: { type: Boolean, default: false },
+  /** 保存按钮文案 */
+  saveText: { type: String, default: '保存并继续' },
+  /** 放弃修改按钮文案 */
+  discardText: { type: String, default: '放弃修改' },
+  /** 描述文案;缺省按 name 生成 */
+  description: { type: String, default: '' },
+  /** 自定义标题 */
+  title: { type: String, default: '有未保存的修改' },
+})
+
+const emit = defineEmits(['update:open', 'save', 'discard'])
+
+function close() {
+  if (props.saving) return
+  emit('update:open', false)
+}
+
+function onSave() {
+  emit('save')
+}
+
+function onDiscard() {
+  emit('discard')
+}
+
+
+
diff --git a/code/fms/fms-vue/src/components/fms-table/FmsTable.vue b/code/fms/fms-vue/src/components/fms-table/FmsTable.vue
index 6264eb36..8f4838fa 100644
--- a/code/fms/fms-vue/src/components/fms-table/FmsTable.vue
+++ b/code/fms/fms-vue/src/components/fms-table/FmsTable.vue
@@ -40,8 +40,8 @@ const props = defineProps({
     validator: (value) => value === 'light' || value === 'dark',
   },
   // 行高:唯一真源,同时驱动虚拟滚动计算与库注入的 --row-height 等 CSS 变量,外部勿用 CSS 覆盖
-  rowHeight: { type: Number, default: 34 },
-  headerRowHeight: { type: Number, default: 40 },
+  rowHeight: { type: Number, default: 30 },
+  headerRowHeight: { type: Number, default: 34 },
 })
 
 const emit = defineEmits(['update:columns', 'change', 'row-order-change'])
diff --git a/code/fms/fms-vue/src/views/system/module-management/components/ModuleAutoCodePanel.vue b/code/fms/fms-vue/src/views/system/module-management/ModuleAutoCodePanel.vue
similarity index 100%
rename from code/fms/fms-vue/src/views/system/module-management/components/ModuleAutoCodePanel.vue
rename to code/fms/fms-vue/src/views/system/module-management/ModuleAutoCodePanel.vue
diff --git a/code/fms/fms-vue/src/views/system/module-management/components/ModuleBasicPanel.vue b/code/fms/fms-vue/src/views/system/module-management/ModuleBasicPanel.vue
similarity index 76%
rename from code/fms/fms-vue/src/views/system/module-management/components/ModuleBasicPanel.vue
rename to code/fms/fms-vue/src/views/system/module-management/ModuleBasicPanel.vue
index dda3157f..50863aef 100644
--- a/code/fms/fms-vue/src/views/system/module-management/components/ModuleBasicPanel.vue
+++ b/code/fms/fms-vue/src/views/system/module-management/ModuleBasicPanel.vue
@@ -5,10 +5,8 @@
  * category 分类节点 / data 数据模块 / query 查询模块。
  */
 import { computed, ref, watch } from 'vue'
-import { Languages } from '@lucide/vue'
-import { Input, Select, Switch, Form, FormItem, Button } from '@/components/ui'
+import { Input, Select, Switch, Form, FormItem } from '@/components/ui'
 import { buildModuleI18nKey, validateModuleCode } from '@/utils/i18n'
-import I18nQuickSetModal from './I18nQuickSetModal.vue'
 
 const props = defineProps({
   data: { type: Object, default: null },
@@ -16,7 +14,7 @@ const props = defineProps({
   loading: { type: Boolean, default: false },
 })
 
-const emit = defineEmits(['update', 'i18nSaved'])
+const emit = defineEmits(['update'])
 
 const formData = ref(null)
 
@@ -35,10 +33,11 @@ const typeOptions = [
   { label: '查询模块', value: 'query' },
 ]
 
+// 取值与设计文档/CK_s_module_delete_policy 对齐(模块层无 cascade,级联由 s_relation 表达)
 const deletePolicyOptions = [
-  { label: '禁止删除(restrict)', value: 'restrict' },
-  { label: '生命周期(lifecycle)', value: 'lifecycle' },
-  { label: '物理删除(hard)', value: 'hard' },
+  { label: 'restrict · 主数据', value: 'restrict' },
+  { label: 'lifecycle · 业务单据', value: 'lifecycle' },
+  { label: 'hard · 临时记录', value: 'hard' },
 ]
 
 const isCategory = computed(() => formData.value?.b_module_type === 'category')
@@ -47,7 +46,7 @@ const isQuery = computed(() => formData.value?.b_module_type === 'query')
 
 /* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
 // data/query 的必填项与数据库 CK_s_module_config 约束对齐:
-// data:查询视图/保存表/主键字段/删除策略全必填;query:仅查询视图必填(主键字段可选)
+// data:查询视图/保存表/主键字段必填;query:仅查询视图必填(主键字段可选)
 const formRules = computed(() => ({
   b_code: [
     {
@@ -65,6 +64,7 @@ const formRules = computed(() => ({
     isData.value || isQuery.value ? [{ required: true, message: '请填写查询视图' }] : [],
   b_keyfield: isData.value ? [{ required: true, message: '请填写主键字段' }] : [],
   b_savetable: isData.value ? [{ required: true, message: '请填写保存表' }] : [],
+  // CK_s_module_config 要求 data 的 b_delete_policy 非空、category/query 必须为空
   b_delete_policy: isData.value ? [{ required: true, message: '请选择删除策略' }] : [],
 }))
 
@@ -115,19 +115,6 @@ function onModuleTypeChange(v) {
   emitUpdate()
 }
 
-const i18nModalRef = ref(null)
-
-function openI18nModal() {
-  if (!formData.value || !i18nModalRef.value) return
-  const key =
-    formData.value.b_i18n || buildModuleI18nKey(formData.value.b_module_type, formData.value.b_code)
-  i18nModalRef.value.open(key, formData.value.b_name || '')
-}
-
-function onI18nSaved(key) {
-  setField('b_i18n', key)
-  emit('i18nSaved', key)
-}
 
 
 
 
diff --git a/code/fms/fms-vue/src/views/system/module-management/components/ModuleI18nPanel.vue b/code/fms/fms-vue/src/views/system/module-management/ModuleI18nPanel.vue
similarity index 92%
rename from code/fms/fms-vue/src/views/system/module-management/components/ModuleI18nPanel.vue
rename to code/fms/fms-vue/src/views/system/module-management/ModuleI18nPanel.vue
index c663dde4..dee61772 100644
--- a/code/fms/fms-vue/src/views/system/module-management/components/ModuleI18nPanel.vue
+++ b/code/fms/fms-vue/src/views/system/module-management/ModuleI18nPanel.vue
@@ -9,9 +9,14 @@ import { Message, Button, Input } from '@/components/ui'
 import FmsTable from '@/components/fms-table/FmsTable.vue'
 import { createTempId } from '@/utils/tempId'
 import { cloneData } from '@/utils/dataChanges'
-import { consolidatePivotRows } from '@/utils/i18n'
+import { consolidatePivotRows, getI18nNamespace } from '@/utils/i18n'
 import { loadDataApi } from '@/services/api'
-import { DEFAULT_LANGUAGES } from '../common'
+// 前端默认语言列表(默认语言排第一)。
+// 后续若引入语言类型表(s_i18n_type)再改为接口加载。
+const DEFAULT_LANGUAGES = [
+  { code: 'zh-CN', name: '简体中文', default: true },
+  { code: 'en-US', name: 'English', default: false },
+]
 
 const props = defineProps({
   moduleId: { type: String, required: true },
@@ -257,6 +262,13 @@ function buildSaveRequest() {
   const inserts = []
   const updates = []
   const deletes = []
+  // 模块命名空间:这些键属于当前模块元数据,落库时写入 b_module_id 便于按模块级联删除;
+  // 其余(common.* 等通用文案)b_module_id 保持 NULL,不归属任何模块
+  const MODULE_NAMESPACES = ['module', 'menu', 'field', 'power', 'group']
+  function moduleIdOf(key) {
+    const ns = getI18nNamespace(key)
+    return MODULE_NAMESPACES.includes(ns) ? props.moduleId : null
+  }
   const originals = new Map(
     originalsFlat.value.map((r) => [`${r.b_key}|${r.b_locale}`, r]),
   )
@@ -273,7 +285,14 @@ function buildSaveRequest() {
       const localeId = row._localeIds?.[locale]
       if (localeId) {
         if (!original || String(original.b_value ?? '') !== value) {
-          updates.push({ b_id: localeId, b_key: key, b_locale: locale, b_value: value, b_canuse: 1 })
+          updates.push({
+            b_id: localeId,
+            b_key: key,
+            b_locale: locale,
+            b_value: value,
+            b_module_id: moduleIdOf(key),
+            b_canuse: 1,
+          })
         }
       } else if (value !== '') {
         inserts.push({
@@ -285,6 +304,7 @@ function buildSaveRequest() {
           b_key: key,
           b_locale: locale,
           b_value: value,
+          b_module_id: moduleIdOf(key),
           b_canuse: 1,
         })
       }
@@ -331,6 +351,7 @@ defineExpose({
   removeByManagedIds,
   reconcileManagedRows,
   applyInsertedIds,
+  loadI18n,
 })
 
 
diff --git a/code/fms/fms-vue/src/views/system/module-management/ModuleListConfigPanel.vue b/code/fms/fms-vue/src/views/system/module-management/ModuleListConfigPanel.vue
new file mode 100644
index 00000000..553f764b
--- /dev/null
+++ b/code/fms/fms-vue/src/views/system/module-management/ModuleListConfigPanel.vue
@@ -0,0 +1,46 @@
+
+
+
diff --git a/code/fms/fms-vue/src/views/system/module-management/components/ModulePowersPanel.vue b/code/fms/fms-vue/src/views/system/module-management/ModulePowersPanel.vue
similarity index 73%
rename from code/fms/fms-vue/src/views/system/module-management/components/ModulePowersPanel.vue
rename to code/fms/fms-vue/src/views/system/module-management/ModulePowersPanel.vue
index 0971dec7..32f9d88c 100644
--- a/code/fms/fms-vue/src/views/system/module-management/components/ModulePowersPanel.vue
+++ b/code/fms/fms-vue/src/views/system/module-management/ModulePowersPanel.vue
@@ -3,13 +3,11 @@
  * 权限配置面板(s_power):模块操作权限点(b_power_type=action)。
  * i18n 键按 power.{moduleCode}.{powerCode} 自动同步。
  */
-import { computed, h, nextTick, ref, shallowRef, watch } from 'vue'
-import { Languages } from '@lucide/vue'
+import { computed, nextTick, ref, shallowRef, watch } from 'vue'
 import { Message, Modal, Button } from '@/components/ui'
 import FmsTable from '@/components/fms-table/FmsTable.vue'
-import { buildModuleItemI18nKey, syncModuleItemI18nKeys } from '@/utils/i18n'
+import { syncModuleItemI18nKeys } from '@/utils/i18n'
 import { createTempId } from '@/utils/tempId'
-import I18nQuickSetModal from './I18nQuickSetModal.vue'
 
 const props = defineProps({
   moduleId: { type: String, required: true },
@@ -24,7 +22,7 @@ const props = defineProps({
   i18nPrefix: { type: String, default: 'power' },
 })
 
-const emit = defineEmits(['update', 'i18nSaved'])
+const emit = defineEmits(['update'])
 
 const localPowers = ref([])
 const activeKey = ref(null)
@@ -101,37 +99,10 @@ function onCellChange() {
   emitUpdate()
 }
 
-function openI18n(row) {
-  const key = buildModuleItemI18nKey(props.i18nPrefix, props.moduleCode, row.b_code)
-  if (!key) {
-    Message.warning('请先填写操作编码')
-    return
-  }
-  i18nModalRef.value?.open(key, row.b_name || '')
-}
-
-const I18nKeyCell = ({ row, cellValue }) =>
-  h('span', { class: 'mm-i18n-cell' }, [
-    h('span', { class: 'mm-i18n-cell__text' }, cellValue ?? ''),
-    h(
-      'button',
-      {
-        type: 'button',
-        title: '设置多语言',
-        class: 'mm-i18n-cell__btn',
-        onClick: (event) => {
-          event.stopPropagation()
-          openI18n(row)
-        },
-      },
-      [h(Languages, { size: 14 })],
-    ),
-  ])
-
 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, customCell: I18nKeyCell },
+  { dataIndex: 'b_i18n', title: '多语言键', width: 200 },
   { dataIndex: 'b_xh', title: '排序', width: 90, editor: { type: 'input', inputType: 'number' } },
   {
     dataIndex: 'b_canuse',
@@ -177,7 +148,6 @@ const columns = shallowRef([
         @change="onCellChange"
       />
     
-    
   
 
 
@@ -204,36 +174,3 @@ const columns = shallowRef([
   flex: 1;
 }
 
-
-
-
diff --git a/code/fms/fms-vue/src/views/system/module-management/components/ModuleQueryConfigPanel.vue b/code/fms/fms-vue/src/views/system/module-management/ModuleQueryConfigPanel.vue
similarity index 99%
rename from code/fms/fms-vue/src/views/system/module-management/components/ModuleQueryConfigPanel.vue
rename to code/fms/fms-vue/src/views/system/module-management/ModuleQueryConfigPanel.vue
index 536c2bb0..b8bd924d 100644
--- a/code/fms/fms-vue/src/views/system/module-management/components/ModuleQueryConfigPanel.vue
+++ b/code/fms/fms-vue/src/views/system/module-management/ModuleQueryConfigPanel.vue
@@ -9,7 +9,7 @@ import { computed, nextTick, ref, shallowRef, watch } from 'vue'
 import { Button } from '@/components/ui'
 import FmsTable from '@/components/fms-table/FmsTable.vue'
 import { createTempId } from '@/utils/tempId'
-import { queryRowDefaults } from '../fieldAreaDefaults'
+import { queryRowDefaults } from './components/fieldDefaults'
 
 const props = defineProps({
   moduleId: { type: String, required: true },
diff --git a/code/fms/fms-vue/src/views/system/module-management/common.js b/code/fms/fms-vue/src/views/system/module-management/common.js
deleted file mode 100644
index 08304311..00000000
--- a/code/fms/fms-vue/src/views/system/module-management/common.js
+++ /dev/null
@@ -1,6 +0,0 @@
-// 前端默认语言列表(默认语言排第一)。
-// 后续若引入语言类型表(s_i18n_type)再改为接口加载。
-export const DEFAULT_LANGUAGES = [
-  { code: 'zh-CN', name: '简体中文', default: true },
-  { code: 'en-US', name: 'English', default: false },
-]
diff --git a/code/fms/fms-vue/src/views/system/module-management/components/I18nKeyCell.js b/code/fms/fms-vue/src/views/system/module-management/components/I18nKeyCell.js
deleted file mode 100644
index 5c5e3ee6..00000000
--- a/code/fms/fms-vue/src/views/system/module-management/components/I18nKeyCell.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * 多语言键单元格(h 渲染函数):key 文本 + globe 快捷设置按钮。
- * 供字段定义 / 列表配置等 FmsTable 列的 customCell 复用。
- * 表格 cell 由 h() 动态渲染、不受 scoped 样式约束,样式放独立 scss 随本模块全局引入。
- */
-import { h } from 'vue'
-import { Languages } from '@lucide/vue'
-import './i18n-key-cell.scss'
-
-/**
- * @param {object} opts
- * @param {(row: object) => void} opts.onOpen 点击 globe 打开快捷设置
- * @param {(row: object) => string} [opts.textOf] 单元格显示值(缺省用 cellValue)
- * @param {(row: object) => boolean} [opts.isMuted] 显示为继承/占位样式(灰色)
- */
-export function createI18nKeyCell({ onOpen, textOf, isMuted }) {
-  return ({ row, cellValue }) => {
-    const text = textOf ? textOf(row) : cellValue || ''
-    return h('span', { class: 'mm-i18n-cell' }, [
-      h(
-        'span',
-        { class: ['mm-i18n-cell__text', isMuted?.(row) ? 'is-muted' : ''] },
-        text || '',
-      ),
-      h(
-        'button',
-        {
-          type: 'button',
-          title: '设置多语言',
-          class: 'mm-i18n-cell__btn',
-          onClick: (event) => {
-            event.stopPropagation()
-            onOpen(row)
-          },
-        },
-        [h(Languages, { size: 14 })],
-      ),
-    ])
-  }
-}
diff --git a/code/fms/fms-vue/src/views/system/module-management/components/I18nQuickSetModal.vue b/code/fms/fms-vue/src/views/system/module-management/components/I18nQuickSetModal.vue
deleted file mode 100644
index 7a95dbf8..00000000
--- a/code/fms/fms-vue/src/views/system/module-management/components/I18nQuickSetModal.vue
+++ /dev/null
@@ -1,135 +0,0 @@
-
-
-
-
-
diff --git a/code/fms/fms-vue/src/views/system/module-management/components/ModuleListConfigPanel.vue b/code/fms/fms-vue/src/views/system/module-management/components/ModuleListConfigPanel.vue
deleted file mode 100644
index 8199ba22..00000000
--- a/code/fms/fms-vue/src/views/system/module-management/components/ModuleListConfigPanel.vue
+++ /dev/null
@@ -1,574 +0,0 @@
-
-
-
-
-
diff --git a/code/fms/fms-vue/src/views/system/module-management/components/form-edit/FormEditPanel.vue b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/FormDesignEditor.vue
similarity index 98%
rename from code/fms/fms-vue/src/views/system/module-management/components/form-edit/FormEditPanel.vue
rename to code/fms/fms-vue/src/views/system/module-management/components/design-editor/FormDesignEditor.vue
index f6a5e62e..90a8c37f 100644
--- a/code/fms/fms-vue/src/views/system/module-management/components/form-edit/FormEditPanel.vue
+++ b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/FormDesignEditor.vue
@@ -1,10 +1,12 @@
 
 
 
             
@@ -311,7 +402,7 @@ function switchView(mode) {
           :visual-columns="visualColumns"
           :band-rows="bandRows"
           :header-columns="headerColumns"
-          :ungrouped-count="groupColumnsMap.ungrouped.length"
+          :ungrouped-count="ungroupedCount"
           :selected-node-key="selectedNodeKey"
           :loading="loading"
           :width-of="widthOf"
@@ -320,6 +411,7 @@ function switchView(mode) {
           :group-id-of-row="groupIdOfRow"
           :commit-col-drag="commitColDrag"
           :on-commit="emitUpdate"
+          :empty-text="isSubtable ? undefined : '暂无列配置'"
         />
 
         
@@ -329,14 +421,15 @@ function switchView(mode) { {{ groupCount }} 个分组
- 暂无分组,点击工具栏「添加分组」 - +
@@ -452,7 +547,10 @@ function switchView(mode) { line-height: 1; padding-bottom: 1px; cursor: help; - transition: border-color 0.15s, color 0.15s, background 0.15s; + transition: + border-color 0.15s, + color 0.15s, + background 0.15s; &:hover { border-color: color-mix(in srgb, var(--fms-primary) 45%, transparent); background: color-mix(in srgb, var(--fms-primary) 5%, var(--fms-card)); diff --git a/code/fms/fms-vue/src/views/system/module-management/components/edit-panels/EditDataView.vue b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/EditDataView.vue similarity index 100% rename from code/fms/fms-vue/src/views/system/module-management/components/edit-panels/EditDataView.vue rename to code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/EditDataView.vue diff --git a/code/fms/fms-vue/src/views/system/module-management/components/edit-panels/FieldLibrary.vue b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/FieldLibrary.vue similarity index 99% rename from code/fms/fms-vue/src/views/system/module-management/components/edit-panels/FieldLibrary.vue rename to code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/FieldLibrary.vue index 83bee0fa..cec2ec4b 100644 --- a/code/fms/fms-vue/src/views/system/module-management/components/edit-panels/FieldLibrary.vue +++ b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/FieldLibrary.vue @@ -12,7 +12,7 @@ import { computed, ref } from 'vue' import { Input } from '@/components/ui' import draggable from 'vuedraggable' -import { filterLibraryFields } from '../../editPanelShared' +import { filterLibraryFields } from './useStructureEditing' const props = defineProps({ /** 模块全部字段 */ diff --git a/code/fms/fms-vue/src/views/system/module-management/composables/useStructureEditing.js b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/useStructureEditing.js similarity index 61% rename from code/fms/fms-vue/src/views/system/module-management/composables/useStructureEditing.js rename to code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/useStructureEditing.js index 6b870603..266b5a47 100644 --- a/code/fms/fms-vue/src/views/system/module-management/composables/useStructureEditing.js +++ b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/common/useStructureEditing.js @@ -1,10 +1,17 @@ /** - * 结构编辑内核(分组树 + 行归属 + 平铺重编 + 选中态 + 列属性剪贴板)。 - * 由 TableEditPanel(子表设计,s_field_edit 行)与 ModuleListConfigPanel(列表配置, - * s_field_view 行)共用:宿主只注入数据源(getter)、行工厂(差异字段)、 - * 提交回调(转 emit)与重命名请求(转发结构树 startRename)。 + * 设计器内核(design-editor 唯一共享 js,纯编辑器逻辑、不与业务打交道)。 + * 由 TableDesignEditor(子表/列表表格设计)与 FormEditPanel(表单画布设计)共用,按区块划分: * - * 语义约定: + * 1. 分组树推导 buildGroupTree / groupIdOfRow / useGroupTree + * 2. 字段查询 useFieldLookup(字段名/字段对象 Map、控件类型标签) + * 3. 多级表头布局 useBandLayout(visualColumns / bandRows / headerColumns) + * 4. 行编辑纯函数 renumberRows / 剪贴板 / toggleFlag / filterLibraryFields / createEditRowFactory + * 5. 结构编辑内核 useStructureEditing(分组树 + 行归属 + 平铺重编 + 选中态 + 列属性剪贴板) + * + * 业务默认策略(listRowDefaults / queryRowDefaults / createListConfigRow / createQueryConfigRow) + * 已拆至 ../fieldDefaults.js(业务层);编辑器内核不依赖它——差异行工厂由宿主注入。 + * + * 语义约定(useStructureEditing): * - 分组 b_xh 为「兄弟内编号」((i+1)*10),不同层可重复; * - 行 b_xh 为「未分组在前 + 分组树先序」的全局平铺序,与运行时列序一致; * - 删除分组:直接子分组提升为被删分组的父级,组内行归未分组;级联清理其它配置 @@ -13,8 +20,348 @@ import { computed, nextTick, ref, watch } from 'vue' import { Message } from '@/components/ui' import { createTempId } from '@/utils/tempId' -import { useGroupTree, buildGroupTree } from './useGroupTree' -import { applyClipboardProps, buildClipboardProps, renumberRows } from '../editPanelShared' + +/* ============================================================ + * 1. 分组树推导(s_field_group 分组扁平数组 → 树 + 多级表头 band 元信息) + * ============================================================ */ + +// toSorted 返回新数组,必须回写原数组,否则 buildGroupTree 的排序不生效 +function sortGroupNodesByXh(nodes) { + const sorted = nodes.toSorted((a, b) => (a.group.b_xh || 0) - (b.group.b_xh || 0)) + nodes.splice(0, nodes.length, ...sorted) + return nodes +} + +export function buildGroupTree(groups) { + const map = new Map() + const roots = [] + for (const g of groups || []) { + map.set(String(g.b_id), { group: g, children: [] }) + } + for (const g of groups || []) { + const node = map.get(String(g.b_id)) + const parentId = g.b_parent_id == null ? null : String(g.b_parent_id) + if (parentId != null && map.has(parentId)) map.get(parentId).children.push(node) + else roots.push(node) + } + sortGroupNodesByXh(roots) + const walk = (nodes) => { + for (const node of nodes) { + sortGroupNodesByXh(node.children) + walk(node.children) + } + } + walk(roots) + return roots +} + +export function groupIdOfRow(row) { + return row?.b_group_id == null ? null : String(row.b_group_id) +} + +/** + * @param {import('vue').Ref|import('vue').ComputedRef} groupsRef 分组扁平数组 + * @param {import('vue').Ref|import('vue').ComputedRef} rowsRef 编辑行数组 + */ +export function useGroupTree(groupsRef, rowsRef) { + const groupTree = computed(() => buildGroupTree(groupsRef.value)) + + const groupNodeMap = computed(() => { + const map = new Map() + const walk = (nodes) => { + for (const node of nodes) { + map.set(String(node.group.b_id), node) + walk(node.children) + } + } + walk(groupTree.value) + return map + }) + + // groupId → 直属列(不递归收集);未分组列单独收集 + const groupColumnsMap = computed(() => { + const map = new Map() + const ungrouped = [] + for (const row of rowsRef.value || []) { + const gid = groupIdOfRow(row) + if (gid != null && groupNodeMap.value.has(gid)) { + if (!map.has(gid)) map.set(gid, []) + map.get(gid).push(row) + } else { + ungrouped.push(row) + } + } + return { map, ungrouped } + }) + + // 分组带元信息:层级(根=0)、直属列数、子树列数(span,空分组至少 1)、最大层级 + const bandMeta = computed(() => { + const columns = groupColumnsMap.value + const metaMap = new Map() + let maxLevel = 0 + const build = (nodes, level) => + nodes.map((node) => { + const gid = String(node.group.b_id) + const meta = { + gid, + group: node.group, + level, + ownCount: (columns.map.get(gid) || []).length, + children: [], + span: 0, + } + metaMap.set(gid, meta) + meta.children = build(node.children, level + 1) + meta.span = Math.max(1, meta.ownCount + meta.children.reduce((s, c) => s + c.span, 0)) + if (level > maxLevel) maxLevel = level + return meta + }) + const roots = build(groupTree.value, 0) + return { roots, metaMap, maxLevel } + }) + + // gid 是否位于 ancestorGid 的子树内(防拖入自身子树成环) + function isDescendantGroup(ancestorGid, gid) { + const meta = bandMeta.value.metaMap.get(String(gid)) + if (!meta) return false + let cur = meta.group.b_parent_id == null ? null : String(meta.group.b_parent_id) + while (cur != null) { + if (cur === String(ancestorGid)) return true + const m = bandMeta.value.metaMap.get(cur) + if (!m) break + cur = m.group.b_parent_id == null ? null : String(m.group.b_parent_id) + } + return false + } + + return { groupColumnsMap, bandMeta, isDescendantGroup, groupIdOfRow } +} + +/* ============================================================ + * 2. 字段查询与标签共享逻辑 + * ============================================================ */ + +const FIELD_TYPE_LABELS = { + input: '输入框', + textarea: '文本域', + number: '数字', + money: '金额', + date: '日期', + datetime: '日期时间', + checkbox: '复选框', + select: '下拉选择', +} + +/** + * @param {import('vue').Ref|import('vue').ComputedRef|(() => Array)} fieldsRef + * 字段数组 ref/computed,或返回字段数组的 getter 函数 + */ +export function useFieldLookup(fieldsRef) { + const fields = typeof fieldsRef === 'function' ? computed(fieldsRef) : fieldsRef + + const fieldNameMap = computed(() => { + const map = new Map() + for (const field of fields.value || []) { + map.set(String(field.b_id), field.b_name || field.b_field) + } + return map + }) + + const fieldMap = computed(() => { + const map = new Map() + for (const field of fields.value || []) { + map.set(String(field.b_id), field) + } + return map + }) + + function fieldOf(row) { + if (row?.b_field_id == null) return null + return fieldMap.value.get(String(row.b_field_id)) || null + } + + function fieldName(row) { + const fid = row?.b_field_id + if (fid == null) return '' + return fieldNameMap.value.get(String(fid)) || String(fid) + } + + function controlTypeLabel(row) { + const field = fieldOf(row) + return FIELD_TYPE_LABELS[field?.b_type] || field?.b_type || '未知' + } + + return { fieldNameMap, fieldMap, fieldOf, fieldName, controlTypeLabel } +} + +/* ============================================================ + * 3. 多级表头渲染推导(纯 computed,无状态) + * ============================================================ */ + +/** + * @param {{ groupColumnsMap: import('vue').ComputedRef<{map: Map, ungrouped: Array}>, + * bandMeta: import('vue').ComputedRef<{roots: Array, maxLevel: number}> }} tree + */ +export function useBandLayout(tree) { + const { groupColumnsMap, bandMeta } = tree + + // 视觉列序:未分组列在前,随后分组树先序(每分组:直属列 → 空叶子分组补 slot 占位) + const visualColumns = computed(() => { + const columns = groupColumnsMap.value + const list = [] + for (const row of columns.ungrouped) { + list.push({ kind: 'col', key: `col-${row.b_field_id}`, row, gid: null }) + } + const walk = (nodes) => { + for (const node of nodes) { + const own = columns.map.get(node.gid) || [] + for (const row of own) { + list.push({ kind: 'col', key: `col-${row.b_field_id}`, row, gid: node.gid }) + } + if (!own.length && !node.children.length) { + list.push({ kind: 'slot', key: `slot-${node.gid}`, gid: node.gid, group: node.group }) + } + walk(node.children) + } + } + walk(bandMeta.value.roots) + return list + }) + + // 多级表头 band 行:每层一个 tr 的单元格序列;未分组列头在第 0 层,贯穿全部表头行高 + const bandRows = computed(() => { + const { roots, maxLevel } = bandMeta.value + if (!roots.length) return [] + const ungrouped = groupColumnsMap.value.ungrouped + const totalHeaderRows = maxLevel + 2 + const byLevel = new Map() + const collect = (nodes) => { + for (const node of nodes) { + if (!byLevel.has(node.level)) byLevel.set(node.level, []) + byLevel.get(node.level).push(node) + collect(node.children) + } + } + collect(roots) + const rows = [] + for (let level = 0; level <= maxLevel; level++) { + const nodes = byLevel.get(level) || [] + const cells = [] + if (level === 0 && ungrouped.length) { + for (const row of ungrouped) { + cells.push({ kind: 'ungrouped-col', row, span: 1, rowspan: totalHeaderRows }) + } + } + cells.push( + ...nodes.map((node) => ({ + kind: 'band', + gid: node.gid, + group: node.group, + level: node.level, + span: node.span, + rowspan: node.children.length ? 1 : maxLevel - node.level + 1, + })), + ) + rows.push(cells) + } + return rows + }) + + const ungroupedCount = computed(() => groupColumnsMap.value.ungrouped.length) + const ungroupedInBand = computed( + () => bandRows.value.length > 0 && groupColumnsMap.value.ungrouped.length > 0, + ) + + // 列头行:未分组列头已渲染在 band 行时,这里只渲染分组内列 + const headerColumns = computed(() => { + if (!ungroupedInBand.value) return visualColumns.value + return visualColumns.value.filter((c) => c.gid != null) + }) + + return { visualColumns, bandRows, headerColumns, ungroupedInBand, ungroupedCount } +} + +/* ============================================================ + * 4. 行编辑纯函数(无 Vue 依赖) + * ============================================================ */ + +/** 按索引重排 b_xh:从 10 开始,步长 10 */ +export function renumberRows(rows) { + return rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 })) +} + +/** + * 复制列属性:提取指定 keys 的标准化值,返回 { name, props }。 + * 特殊键处理:b_placeholder → 字符串或 null;b_height/b_width → 数值或 null; + * 其余按键的 0/1 标志归一。normalize 可覆盖个别键的自定义取值(如 b_colspan 需 spanOf)。 + */ +export function buildClipboardProps(row, fieldNameOf, keys, normalize = {}) { + const props = {} + for (const k of keys) { + if (normalize[k]) props[k] = normalize[k](row) + else if (k === 'b_placeholder') props[k] = row.b_placeholder || null + else if (k === 'b_height' || k === 'b_width') props[k] = row[k] == null ? null : Number(row[k]) || null + else props[k] = Number(row[k]) === 1 ? 1 : 0 + } + return { name: fieldNameOf(row), props } +} + +/** 将剪切板属性对象应用到行的目标属性 */ +export function applyClipboardProps(row, props) { + Object.assign(row, props) +} + +/** 切换字段布尔标志(纯 mutation) */ +export function toggleFlag(row, key) { + row[key] = Number(row[key]) === 1 ? 0 : 1 +} + +/** 按关键字过滤字段(名称/编码),按名称排序 */ +export function filterLibraryFields(fields, keyword) { + const kw = String(keyword || '').trim().toLowerCase() + return [...(fields || [])] + .filter( + (f) => + !kw || + String(f.b_name || '').toLowerCase().includes(kw) || + String(f.b_field || '').toLowerCase().includes(kw), + ) + .toSorted((a, b) => + String(a.b_name || a.b_field || '').localeCompare(String(b.b_name || b.b_field || ''), 'zh'), + ) +} + +/** + * 创建编辑行工厂函数。 + * 共享:b_id / b_module_id / b_field_id / b_group_id / b_visible / b_required / b_readonly / b_disabled + * / b_colspan(默认24) / b_height(textarea 120) / b_placeholder / b_xh + * 差异:由 getExtra(field, groupId) 补充(如 b_width / b_colspan 覆盖)。 + * @param {{ moduleId: string, getExtra?: (field: object, groupId: string|null) => object }} opts + * @returns {(field: object, groupId: string|null) => object} + */ +export function createEditRowFactory({ moduleId, getExtra }) { + return (field, groupId) => ({ + b_id: createTempId(), + b_module_id: moduleId, + b_field_id: String(field.b_id), + b_group_id: groupId || null, + b_visible: 1, + b_required: 0, + b_readonly: 0, + b_disabled: 0, + b_colspan: 24, + b_height: field.b_type === 'textarea' ? 120 : null, + b_placeholder: null, + b_xh: 0, + ...(getExtra ? getExtra(field, groupId) : {}), + }) +} + +/* ============================================================ + * 5. 结构编辑内核 + * 由 TableDesignEditor(子表设计 / 列表设计共用)消费:宿主只注入数据源(getter)、 + * 行工厂(差异字段)、提交回调(转 emit)与重命名请求(转发结构树 startRename)。 + * ============================================================ */ const DEFAULT_CLIP_KEYS = [ 'b_required', diff --git a/code/fms/fms-vue/src/views/system/module-management/components/form-edit/FormInspector.vue b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/form/FormInspector.vue similarity index 85% rename from code/fms/fms-vue/src/views/system/module-management/components/form-edit/FormInspector.vue rename to code/fms/fms-vue/src/views/system/module-management/components/design-editor/form/FormInspector.vue index 3d91f9dd..08314e86 100644 --- a/code/fms/fms-vue/src/views/system/module-management/components/form-edit/FormInspector.vue +++ b/code/fms/fms-vue/src/views/system/module-management/components/design-editor/form/FormInspector.vue @@ -1,37 +1,58 @@ - - + -

- 「{{ switchConfirm?.name || '当前对象' }}」有未保存的修改,切换前请选择如何处理: -

- -
+ :name="switchConfirm?.name || ''" + :saving="switchSaving" + save-text="保存并切换" + discard-text="放弃修改" + @save="onSwitchSave" + @discard="onSwitchDiscard" + @update:open="(v) => !v && onSwitchCancel()" + /> @@ -862,15 +1512,6 @@ loadModuleTree() color: var(--fms-text-secondary); } -/* 脏点:作为 label 插槽的 flex 子元素排在图标后,间距由 .tabs-tab 的 gap 统一管理 */ -.mm-config-nav__dirty { - flex: none; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--fms-primary); -} - .mm-config-nav .tabs-tab-active .mm-config-nav__icon { color: var(--fms-primary); } diff --git a/code/fms/fms-vue/src/views/tmp-dnd-panel.vue b/code/fms/fms-vue/src/views/tmp-dnd-panel.vue index 3e5b7d74..41f65a3b 100644 --- a/code/fms/fms-vue/src/views/tmp-dnd-panel.vue +++ b/code/fms/fms-vue/src/views/tmp-dnd-panel.vue @@ -1,6 +1,6 @@