From d04fad24ed6e24e65e6f9bffe9c203f702238797 Mon Sep 17 00:00:00 2001 From: ZhangAo Date: Wed, 22 Jul 2026 17:31:48 +0800 Subject: [PATCH] 20260722173146 --- .../config/migrations/005_test_user_power.sql | 49 ++++ .../migrations/006_module_power_mock.sql | 53 ++++ .../fmsapi/controller/DataController.java | 8 + .../fmsapi/service/DataSaveService.java | 2 +- .../cn/g3soft/fmsapi/service/DataService.java | 15 + .../java/cn/g3soft/fmsapi/utils/DbUtils.java | 52 +++- .../fmsapi/mock/ModulePowerMockDataTests.java | 119 ++++++++ code/fms/fms-vue/src/components/FmsTable.vue | 261 ----------------- .../src/components/fms-table/FmsTable.vue | 164 +++++++++++ .../utils/createEditableCellRenderer.js | 80 ++++++ .../utils/createSelectionCellRenderers.js | 54 ++++ .../src/layouts/components/AppSidebar.vue | 6 +- code/fms/fms-vue/src/router/routes.js | 10 + code/fms/fms-vue/src/services/api.js | 9 + code/fms/fms-vue/src/utils/tempId.js | 12 + .../fms-vue/src/views/dashboard/TableDemo.vue | 264 ++++++++++++++++++ .../src/views/i18n-management/index.vue | 2 +- .../components/ModuleBasicInfoPanel.vue | 2 + .../components/ModuleFieldDefinitionPanel.vue | 142 ++++++---- .../components/ModulePowerPanel.vue | 77 +++-- .../components/ModuleTranslationEditor.vue | 44 ++- .../components/createI18nCellRenderer.js | 64 +++++ .../src/views/module-management/index.vue | 90 +++--- code/fms/新数据库结构.md | 152 +++++++++- 24 files changed, 1309 insertions(+), 422 deletions(-) create mode 100644 code/fms/fms-api/config/migrations/005_test_user_power.sql create mode 100644 code/fms/fms-api/config/migrations/006_module_power_mock.sql create mode 100644 code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/mock/ModulePowerMockDataTests.java delete mode 100644 code/fms/fms-vue/src/components/FmsTable.vue create mode 100644 code/fms/fms-vue/src/components/fms-table/FmsTable.vue create mode 100644 code/fms/fms-vue/src/components/fms-table/utils/createEditableCellRenderer.js create mode 100644 code/fms/fms-vue/src/components/fms-table/utils/createSelectionCellRenderers.js create mode 100644 code/fms/fms-vue/src/utils/tempId.js create mode 100644 code/fms/fms-vue/src/views/dashboard/TableDemo.vue create mode 100644 code/fms/fms-vue/src/views/module-management/components/createI18nCellRenderer.js diff --git a/code/fms/fms-api/config/migrations/005_test_user_power.sql b/code/fms/fms-api/config/migrations/005_test_user_power.sql new file mode 100644 index 00000000..00de8841 --- /dev/null +++ b/code/fms/fms-api/config/migrations/005_test_user_power.sql @@ -0,0 +1,49 @@ +-- 操作权限测试数据:生成 5000 条 b_user_power +-- 注意:会删除 admin 用户的旧权限记录,并移除 (b_user_id,b_power_id) 唯一约束。 +-- 如需恢复约束,执行: +-- ALTER TABLE dbo.b_user_power ADD CONSTRAINT UQ_b_user_power UNIQUE (b_user_id, b_power_id); + +BEGIN TRANSACTION; + +DECLARE @AdminUserId bigint = (SELECT TOP 1 b_id FROM dbo.b_user WHERE b_canuse = 1); +IF @AdminUserId IS NULL BEGIN + RAISERROR('b_user 中没有 b_canuse=1 的用户', 16, 1); + ROLLBACK; + RETURN; +END; + +DROP TABLE IF EXISTS #powers; +SELECT ROW_NUMBER() OVER (ORDER BY b_id) AS rn, b_id AS pid +INTO #powers +FROM dbo.b_module_power WHERE b_canuse = 1; + +DECLARE @powerCount int = @@ROWCOUNT; +IF @powerCount = 0 BEGIN + RAISERROR('b_module_power 中没有 b_canuse=1 的权限', 16, 1); + ROLLBACK; + RETURN; +END; + +-- 移除唯一约束以便灌入测试数据 +IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_b_user_power' AND object_id = OBJECT_ID('dbo.b_user_power')) + ALTER TABLE dbo.b_user_power DROP CONSTRAINT UQ_b_user_power; + +DELETE FROM dbo.b_user_power WHERE b_user_id = @AdminUserId; + +WITH num AS ( + SELECT TOP 5000 ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n + FROM sys.all_columns a CROSS JOIN sys.all_columns b +) +INSERT INTO dbo.b_user_power (b_id, b_user_id, b_power_id, b_inputuser_id, b_inputdatetime) +SELECT + 9000000000000000000 + n, + @AdminUserId, + (SELECT pid FROM #powers WHERE rn = ((n - 1) % @powerCount) + 1), + @AdminUserId, + GETDATE() +FROM num; + +PRINT '插入 ' + CAST(@@ROWCOUNT AS varchar) + ' 条'; + +DROP TABLE #powers; +COMMIT; diff --git a/code/fms/fms-api/config/migrations/006_module_power_mock.sql b/code/fms/fms-api/config/migrations/006_module_power_mock.sql new file mode 100644 index 00000000..b437b053 --- /dev/null +++ b/code/fms/fms-api/config/migrations/006_module_power_mock.sql @@ -0,0 +1,53 @@ +-- 006_module_power_mock.sql +-- 为「用户管理」模块(b_id = 829648877883461)插入 5000 条模拟操作权限(b_module_power)。 +-- 幂等:每次执行先删除该模块下 mock_ 前缀的旧数据,再重新插入 5000 条, +-- 可重复运行而不会造成 (b_module_id, b_code) 或 b_id 冲突。 + +SET NOCOUNT ON; +SET XACT_ABORT ON; + +BEGIN TRY + BEGIN TRANSACTION; + + DECLARE @ModuleId bigint = 829648877883461; + + -- 1) 校验目标模块存在 + IF NOT EXISTS (SELECT 1 FROM dbo.b_module WHERE b_id = @ModuleId) + BEGIN + DECLARE @msg nvarchar(200) = N'未找到用户管理模块 b_id=' + CAST(@ModuleId AS nvarchar); + THROW 51001, @msg, 1; + END + + -- 2) 清理旧的 mock 数据(避免重复累积) + DELETE FROM dbo.b_module_power WHERE b_module_id = @ModuleId AND b_code LIKE 'mock_%'; + + -- 4) 插入 5000 条模拟操作权限 + WITH num AS ( + SELECT TOP 5000 ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n + FROM sys.all_columns a CROSS JOIN sys.all_columns b + ) + INSERT INTO dbo.b_module_power (b_id, b_module_id, b_code, b_name, b_i18n, b_xh, b_canuse) + SELECT + 9100000000000000000 + n, + @ModuleId, + 'mock_' + RIGHT('000000' + CAST(n AS varchar(10)), 6), + N'模拟操作 ' + RIGHT('000000' + CAST(n AS varchar(10)), 6), + 'power.m_user.mock_' + RIGHT('000000' + CAST(n AS varchar(10)), 6), + n, + 1 + FROM num + WHERE NOT EXISTS ( + SELECT 1 FROM dbo.b_module_power p + WHERE p.b_module_id = @ModuleId + AND p.b_code = 'mock_' + RIGHT('000000' + CAST(n AS varchar(10)), 6) + ); + + PRINT '插入 ' + CAST(@@ROWCOUNT AS varchar) + ' 条模拟操作权限(模块 b_id=' + CAST(@ModuleId AS varchar) + ')'; + + COMMIT TRANSACTION; +END TRY +BEGIN CATCH + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + THROW; +END CATCH; diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/DataController.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/DataController.java index 536d52c9..7419c269 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/DataController.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/DataController.java @@ -3,6 +3,7 @@ package cn.g3soft.fmsapi.controller; import cn.g3soft.fmsapi.service.DataSaveService; import cn.g3soft.fmsapi.service.DataService; import cn.g3soft.fmsapi.utils.ApiResponse; +import cn.g3soft.fmsapi.utils.ParamUtils; import cn.g3soft.fmsapi.utils.snowflake.idgen.IdGenerator; import jakarta.annotation.Resource; import org.springframework.web.bind.annotation.GetMapping; @@ -58,4 +59,11 @@ public class DataController { public ApiResponse nextId() { return ApiResponse.success(IdGenerator.nextId()); } + + @PostMapping("/describe") + public ApiResponse>> describe( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(dataService.describe(ParamUtils.getRequiredString(params, "table"))); + } } diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java index 8bebd7fc..e512882e 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java @@ -126,7 +126,7 @@ public class DataSaveService { switch (action) { case "delete" -> dbUtils.delete(connection, table, keyColumn, rows.get(index)); case "update" -> dbUtils.update(connection, table, keyColumn, rows.get(index)); - case "insert" -> dbUtils.insert(connection, table, rows.get(index)); + case "insert" -> dbUtils.insert(connection, table, keyColumn, rows.get(index)); default -> throw new IllegalArgumentException("不支持的保存操作: " + action); } } catch (SQLException exception) { diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataService.java index 7396f33a..953bba7c 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataService.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataService.java @@ -4,8 +4,11 @@ import cn.g3soft.fmsapi.exception.BusinessException; import cn.g3soft.fmsapi.utils.DbUtils; import cn.g3soft.fmsapi.utils.ParamUtils; import jakarta.annotation.Resource; +import org.springframework.jdbc.datasource.DataSourceUtils; import org.springframework.stereotype.Service; +import javax.sql.DataSource; +import java.sql.Connection; import java.sql.SQLException; import java.util.LinkedHashMap; import java.util.List; @@ -17,6 +20,18 @@ public class DataService { @Resource private DbUtils dbUtils; + @Resource + private DataSource dataSource; + + public List> describe(String table) throws SQLException { + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + return dbUtils.describeColumns(connection, table); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + public List> loadData(Map params) throws SQLException { return dbUtils.loadData( ParamUtils.getRequiredString(params, "view_name"), diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java index 0f497dd4..b1c5a6b3 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java @@ -2,6 +2,7 @@ package cn.g3soft.fmsapi.utils; import cn.g3soft.fmsapi.database.OrgDataSourceManager; import cn.g3soft.fmsapi.exception.BusinessException; +import cn.g3soft.fmsapi.utils.snowflake.idgen.IdGenerator; import jakarta.annotation.Resource; import org.springframework.jdbc.datasource.DataSourceUtils; import org.springframework.stereotype.Component; @@ -222,9 +223,11 @@ public class DbUtils { || metadata.isReadOnly(index) || "timestamp".equalsIgnoreCase(typeName) || "rowversion".equalsIgnoreCase(typeName); + boolean nullable = metadata.isNullable(index) != ResultSetMetaData.columnNoNulls; + int size = metadata.getPrecision(index); columns.put( normalizedName, - new ColumnMetadata(columnName, normalizedName, !generated) + new ColumnMetadata(columnName, normalizedName, !generated, typeName, nullable, size) ); } } @@ -241,12 +244,54 @@ public class DbUtils { return table.columnsByName().get(normalizeName(requiredFieldName)); } + /** + * 读取表或视图的列结构(列名、类型、可空、长度),用于前端同步字段等场景。 + * 入参 table 会经后端标识符校验,不会拼接到 SQL。 + */ + public List> describeColumns( + Connection connection, + String tableName + ) throws SQLException { + TableMetadata table = loadTableMetadata(connection, tableName); + List> result = new ArrayList<>(); + for (ColumnMetadata column : table.columnsByName().values()) { + Map describe = new LinkedHashMap<>(); + describe.put("name", column.name()); + describe.put("type", column.typeName()); + describe.put("nullable", column.nullable()); + describe.put("size", column.size()); + result.add(describe); + } + return result; + } + + private boolean shouldAssignKey(Object keyValue) { + if (keyValue == null) { + return true; + } + if (keyValue instanceof Number number) { + return number.longValue() < 0; + } + if (keyValue instanceof String text && !text.isBlank()) { + try { + return Long.parseLong(text) < 0; + } catch (NumberFormatException ignored) { + return false; + } + } + return false; + } + public int insert( Connection connection, TableMetadata table, + ColumnMetadata keyColumn, Map row ) throws SQLException { Map normalizedRow = normalizeRow(row); + if (keyColumn != null && shouldAssignKey(normalizedRow.get(keyColumn.normalizedName()))) { + normalizedRow.put(keyColumn.normalizedName(), IdGenerator.nextId()); + } LinkedHashMap values = writableValues( table, normalizedRow, @@ -600,7 +645,10 @@ public class DbUtils { public record ColumnMetadata( String name, String normalizedName, - boolean writable + boolean writable, + String typeName, + boolean nullable, + int size ) { } diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/mock/ModulePowerMockDataTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/mock/ModulePowerMockDataTests.java new file mode 100644 index 00000000..e521c4fb --- /dev/null +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/mock/ModulePowerMockDataTests.java @@ -0,0 +1,119 @@ +package cn.g3soft.fmsapi.mock; + +import cn.g3soft.fmsapi.database.OrgContext; +import cn.g3soft.fmsapi.database.OrgDataSourceManager; +import cn.g3soft.fmsapi.utils.snowflake.contract.IdGeneratorOptions; +import cn.g3soft.fmsapi.utils.snowflake.idgen.IdGenerator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * 为「用户管理」模块(b_id = 829648877883461)插入 5000 条模拟操作权限(b_module_power)。 + * + * 运行方式: + * cd fms-api && ./mvnw test -Dtest=ModulePowerMockDataTests + * + * 说明: + * - 使用硬编码的模块 b_id(829648877883461),即用户管理模块; + * 行级 b_id 由雪花 ID 生成器产生,保证唯一且符合系统规范。 + * - 幂等:每次执行先删除该模块下 mock_ 前缀的旧数据,再重新插入 5000 条, + * 可重复运行而不会造成 (b_module_id, b_code) 或 b_id 冲突。 + * - 通过 OrgDataSourceManager 命中 g3hd.properties 对应的数据源,需本机可连接该库。 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +class ModulePowerMockDataTests { + + private static final String ORG_ID = "G3HD"; + private static final long USER_MODULE_ID = 829648877883461L; + private static final int MOCK_COUNT = 5000; + private static final int BATCH_SIZE = 500; + + @Autowired + private OrgDataSourceManager dataSourceManager; + + @BeforeAll + static void initIdGenerator() { + // @SpringBootTest 不执行 main(),需在此初始化雪花 ID 生成器 + IdGeneratorOptions options = new IdGeneratorOptions(); + options.WorkerId = 1; + IdGenerator.setIdGenerator(options); + } + + @AfterEach + void clearOrgContext() { + OrgContext.clear(); + } + + @Test + void insertModulePowerMockData() throws SQLException { + OrgContext.setOrgId(ORG_ID); + try (Connection connection = dataSourceManager.getConnection(ORG_ID)) { + long moduleId = requireUserModule(connection, USER_MODULE_ID); + + // 1) 清理旧的 mock 数据,保证可重复运行 + try (PreparedStatement delete = connection.prepareStatement( + "DELETE FROM [dbo].[b_module_power] WHERE [b_module_id] = ? AND [b_code] LIKE 'mock_%'")) { + delete.setLong(1, moduleId); + int removed = delete.executeUpdate(); + System.out.println("清理旧 mock 数据: " + removed + " 条"); + } + + // 2) 批量插入 5000 条模拟操作权限 + String sql = "INSERT INTO [dbo].[b_module_power] " + + "([b_id], [b_module_id], [b_code], [b_name], [b_i18n], [b_xh], [b_canuse]) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)"; + + int total = 0; + try (PreparedStatement insert = connection.prepareStatement(sql)) { + for (int n = 1; n <= MOCK_COUNT; n++) { + String seq = String.format("%06d", n); + insert.setLong(1, IdGenerator.nextId()); + insert.setLong(2, moduleId); + insert.setString(3, "mock_" + seq); + insert.setString(4, "模拟操作 " + seq); + insert.setString(5, "power.m_user.mock_" + seq); + insert.setInt(6, n); + insert.setInt(7, 1); + insert.addBatch(); + + if (n % BATCH_SIZE == 0) { + total += sum(insert.executeBatch()); + } + } + total += sum(insert.executeBatch()); + } + + System.out.println("插入 " + total + " 条模拟操作权限(模块 b_id=" + moduleId + ")"); + } + } + + private long requireUserModule(Connection connection, long moduleId) throws SQLException { + try (PreparedStatement ps = connection.prepareStatement( + "SELECT 1 FROM [dbo].[b_module] WHERE [b_id] = ?")) { + ps.setLong(1, moduleId); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + throw new IllegalStateException( + "未找到用户管理模块 b_id=" + moduleId + ",请确认该模块在目标库中已存在"); + } + } + } + return moduleId; + } + + private static int sum(int[] counts) { + int sum = 0; + for (int c : counts) { + sum += c; + } + return sum; + } +} diff --git a/code/fms/fms-vue/src/components/FmsTable.vue b/code/fms/fms-vue/src/components/FmsTable.vue deleted file mode 100644 index b5e651e3..00000000 --- a/code/fms/fms-vue/src/components/FmsTable.vue +++ /dev/null @@ -1,261 +0,0 @@ - - - - - - - diff --git a/code/fms/fms-vue/src/components/fms-table/FmsTable.vue b/code/fms/fms-vue/src/components/fms-table/FmsTable.vue new file mode 100644 index 00000000..a9d4cbc4 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-table/FmsTable.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-table/utils/createEditableCellRenderer.js b/code/fms/fms-vue/src/components/fms-table/utils/createEditableCellRenderer.js new file mode 100644 index 00000000..9b702883 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-table/utils/createEditableCellRenderer.js @@ -0,0 +1,80 @@ +import { h } from "vue"; +import { Checkbox, Input, InputNumber, Select } from "antdv-next"; + +function resolveOption(option, row) { + return typeof option === "function" ? option(row) : option; +} + +export function createEditableCellRenderer({ getRowKey, getActiveRowKey }) { + return function EditableCell({ row, col, cellValue }) { + const editor = typeof col.editor === "string" ? { type: col.editor } : col.editor || {}; + const activeRowKey = getActiveRowKey(); + const isEditing = activeRowKey != null && String(getRowKey(row)) === String(activeRowKey); + + if (!isEditing) { + if (editor.type === "checkbox") { + return h(Checkbox, { + checked: cellValue === (editor.checkedValue ?? 1), + style: { pointerEvents: "none" }, + }); + } + if (editor.type === "select") { + const options = resolveOption(editor.options, row) || []; + const option = options.find((item) => item.value === cellValue); + return h("span", { class: "fms-cell-text" }, option?.label ?? cellValue ?? ""); + } + return h("span", { class: "fms-cell-text" }, cellValue ?? ""); + } + + const update = (value) => { + row[col.dataIndex] = value; + editor.onChange?.(value, row, col); + }; + const common = { + size: "small", + style: { width: "100%" }, + onClick: (event) => event.stopPropagation(), + }; + + if (editor.type === "number") { + return h(InputNumber, { + ...common, + value: cellValue, + min: resolveOption(editor.min, row), + max: resolveOption(editor.max, row), + precision: resolveOption(editor.precision, row), + disabled: resolveOption(editor.disabled, row), + "onUpdate:value": update, + }); + } + if (editor.type === "select") { + return h(Select, { + ...common, + value: cellValue, + options: resolveOption(editor.options, row) || [], + disabled: resolveOption(editor.disabled, row), + "onUpdate:value": update, + }); + } + if (editor.type === "checkbox") { + const checkedValue = editor.checkedValue ?? 1; + const uncheckedValue = editor.uncheckedValue ?? 0; + return h(Checkbox, { + checked: cellValue === checkedValue, + disabled: resolveOption(editor.disabled, row), + onClick: (event) => event.stopPropagation(), + "onUpdate:checked": (checked) => update(checked ? checkedValue : uncheckedValue), + }); + } + return h(Input, { + ...common, + value: cellValue, + disabled: resolveOption(editor.disabled, row), + maxlength: resolveOption(editor.maxlength, row), + placeholder: resolveOption(editor.placeholder, row), + status: resolveOption(editor.status, row), + allowClear: editor.allowClear ?? true, + "onUpdate:value": update, + }); + }; +} diff --git a/code/fms/fms-vue/src/components/fms-table/utils/createSelectionCellRenderers.js b/code/fms/fms-vue/src/components/fms-table/utils/createSelectionCellRenderers.js new file mode 100644 index 00000000..c45cd97d --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-table/utils/createSelectionCellRenderers.js @@ -0,0 +1,54 @@ +import { h } from "vue"; +import { Checkbox } from "antdv-next"; + +export function createSelectionCellRenderers({ + getDataSource, + getRowKey, + getSelectedRowKeySet, + onChange, +}) { + function updateSelection(nextKeys) { + const keySet = new Set(nextKeys); + const rows = getDataSource().filter((row) => keySet.has(getRowKey(row))); + onChange(nextKeys, rows); + } + + function changeRow(row, checked) { + const nextKeys = new Set(getSelectedRowKeySet()); + const key = getRowKey(row); + if (checked) nextKeys.add(key); + else nextKeys.delete(key); + updateSelection(Array.from(nextKeys)); + } + + function changeAll(checked) { + const nextKeys = new Set(getSelectedRowKeySet()); + getDataSource().forEach((row) => { + const key = getRowKey(row); + if (checked) nextKeys.add(key); + else nextKeys.delete(key); + }); + updateSelection(Array.from(nextKeys)); + } + + const SelectionCell = ({ row }) => + h(Checkbox, { + checked: getSelectedRowKeySet().has(getRowKey(row)), + "onUpdate:checked": (checked) => changeRow(row, checked), + onClick: (event) => event.stopPropagation(), + }); + + const SelectionHeaderCell = () => { + const rows = getDataSource(); + const selectedRowKeySet = getSelectedRowKeySet(); + const selectedCount = rows.filter((row) => selectedRowKeySet.has(getRowKey(row))).length; + return h(Checkbox, { + checked: rows.length > 0 && selectedCount === rows.length, + indeterminate: selectedCount > 0 && selectedCount < rows.length, + "onUpdate:checked": changeAll, + onClick: (event) => event.stopPropagation(), + }); + }; + + return { SelectionCell, SelectionHeaderCell }; +} diff --git a/code/fms/fms-vue/src/layouts/components/AppSidebar.vue b/code/fms/fms-vue/src/layouts/components/AppSidebar.vue index fbfe9092..6f2d5286 100644 --- a/code/fms/fms-vue/src/layouts/components/AppSidebar.vue +++ b/code/fms/fms-vue/src/layouts/components/AppSidebar.vue @@ -24,9 +24,13 @@ const effectiveCollapsed = computed(() => appStore.sidebarCollapsed); const fallbackMenuItems = [ { - key: "/dashboard", + key: "dashboard", icon: menuIcon(House), label: "工作台", + children: [ + { key: "/dashboard", label: "概览" }, + { key: "/dashboard/table-demo", label: "表格 Demo" }, + ], }, { key: "business", diff --git a/code/fms/fms-vue/src/router/routes.js b/code/fms/fms-vue/src/router/routes.js index 4b57ea51..0e365ade 100644 --- a/code/fms/fms-vue/src/router/routes.js +++ b/code/fms/fms-vue/src/router/routes.js @@ -14,6 +14,16 @@ const routes = [ component: () => import("@/views/dashboard/index.vue"), meta: { title: "工作台", menuKey: "/dashboard" }, }, + { + path: "dashboard/table-demo", + name: "dashboard-table-demo", + component: () => import("@/views/dashboard/TableDemo.vue"), + meta: { + title: "表格 Demo", + section: "工作台", + menuKey: "/dashboard/table-demo", + }, + }, { path: "system/modules", name: "system-modules", diff --git a/code/fms/fms-vue/src/services/api.js b/code/fms/fms-vue/src/services/api.js index c33acc41..897abdf1 100644 --- a/code/fms/fms-vue/src/services/api.js +++ b/code/fms/fms-vue/src/services/api.js @@ -95,3 +95,12 @@ export function saveObjectApi(requests) { export function nextIdApi() { return get("/data/nextid"); } + +/** + * 读取表或视图的列结构,用于同步字段等场景。 + * @param {string} table 表名或视图名,必填,后端会校验标识符。 + * @returns {Promise} 列结构响应。 + */ +export function describeApi(table) { + return post("/data/describe", { table }); +} diff --git a/code/fms/fms-vue/src/utils/tempId.js b/code/fms/fms-vue/src/utils/tempId.js new file mode 100644 index 00000000..2470502c --- /dev/null +++ b/code/fms/fms-vue/src/utils/tempId.js @@ -0,0 +1,12 @@ +// 生成本地临时 ID,用于尚未保存到后端的子表新增行。 +// 使用负数以区别于后端生成的雪花 ID(恒为正),保存时后端会替换为真实 ID。 +let sequence = 0; + +/** + * 生成会话内唯一的临时 ID(负数字符串)。 + * @returns {string} 形如 "-1"、"-2" 的临时 ID。 + */ +export function createTempId() { + sequence += 1; + return String(-sequence); +} diff --git a/code/fms/fms-vue/src/views/dashboard/TableDemo.vue b/code/fms/fms-vue/src/views/dashboard/TableDemo.vue new file mode 100644 index 00000000..4a206e02 --- /dev/null +++ b/code/fms/fms-vue/src/views/dashboard/TableDemo.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/code/fms/fms-vue/src/views/i18n-management/index.vue b/code/fms/fms-vue/src/views/i18n-management/index.vue index 6c6e735f..93c1928d 100644 --- a/code/fms/fms-vue/src/views/i18n-management/index.vue +++ b/code/fms/fms-vue/src/views/i18n-management/index.vue @@ -3,7 +3,7 @@ import { computed, onMounted, reactive, ref, watch } from "vue"; import { useRoute } from "vue-router"; import { App } from "antdv-next"; import { Languages, Plus, RefreshCw, RotateCcw, Save, Trash2 } from "@lucide/vue"; -import FmsTable from "@/components/FmsTable.vue"; +import FmsTable from "@/components/fms-table/FmsTable.vue"; import { loadDataApi, nextIdApi, saveObjectApi } from "@/services/api"; import { useAuthStore } from "@/stores/auth"; import { usePermissionStore } from "@/stores/permissions"; diff --git a/code/fms/fms-vue/src/views/module-management/components/ModuleBasicInfoPanel.vue b/code/fms/fms-vue/src/views/module-management/components/ModuleBasicInfoPanel.vue index d860bf35..498e0263 100644 --- a/code/fms/fms-vue/src/views/module-management/components/ModuleBasicInfoPanel.vue +++ b/code/fms/fms-vue/src/views/module-management/components/ModuleBasicInfoPanel.vue @@ -40,6 +40,7 @@ function updateRoute(value) { @@ -80,6 +81,7 @@ function updateRoute(value) { diff --git a/code/fms/fms-vue/src/views/module-management/components/ModuleFieldDefinitionPanel.vue b/code/fms/fms-vue/src/views/module-management/components/ModuleFieldDefinitionPanel.vue index db2882ff..6d75dcfc 100644 --- a/code/fms/fms-vue/src/views/module-management/components/ModuleFieldDefinitionPanel.vue +++ b/code/fms/fms-vue/src/views/module-management/components/ModuleFieldDefinitionPanel.vue @@ -1,57 +1,83 @@ @@ -616,6 +607,7 @@ onMounted(async () => { diff --git a/code/fms/新数据库结构.md b/code/fms/新数据库结构.md index f316c6b5..365795df 100644 --- a/code/fms/新数据库结构.md +++ b/code/fms/新数据库结构.md @@ -9,6 +9,7 @@ - `b_xh` 表示显示顺序,数值越小越靠前。 - 当前按机构使用独立数据库,表中不保存机构编号。 - 模块管理界面只区分目录和普通模块;普通模块配置路由时保存为 `page`,未配置路由时保存为 `data`。 +- 字段配置按两个使用状态设计:`list` 列表状态和 `edit` 编辑状态;连同字段基础定义共三张表。该结构为目标设计,需通过后续迁移替换当前 `b_module_field` 中混合保存的列表和表单属性。 ## 模块管理 @@ -18,10 +19,15 @@ b_module |-- parent_id --> b_module |-- 1:N --------> b_module_field + |-- 1:N --------> b_module_field_list -- parent_id --> b_module_field_list |-- 1:N --------> b_user_module |-- 1:N --------> b_module_power ---- 1:N ----> b_user_power -b_module / b_module_field / b_module_power +b_module_field + |-- 0:1 --------> b_module_field_list + |-- 0:1 --------> b_module_field_edit + +b_module / b_module_field / b_module_field_list / b_module_field_edit / b_module_power |-- b_i18n --> b_i18n.b_key ``` @@ -56,7 +62,7 @@ b_module / b_module_field / b_module_power ### `dbo.b_module_field` -保存模块的标准字段配置。 +保存字段本身的稳定定义,不保存列表位置、表单位置等界面场景属性。 | 字段 | 类型 | 说明 | | --- | --- | --- | @@ -66,22 +72,146 @@ b_module / b_module_field / b_module_power | `b_name` | `nvarchar(100)` | 默认中文名称 | | `b_i18n` | `varchar(150)` | 多语言键 | | `b_type` | `varchar(20)` | `string`、`text`、`number`、`select`、`date`、`boolean` | -| `b_canlist` | `int` | 是否显示在列表 | -| `b_canform` | `int` | 是否显示在表单 | -| `b_must` | `int` | 是否必填 | -| `b_canquery` | `int` | 是否作为查询条件 | -| `b_readonly` | `int` | 是否只读 | -| `b_width` | `int` | 列表列宽 | | `b_value` | `nvarchar(500)` | 新增时的默认值 | -| `b_xh` | `int` | 显示顺序 | +| `b_dict_code` | `varchar(100)` | 字典编码,非字典字段为空 | | `b_canuse` | `int` | 是否启用 | +| `b_remark` | `nvarchar(500)` | 字段说明 | 约束: - `b_module_id` 外键引用 `b_module.b_id`。 - `(b_module_id, b_field)` 唯一。 -- `b_canform = 0` 时,`b_must` 必须为 `0`。 -- `select` 的选项来源后续单独设计。 +- `b_canuse` 只能为 `0` 或 `1`。 +- 字段未配置对应的表格列或编辑字段记录时,表示该字段不参与对应场景。 +- `select` 的选项来源优先使用 `b_dict_code`,复杂选项来源后续单独设计。 +- 建立 `(b_module_id, b_canuse, b_id)` 索引,用于按模块加载字段定义。 + +### `dbo.b_module_field_list` + +保存字段在 `list` 状态下的列结构和显示配置。只读列表和可编辑 Table 都使用本表;表头分组节点与字段节点保存在同一张自关联表中,通过 `b_parent_id` 组成任意层级的表头树。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `b_id` | `bigint` | 雪花 ID 主键 | +| `b_module_id` | `bigint` | 所属模块 ID;分组节点没有字段关系,因此必须直接保存模块 ID | +| `b_parent_id` | `bigint` | 上级表头分组 ID,顶层节点为空 | +| `b_kind` | `varchar(20)` | 节点类型:`group` 或 `field` | +| `b_field_id` | `bigint` | 字段节点关联 `b_module_field.b_id`;分组节点为空 | +| `b_title` | `nvarchar(100)` | 分组标题或字段列标题覆盖值;字段节点为空时使用字段默认名称 | +| `b_i18n` | `varchar(150)` | 分组多语言键或字段列多语言键覆盖值 | +| `b_visible` | `int` | 当前是否显示;分组隐藏时其全部后代都不显示 | +| `b_xh` | `int` | 在同一父节点下的显示顺序 | +| `b_width` | `int` | 字段列宽;分组节点为空,由子列计算 | +| `b_sortable` | `int` | 是否允许排序 | +| `b_display_type` | `varchar(30)` | 显示方式,例如 `text`、`date`、`money`、`dict`、`tag`、`link` | +| `b_display_format` | `varchar(100)` | 显示格式,例如日期格式或数字格式 | +| `b_display_options` | `nvarchar(max)` | 少量显示器专属配置,保存 JSON;无配置时为空 | +| `b_canedit` | `int` | 是否允许在表格单元格内编辑;仅字段节点可为 `1` | +| `b_canquery` | `int` | 是否作为列表查询条件 | + +约束: + +- `b_module_id` 外键引用 `b_module.b_id`,`b_parent_id` 外键引用本表 `b_id`。 +- `group` 节点的 `b_field_id` 必须为空,`field` 节点的 `b_field_id` 必须非空。 +- 同一字段在同一模块的表格列树中最多出现一次,使用过滤唯一索引约束非空 `b_field_id`。 +- `b_parent_id` 必须指向同一模块的 `group` 节点,且不能形成循环;由保存服务校验。 +- `b_visible`、`b_sortable`、`b_canedit`、`b_canquery` 只能为 `0` 或 `1`。 +- `group` 节点的 `b_canedit` 必须为 `0`;`field` 节点 `b_canedit = 1` 时,必须存在对应的 `b_module_field_edit` 编辑规则。 +- `b_display_options` 非空时必须通过 SQL Server `ISJSON` 校验。 +- 不保存 `level`、`colspan` 和 `rowspan`,由表头树及可见叶子节点计算。 +- 分组下没有可见字段节点时,不渲染该分组。 +- 列表分组节点与字段节点统一使用 `b_xh` 表示同级顺序,因此分组和顶层字段可以混合排序。 +- 建立 `(b_module_id, b_parent_id, b_xh, b_id)` 索引,用于一次加载并组装表格列树。 + +示例: + +```text +基础信息(group) +|-- 订单号(field) +|-- 客户名称(field) +`-- 运输信息(group) + |-- 起运港(field) + `-- 目的港(field) +``` + +加载模块的全部列后,按 `b_parent_id` 和 `b_xh` 在前端组装为 `FmsTable` 使用的嵌套 `children`,不需要为每一级表头执行单独查询。只读列表忽略 `b_canedit`;可编辑子表根据 `b_canedit` 和对应编辑规则生成单元格编辑器。 + +### `dbo.b_module_field_edit` + +保存字段在 `edit` 状态下的编辑规则,以及字段在 Form 中的布局。编辑状态同时供 Form 和可编辑 Table 复用;表单分组只是同一个表单中的视觉分组,不代表页签或动态页面区域。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `b_id` | `bigint` | 雪花 ID 主键 | +| `b_module_id` | `bigint` | 所属模块 ID,便于通过通用接口直接按模块加载 | +| `b_field_id` | `bigint` | 关联 `b_module_field.b_id` | +| `b_editor` | `varchar(30)` | 编辑控件,例如 `input`、`textarea`、`select`、`date-picker`、`switch` | +| `b_edit_format` | `varchar(100)` | 编辑值格式,例如日期格式或数字精度格式 | +| `b_editor_options` | `nvarchar(max)` | 少量编辑控件专属配置,保存 JSON;无配置时为空 | +| `b_required` | `int` | 是否必填 | +| `b_readonly` | `int` | 是否只读 | +| `b_disabled` | `int` | 是否禁用 | +| `b_placeholder` | `nvarchar(200)` | 输入提示 | +| `b_canform` | `int` | 是否在 Form 中显示;为 `0` 时编辑规则仍可供可编辑 Table 使用 | +| `b_edit_group` | `varchar(100)` | 编辑分组 key;不分组时为空 | +| `b_edit_xh` | `int` | 整个编辑区域内的全局显示顺序 | +| `b_edit_span` | `int` | Form 编辑时的栅格宽度,例如 `6`、`8`、`12`、`24` | + +约束: + +- `b_module_id` 外键引用 `b_module.b_id`,`b_field_id` 外键引用 `b_module_field.b_id`。 +- `(b_module_id, b_field_id)` 唯一,且字段必须属于同一模块;模块一致性由保存服务校验。 +- `b_required`、`b_readonly`、`b_disabled`、`b_canform` 只能为 `0` 或 `1`。 +- `b_edit_span` 必须大于 `0` 且不超过 `24`。 +- `b_editor_options` 非空时必须通过 SQL Server `ISJSON` 校验。 +- `b_edit_group` 为空表示不分组;非空时使用页面代码中声明的分组 key,例如 `basic`、`booking`。 +- 编辑分组按组内最小的 `b_edit_xh` 排序,组内字段也按 `b_edit_xh` 排序;不重复保存分组顺序。 +- 分组标题、主表、子表和页面组合仍由具体 Vue 页面显式维护,不建立动态页面区域模型。 +- 建立 `(b_module_id, b_edit_xh, b_id)` 索引,用于按模块顺序加载编辑字段并计算分组顺序。 + +### 字段配置语义 + +- 字段没有表格列配置记录:不参与 Table 及主列表查询。 +- 字段有表格列配置且 `b_visible = 0`:保留列配置但当前不显示,仍可通过 `b_canquery = 1` 作为主列表查询条件。 +- 字段没有编辑字段配置记录:不能用于 Form 或可编辑 Table。 +- `b_canform = 0`:字段不出现在 Form 中,但编辑规则仍可供可编辑 Table 使用。 +- `b_canedit = 1`:表格单元格使用对应 `b_module_field_edit` 的编辑器、格式、必填和只读规则。 +- 表格显示、表格位置、Form 布局和编辑方式相互独立,可以只使用其中一个场景。 +- 子表使用独立模块及字段配置,不在字段配置中增加区域、页签或子表类型。 + +### 海运页面组合示例 + +海运业务页面是显式编写的 Vue 页面,只负责组合数据模块,不通过字段配置自动生成区域。 + +```text +海运业务页面 m_sea(路由、菜单、权限、页面编排) +|-- 海运主单 d_sea +| |-- 海运列表:Table,读取 b_module_field_list,全部 b_canedit = 0 +| `-- 主单编辑:Form,读取 b_module_field_edit 且 b_canform = 1 +|-- 订舱主单 d_booking(订舱有独立表和主键时) +| `-- 订舱编辑:Form,读取 b_module_field_edit 且 b_canform = 1 +`-- 装箱明细 d_container + `-- 装箱编辑表:Table,读取 b_module_field_list;b_canedit = 1 的字段读取 b_module_field_edit +``` + +若订舱字段实际保存于海运主单表,不建立 `d_booking`,而是在 `d_sea` 的 Form 中使用不同的 `b_edit_group`。主表、订舱主表和装箱子表的保存关联、加载时机和页面布局由业务页面代码明确处理。 + +### 现有字段迁移 + +当前 `b_module_field` 的混合配置迁移到目标结构时按以下规则处理: + +| 当前字段 | 目标字段 | +| --- | --- | +| `b_canlist` | `b_module_field_list.b_visible` | +| `b_width` | `b_module_field_list.b_width` | +| `b_canquery` | `b_module_field_list.b_canquery` | +| `b_xh` | 分别初始化 `b_module_field_list.b_xh` 和 `b_module_field_edit.b_edit_xh` | +| `b_canform` | `b_module_field_edit.b_canform` | +| `b_must` | `b_module_field_edit.b_required` | +| `b_readonly` | `b_module_field_edit.b_readonly` | +| `b_value` | 保留在 `b_module_field.b_value` | + +迁移时先为现有字段创建顶层 `field` 列表状态节点,`b_parent_id` 为空、`b_canedit = 0`;多级表头分组及可编辑子表列由后续配置补充。迁移、前端适配和旧字段删除应分阶段实施,确认新结构读写稳定后再删除旧列。 ### `dbo.b_module_power`