20260722173146

This commit is contained in:
oneao committed 2026-07-22 17:31:48 +08:00
1 parent e9f099f044
commit d04fad24ed
24 files changed
+1309 -422

No files matched your search

@@ -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;
@@ -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;
@@ -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<Long> nextId() {
return ApiResponse.success(IdGenerator.nextId());
}
@PostMapping("/describe")
public ApiResponse<List<Map<String, Object>>> describe(
@RequestBody(required = false) Map<String, Object> params
) throws SQLException {
return ApiResponse.success(dataService.describe(ParamUtils.getRequiredString(params, "table")));
}
}
@@ -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) {
@@ -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<Map<String, Object>> describe(String table) throws SQLException {
Connection connection = DataSourceUtils.getConnection(dataSource);
try {
return dbUtils.describeColumns(connection, table);
} finally {
DataSourceUtils.releaseConnection(connection, dataSource);
}
}
public List<Map<String, Object>> loadData(Map<String, Object> params) throws SQLException {
return dbUtils.loadData(
ParamUtils.getRequiredString(params, "view_name"),
@@ -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<Map<String, Object>> describeColumns(
Connection connection,
String tableName
) throws SQLException {
TableMetadata table = loadTableMetadata(connection, tableName);
List<Map<String, Object>> result = new ArrayList<>();
for (ColumnMetadata column : table.columnsByName().values()) {
Map<String, Object> 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<String, Object> row
) throws SQLException {
Map<String, Object> normalizedRow = normalizeRow(row);
if (keyColumn != null && shouldAssignKey(normalizedRow.get(keyColumn.normalizedName()))) {
normalizedRow.put(keyColumn.normalizedName(), IdGenerator.nextId());
}
LinkedHashMap<ColumnMetadata, Object> 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
) {
}
@@ -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;
}
}
@@ -1,261 +0,0 @@
<script setup>
import { computed, defineComponent, h, nextTick, ref, useSlots } from "vue";
import { useRafFn, useResizeObserver } from "@vueuse/core";
import { Checkbox, Input, InputNumber, Select } from "antdv-next";
import { StkTable } from "stk-table-vue";
defineOptions({ inheritAttrs: false });
const props = defineProps({
virtual: { type: Boolean, default: true },
virtualX: { type: Boolean, default: true },
colResizable: { type: Boolean, default: true },
bordered: { type: [Boolean, String], default: true },
noDataFull: { type: Boolean, default: true },
columns: { type: Array, default: () => [] },
dataSource: { type: Array, default: () => [] },
rowKey: { type: [String, Function], default: "id" },
rowSelection: { type: Object, default: null },
});
const emit = defineEmits(["update:columns"]);
function getRowKey(row) {
return typeof props.rowKey === "function" ? props.rowKey(row) : row?.[props.rowKey];
}
const selectedRowKeySet = computed(
() => new Set(props.rowSelection?.selectedRowKeys || []),
);
function changeSelection(row, checked) {
const key = getRowKey(row);
const nextKeys = new Set(selectedRowKeySet.value);
if (checked) nextKeys.add(key);
else nextKeys.delete(key);
const keys = Array.from(nextKeys);
const rows = props.dataSource.filter((item) => nextKeys.has(getRowKey(item)));
props.rowSelection?.onChange?.(keys, rows);
}
function changeAllSelection(checked) {
const visibleKeys = props.dataSource.map(getRowKey);
const nextKeys = new Set(selectedRowKeySet.value);
visibleKeys.forEach((key) => {
if (checked) nextKeys.add(key);
else nextKeys.delete(key);
});
const keys = Array.from(nextKeys);
const rows = props.dataSource.filter((item) => nextKeys.has(getRowKey(item)));
props.rowSelection?.onChange?.(keys, rows);
}
const SelectionCell = defineComponent({
props: ["row"],
setup(cellProps) {
return () =>
h(Checkbox, {
checked: selectedRowKeySet.value.has(getRowKey(cellProps.row)),
"onUpdate:checked": (checked) => changeSelection(cellProps.row, checked),
onClick: (event) => event.stopPropagation(),
});
},
});
const SelectionHeaderCell = defineComponent({
setup() {
return () => {
const visibleKeys = props.dataSource.map(getRowKey);
const selectedCount = visibleKeys.filter((key) => selectedRowKeySet.value.has(key)).length;
return h(Checkbox, {
checked: visibleKeys.length > 0 && selectedCount === visibleKeys.length,
indeterminate: selectedCount > 0 && selectedCount < visibleKeys.length,
"onUpdate:checked": changeAllSelection,
onClick: (event) => event.stopPropagation(),
});
};
},
});
const EditableCell = defineComponent({
props: ["row", "col", "cellValue"],
setup(cellProps) {
function editorOptions() {
return typeof cellProps.col.editor === "string"
? { type: cellProps.col.editor }
: cellProps.col.editor || {};
}
function updateValue(value) {
cellProps.row[cellProps.col.dataIndex] = value;
editorOptions().onChange?.(value, cellProps.row, cellProps.col);
}
return () => {
const editor = editorOptions();
const disabled =
typeof editor.disabled === "function" ? editor.disabled(cellProps.row) : editor.disabled;
const commonProps = {
size: "small",
disabled,
style: { width: "100%" },
};
if (editor.type === "number") {
return h(InputNumber, {
...commonProps,
value: cellProps.row[cellProps.col.dataIndex],
min: editor.min,
max: editor.max,
precision: editor.precision,
"onUpdate:value": updateValue,
});
}
if (editor.type === "select") {
return h(Select, {
...commonProps,
value: cellProps.row[cellProps.col.dataIndex],
options: editor.options || [],
"onUpdate:value": updateValue,
});
}
if (editor.type === "checkbox") {
const checkedValue = editor.checkedValue ?? 1;
const uncheckedValue = editor.uncheckedValue ?? 0;
return h(Checkbox, {
disabled,
checked: cellProps.row[cellProps.col.dataIndex] === checkedValue,
"onUpdate:checked": (checked) => updateValue(checked ? checkedValue : uncheckedValue),
onClick: (event) => event.stopPropagation(),
});
}
return h(Input, {
...commonProps,
value: cellProps.row[cellProps.col.dataIndex],
allowClear: editor.allowClear ?? true,
maxlength: editor.maxlength,
placeholder:
typeof editor.placeholder === "function"
? editor.placeholder(cellProps.row)
: editor.placeholder,
status: typeof editor.status === "function" ? editor.status(cellProps.row) : editor.status,
"onUpdate:value": updateValue,
});
};
},
});
// StkTable 的编辑单元格和多选列统一在封装层生成,业务页面只声明 editor / rowSelection。
const tableColumnsModel = computed({
get: () => {
const columns = props.columns.map((column) =>
column.editor && !column.customCell
? { ...column, customCell: EditableCell, __fmsEditor: true }
: column,
);
if (!props.rowSelection) return columns;
return [
{
dataIndex: "__fmsSelection",
key: "__fmsSelection",
title: "",
width: 44,
fixed: "left",
align: "center",
headerAlign: "center",
customCell: SelectionCell,
customHeaderCell: SelectionHeaderCell,
__fmsSelection: true,
},
...columns,
];
},
set: (value) =>
emit(
"update:columns",
value
.filter((column) => !column.__fmsSelection)
.map((column) => {
if (!column.__fmsEditor) return column;
const rest = { ...column };
delete rest.customCell;
delete rest.__fmsEditor;
return rest;
}),
),
});
const slots = useSlots();
const tableRef = ref(null);
// 表格样式
const customVars = ref({
"--header-row-height": "40px",
"--row-height": "34px",
"--footer-row-height": "36px",
});
const { resume: refreshLayout } = useRafFn(
async () => {
await nextTick();
tableRef.value?.initVirtualScroll?.();
await nextTick();
},
{ immediate: false, once: true },
);
useResizeObserver(
() => tableRef.value?.$el,
([entry]) => {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) refreshLayout();
},
);
// Forward every slot except `empty`; the `empty` slot is handled below so the
// default Result fallback only renders when the parent does not provide one.
const forwardSlots = computed(() => {
const result = {};
for (const name in slots) {
if (name !== "empty") result[name] = slots[name];
}
return result;
});
</script>
<template>
<StkTable
ref="tableRef"
class="fms-table"
v-bind="$attrs"
:style="customVars"
:auto-resize="false"
:virtual="virtual"
:virtual-x="virtualX"
:col-resizable="colResizable"
v-model:columns="tableColumnsModel"
:data-source="dataSource"
:row-key="rowKey"
:bordered="bordered"
:no-data-full="noDataFull"
>
<template v-for="(slot, name) in forwardSlots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
<template v-if="slots.empty" #empty>
<slot name="empty" />
</template>
<template v-else #empty>
<a-result status="info" title="暂无数据" sub-title="当前没有可显示的内容" />
</template>
</StkTable>
</template>
<style scoped>
.fms-table {
width: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,164 @@
<script setup>
import { computed, nextTick, ref, useSlots } from "vue";
import { useRafFn, useResizeObserver } from "@vueuse/core";
import { StkTable } from "stk-table-vue";
import { createEditableCellRenderer } from "./utils/createEditableCellRenderer";
import { createSelectionCellRenderers } from "./utils/createSelectionCellRenderers";
defineOptions({ inheritAttrs: false });
const props = defineProps({
virtual: { type: Boolean, default: true },
virtualX: { type: Boolean, default: true },
colResizable: { type: Boolean, default: true },
bordered: { type: [Boolean, String], default: true },
noDataFull: { type: Boolean, default: true },
columns: { type: Array, default: () => [] },
dataSource: { type: Array, default: () => [] },
rowKey: { type: [String, Function], default: "id" },
rowSelection: { type: Object, default: null },
// 当前处于编辑状态的行 key;为 null 时所有行均以纯文本展示(单行编辑模式)。
activeRowKey: { type: [String, Number], default: null },
});
const emit = defineEmits(["update:columns"]);
function getRowKey(row) {
return typeof props.rowKey === "function" ? props.rowKey(row) : row?.[props.rowKey];
}
const selectedRowKeySet = computed(() => new Set(props.rowSelection?.selectedRowKeys || []));
const EditableCell = createEditableCellRenderer({
getRowKey,
getActiveRowKey: () => props.activeRowKey,
});
const { SelectionCell, SelectionHeaderCell } = createSelectionCellRenderers({
getDataSource: () => props.dataSource,
getRowKey,
getSelectedRowKeySet: () => selectedRowKeySet.value,
onChange: (keys, rows) => props.rowSelection?.onChange?.(keys, rows),
});
// StkTable 的编辑单元格和多选列统一在封装层生成,业务页面只声明 editor / rowSelection。
const tableColumnsModel = computed({
get: () => {
const columns = props.columns.map((column) =>
column.editor && !column.customCell
? { ...column, customCell: EditableCell, __fmsEditor: true }
: column,
);
if (!props.rowSelection) return columns;
return [
{
dataIndex: "__fmsSelection",
key: "__fmsSelection",
title: "",
width: 44,
fixed: "left",
align: "center",
headerAlign: "center",
customCell: SelectionCell,
customHeaderCell: SelectionHeaderCell,
__fmsSelection: true,
},
...columns,
];
},
set: (value) =>
emit(
"update:columns",
value
.filter((column) => !column.__fmsSelection)
.map((column) => {
if (!column.__fmsEditor) return column;
const rest = { ...column };
delete rest.customCell;
delete rest.__fmsEditor;
return rest;
}),
),
});
const slots = useSlots();
const tableRef = ref(null);
// 表格样式
const customVars = ref({
"--header-row-height": "40px",
"--row-height": "34px",
"--footer-row-height": "36px",
});
const { resume: refreshLayout } = useRafFn(
async () => {
await nextTick();
tableRef.value?.initVirtualScroll?.();
await nextTick();
},
{ immediate: false, once: true },
);
useResizeObserver(
() => tableRef.value?.$el,
([entry]) => {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) refreshLayout();
},
);
// Forward every slot except `empty`; the `empty` slot is handled below so the
// default Result fallback only renders when the parent does not provide one.
const forwardSlots = computed(() => {
const result = {};
for (const name in slots) {
if (name !== "empty") result[name] = slots[name];
}
return result;
});
</script>
<template>
<StkTable
ref="tableRef"
class="fms-table"
v-bind="$attrs"
:style="customVars"
:auto-resize="false"
:virtual="virtual"
:virtual-x="virtualX"
:col-resizable="colResizable"
v-model:columns="tableColumnsModel"
:data-source="dataSource"
:row-key="rowKey"
:bordered="bordered"
:no-data-full="noDataFull"
>
<template v-for="(slot, name) in forwardSlots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
<template v-if="slots.empty" #empty>
<slot name="empty" />
</template>
<template v-else #empty>
<a-result status="info" title="暂无数据" sub-title="当前没有可显示的内容" />
</template>
</StkTable>
</template>
<style scoped>
.fms-table {
width: 100%;
height: 100%;
}
:deep(.fms-cell-text) {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
</style>
@@ -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,
});
};
}
@@ -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 };
}
@@ -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",
+10
View File
@@ -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",
+9
View File
@@ -95,3 +95,12 @@ export function saveObjectApi(requests) {
export function nextIdApi() {
return get("/data/nextid");
}
/**
* 读取表或视图的列结构,用于同步字段等场景。
* @param {string} table 表名或视图名,必填,后端会校验标识符。
* @returns {Promise<ApiResult<{name: string, type: string, nullable: boolean, size: number}[]>} 列结构响应。
*/
export function describeApi(table) {
return post("/data/describe", { table });
}
+12
View File
@@ -0,0 +1,12 @@
// 生成本地临时 ID,用于尚未保存到后端的子表新增行。
// 使用负数以区别于后端生成的雪花 ID(恒为正),保存时后端会替换为真实 ID。
let sequence = 0;
/**
* 生成会话内唯一的临时 ID(负数字符串)。
* @returns {string} 形如 "-1"、"-2" 的临时 ID。
*/
export function createTempId() {
sequence += 1;
return String(-sequence);
}
@@ -0,0 +1,264 @@
<script setup>
import { h, ref } from "vue";
import { message } from "antdv-next";
import { Input, InputNumber, Select } from "antdv-next";
import FmsTable from "@/components/fms-table/FmsTable.vue";
const COLUMN_COUNT = 20;
const ROW_COUNT = 4000;
const STATUS_OPTIONS = [
{ label: "启用", value: 1 },
{ label: "禁用", value: 0 },
];
function randInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function randText(len) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let s = "";
for (let i = 0; i < len; i++) s += chars[randInt(0, chars.length - 1)];
return s;
}
function generateValue(c, i) {
switch (c % 5) {
case 0:
return randText(randInt(4, 10));
case 1:
return randInt(0, 99999);
case 2:
return STATUS_OPTIONS[randInt(0, 1)].value;
case 3:
return `名称-${i}-${c}`;
default:
return `${randInt(1000, 9999)}-${randText(4)}`;
}
}
function buildData() {
const rows = [];
for (let i = 0; i < ROW_COUNT; i++) {
const row = { id: `R${i}` };
for (let c = 1; c <= COLUMN_COUNT; c++) row[`col${c}`] = generateValue(c, i);
rows.push(row);
}
return rows;
}
const dataSource = ref(buildData());
// 当前编辑行 key;进入编辑时留存快照,取消可还原
const editingKey = ref(null);
const editSnapshot = ref(null);
function editConfigFor(c) {
switch (c % 5) {
case 1:
return { type: "number" };
case 2:
return { type: "select", options: STATUS_OPTIONS };
default:
return { type: "text", maxlength: 64 };
}
}
// 单元格渲染:编辑行显示控件,其他行显示纯文本
function makeCellEditor(dataIndex, edit) {
return (props) => {
const { row, cellValue } = props;
if (row.id !== editingKey.value) {
if (edit.type === "select") {
const opt = (edit.options || []).find((o) => o.value === cellValue);
return h("span", { class: "cell-display" }, opt ? opt.label : cellValue ?? "");
}
return h("span", { class: "cell-display" }, cellValue ?? "");
}
const update = (v) => {
row[dataIndex] = v;
};
const common = {
size: "small",
style: { width: "100%" },
onClick: (e) => e.stopPropagation(),
};
if (edit.type === "number") {
return h(InputNumber, {
...common,
value: cellValue,
min: edit.min,
max: edit.max,
precision: edit.precision,
"onUpdate:value": update,
});
}
if (edit.type === "select") {
return h(Select, {
...common,
value: cellValue,
options: edit.options || [],
"onUpdate:value": update,
});
}
return h(Input, {
...common,
value: cellValue,
maxlength: edit.maxlength,
"onUpdate:value": update,
});
};
}
function buildColumns() {
const cols = [
{
dataIndex: "id",
title: "编码",
width: 120,
fixed: "left",
customCell: makeCellEditor("id", { type: "text", maxlength: 32 }),
},
];
for (let c = 1; c <= COLUMN_COUNT; c++) {
const edit = editConfigFor(c);
cols.push({
dataIndex: `col${c}`,
title: `列 ${c}`,
width: 140,
sorter: edit.type === "number" ? true : undefined,
customCell: makeCellEditor(`col${c}`, edit),
});
}
return cols;
}
const columns = ref(buildColumns());
function onRowClick(ev, row) {
if (row.id === editingKey.value) return;
editingKey.value = row.id;
editSnapshot.value = JSON.parse(JSON.stringify(row));
}
function saveEdit() {
if (editingKey.value == null) return;
editingKey.value = null;
editSnapshot.value = null;
message.success("已保存当前行修改");
}
function cancelEdit() {
if (editingKey.value != null && editSnapshot.value) {
const idx = dataSource.value.findIndex((r) => r.id === editingKey.value);
if (idx >= 0) dataSource.value[idx] = editSnapshot.value;
}
editingKey.value = null;
editSnapshot.value = null;
message.info("已取消编辑");
}
function regenerate() {
editingKey.value = null;
editSnapshot.value = null;
dataSource.value = buildData();
message.success("已重新生成随机数据");
}
function rowClassName(row) {
return row.id === editingKey.value ? "fms-row-editing" : "";
}
</script>
<template>
<main class="table-demo">
<header class="table-demo__toolbar">
<div class="table-demo__title">
<strong>大数据表格 Demo</strong>
<span class="table-demo__meta">
共 {{ dataSource.length }} 行 · {{ columns.length }} 列 ·
<template v-if="editingKey">正在编辑:{{ editingKey }}</template>
<template v-else>点击任意行进入编辑</template>
</span>
</div>
<div class="table-demo__actions">
<a-button :disabled="editingKey == null" type="primary" @click="saveEdit">保存</a-button>
<a-button :disabled="editingKey == null" @click="cancelEdit">取消</a-button>
<a-button @click="regenerate">重新生成</a-button>
</div>
</header>
<section class="table-demo__body">
<FmsTable
v-model:columns="columns"
:data-source="dataSource"
row-key="id"
:row-class-name="rowClassName"
@row-click="onRowClick"
/>
</section>
</main>
</template>
<style scoped>
.table-demo {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
color: var(--fms-text);
background: var(--fms-surface);
}
.table-demo__toolbar {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--fms-border);
}
.table-demo__title {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.table-demo__title strong {
font-size: 15px;
font-weight: 600;
}
.table-demo__meta {
color: var(--fms-text-secondary);
font-size: 12px;
}
.table-demo__actions {
display: flex;
flex: none;
gap: 8px;
}
.table-demo__body {
flex: 1;
min-height: 0;
padding: 12px 16px 16px;
}
:deep(.fms-row-editing) {
background: rgba(22, 119, 255, 0.08) !important;
}
:deep(.cell-display) {
display: block;
min-height: 22px;
padding: 0 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -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";
@@ -40,6 +40,7 @@ function updateRoute(value) {
<ModuleTranslationEditor
v-model:translations="translations"
:i18n-key="module.b_i18n"
:default-name="module.b_name"
:disabled="!module.b_i18n?.trim()"
/>
</div>
@@ -80,6 +81,7 @@ function updateRoute(value) {
<ModuleTranslationEditor
v-model:translations="translations"
:i18n-key="module.b_i18n"
:default-name="module.b_name"
:disabled="!module.b_i18n?.trim()"
/>
</div>
@@ -1,57 +1,83 @@
<script setup>
import { computed, ref, watch } from "vue";
import { ref } from "vue";
import { App } from "antdv-next";
import { Plus, Trash2 } from "@lucide/vue";
import FmsTable from "@/components/FmsTable.vue";
import { nextIdApi } from "@/services/api";
import { RefreshCw } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { describeApi } from "@/services/api";
import { createTempId } from "@/utils/tempId";
import { createI18nCellRenderer } from "./createI18nCellRenderer";
import ModuleTranslationEditor from "./ModuleTranslationEditor.vue";
const props = defineProps({
moduleId: { type: [String, Number], default: "" },
moduleCode: { type: String, default: "" },
// 字段来源:模块配置的查询表或视图,为空时无法同步。
viewTable: { type: String, default: "" },
});
const fields = defineModel("fields", { type: Array, default: () => [] });
const translations = defineModel("translations", { type: Object, default: () => ({}) });
const { message } = App.useApp();
const adding = ref(false);
const selectedRowKeys = ref([]);
// 当前编辑行 key;点击某行进入单行编辑,再次点击退出。
const activeRowKey = ref(null);
const syncing = ref(false);
const translationEditorRef = ref(null);
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
const renderI18nCell = createI18nCellRenderer({
activeRowKey,
translationEditorRef,
});
watch(
() => fields.value.map((item) => String(item.b_id)),
(keys) => {
const available = new Set(keys);
selectedRowKeys.value = selectedRowKeys.value.filter((key) => available.has(String(key)));
},
);
function nextFieldCode() {
const existing = new Set(fields.value.map((item) => item.b_field));
let index = 1;
while (existing.has(`b_new_${index}`)) index += 1;
return `b_new_${index}`;
function onRowClick(_ev, row) {
const key = String(row.b_id);
activeRowKey.value = activeRowKey.value === key ? null : key;
}
async function onAdd() {
if (!props.moduleId || adding.value) return;
adding.value = true;
function mapType(type) {
const t = String(type || "").toLowerCase();
if (t.includes("bit")) return "boolean";
if (/(tinyint|smallint|int|bigint|decimal|numeric|float|real|money|smallmoney)/.test(t)) {
return "number";
}
if (/(datetime|smalldatetime|date|time)/.test(t)) return "date";
if (/(text|ntext)/.test(t)) return "text";
return "string";
}
// 从视图同步字段:保留已有配置,追加视图新增列,移除视图已不存在的列。
async function syncFields() {
const source = props.viewTable;
if (!source) {
message.warning("请先在基本信息中配置查询表或视图");
return;
}
syncing.value = true;
try {
const code = nextFieldCode();
const id = String((await nextIdApi()).data);
fields.value = [
...fields.value,
{
b_id: id,
const columns = (await describeApi(source)).data || [];
if (!columns.length) {
message.warning("该视图没有可识别的列");
return;
}
const existingByField = new Map(fields.value.map((item) => [item.b_field, item]));
const merged = [];
let maxXh = 0;
fields.value.forEach((item) => {
if (columns.some((col) => col.name === item.b_field)) {
merged.push(item);
maxXh = Math.max(maxXh, Number(item.b_xh) || 0);
}
});
for (const col of columns) {
if (existingByField.has(col.name)) continue;
maxXh += 10;
merged.push({
b_id: createTempId(),
b_module_id: props.moduleId,
b_field: code,
b_name: "新字段",
b_i18n: props.moduleCode ? `field.${props.moduleCode}.${code}` : null,
b_type: "string",
b_field: col.name,
b_name: col.name,
b_i18n: props.moduleCode ? `field.${props.moduleCode}.${col.name}` : null,
b_type: mapType(col.type),
b_canlist: 1,
b_canform: 1,
b_must: 0,
@@ -59,27 +85,28 @@ async function onAdd() {
b_readonly: 0,
b_width: 120,
b_value: null,
b_xh: (fields.value.length + 1) * 10,
b_xh: maxXh,
b_canuse: 1,
},
];
});
}
fields.value = merged;
message.success(`已同步 ${columns.length} 个字段`);
} catch (error) {
message.error(error.message || "字段创建失败");
message.error(error.message || "字段同步失败");
} finally {
adding.value = false;
syncing.value = false;
}
}
function onBatchDelete() {
const selected = new Set(selectedRowKeys.value.map(String));
fields.value = fields.value.filter((item) => !selected.has(String(item.b_id)));
selectedRowKeys.value = [];
}
const columns = ref([
{
dataIndex: "b_i18n",
title: "多语言键",
width: 250,
customCell: renderI18nCell,
},
{ dataIndex: "b_field", title: "字段", width: 150, editor: { maxlength: 50 } },
{ dataIndex: "b_name", title: "名称", width: 150, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "多语言键", width: 230, editor: { maxlength: 150 } },
{
dataIndex: "b_type",
title: "类型",
@@ -145,13 +172,9 @@ const columns = ref([
<template>
<section class="table-pane">
<div class="toolbar">
<a-button type="primary" :loading="adding" @click="onAdd">
<template #icon><Plus /></template>
新增
</a-button>
<a-button danger :disabled="!selectedRowKeys.length" @click="onBatchDelete">
<template #icon><Trash2 /></template>
批量删除
<a-button type="primary" :loading="syncing" @click="syncFields">
<template #icon><RefreshCw /></template>
同步字段
</a-button>
</div>
<FmsTable
@@ -159,8 +182,10 @@ const columns = ref([
row-key="b_id"
v-model:columns="columns"
:data-source="fields"
:row-selection="rowSelection"
:active-row-key="activeRowKey"
@row-click="onRowClick"
/>
<ModuleTranslationEditor ref="translationEditorRef" v-model:translations="translations" />
</section>
</template>
@@ -178,6 +203,7 @@ const columns = ref([
gap: 8px;
flex: none;
padding: 10px 0;
align-items: center;
}
.sub-table {
@@ -1,9 +1,10 @@
<script setup>
import { computed, ref, watch } from "vue";
import { App } from "antdv-next";
import { Plus, Trash2 } from "@lucide/vue";
import FmsTable from "@/components/FmsTable.vue";
import { nextIdApi } from "@/services/api";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import { createI18nCellRenderer } from "./createI18nCellRenderer";
import ModuleTranslationEditor from "./ModuleTranslationEditor.vue";
const props = defineProps({
moduleId: { type: [String, Number], default: "" },
@@ -11,9 +12,21 @@ const props = defineProps({
});
const powers = defineModel("powers", { type: Array, default: () => [] });
const { message } = App.useApp();
const adding = ref(false);
const translations = defineModel("translations", { type: Object, default: () => ({}) });
const selectedRowKeys = ref([]);
// 当前编辑行 key;点击某行进入单行编辑,再次点击退出。
const activeRowKey = ref(null);
const translationEditorRef = ref(null);
const renderI18nCell = createI18nCellRenderer({
activeRowKey,
translationEditorRef,
});
function onRowClick(_ev, row) {
const key = String(row.b_id);
activeRowKey.value = activeRowKey.value === key ? null : key;
}
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
@@ -37,41 +50,42 @@ function nextPowerCode() {
return `new_${index}`;
}
async function onAdd() {
if (!props.moduleId || adding.value) return;
adding.value = true;
try {
const code = nextPowerCode();
const id = String((await nextIdApi()).data);
powers.value = [
...powers.value,
{
b_id: id,
b_module_id: props.moduleId,
b_code: code,
b_name: "新权限",
b_i18n: props.moduleCode ? `power.${props.moduleCode}.${code}` : null,
b_canuse: 1,
b_xh: (powers.value.length + 1) * 10,
},
];
} catch (error) {
message.error(error.message || "权限创建失败");
} finally {
adding.value = false;
}
function onAdd() {
if (!props.moduleId) return;
const code = nextPowerCode();
powers.value = [
...powers.value,
{
b_id: createTempId(),
b_module_id: props.moduleId,
b_code: code,
b_name: "新权限",
b_i18n: props.moduleCode ? `power.${props.moduleCode}.${code}` : null,
b_canuse: 1,
b_xh: (powers.value.length + 1) * 10,
},
];
activeRowKey.value = powers.value[powers.value.length - 1].b_id;
}
function onBatchDelete() {
const selected = new Set(selectedRowKeys.value.map(String));
powers.value = powers.value.filter((item) => !selected.has(String(item.b_id)));
selectedRowKeys.value = [];
if (activeRowKey.value && selected.has(String(activeRowKey.value))) {
activeRowKey.value = null;
}
}
const columns = ref([
{
dataIndex: "b_i18n",
title: "多语言键",
width: 250,
customCell: renderI18nCell,
},
{ dataIndex: "b_code", title: "权限代码", width: 180, editor: { maxlength: 50 } },
{ dataIndex: "b_name", title: "名称", width: 180, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "多语言键", width: 240, editor: { maxlength: 150 } },
{
dataIndex: "b_canuse",
title: "启用",
@@ -87,7 +101,7 @@ const columns = ref([
<template>
<section class="table-pane">
<div class="toolbar">
<a-button type="primary" :loading="adding" @click="onAdd">
<a-button type="primary" @click="onAdd">
<template #icon><Plus /></template>
新增
</a-button>
@@ -102,7 +116,10 @@ const columns = ref([
v-model:columns="columns"
:data-source="powers"
:row-selection="rowSelection"
:active-row-key="activeRowKey"
@row-click="onRowClick"
/>
<ModuleTranslationEditor ref="translationEditorRef" v-model:translations="translations" />
</section>
</template>
@@ -1,5 +1,5 @@
<script setup>
import { reactive } from "vue";
import { computed, reactive } from "vue";
import { App } from "antdv-next";
import { Languages } from "@lucide/vue";
import { I18N_LOCALES, isValidI18nKey } from "@/utils/i18n";
@@ -7,25 +7,50 @@ import { I18N_LOCALES, isValidI18nKey } from "@/utils/i18n";
const props = defineProps({
i18nKey: { type: String, default: "" },
disabled: { type: Boolean, default: false },
// 默认语言(由 b_name 承载),不在此编辑器中维护,避免两份中文名互相脱节。
defaultLocale: { type: String, default: "zh-CN" },
// 默认语言对应的名称(如 b_name 的中文名),仅展示用,帮助辨识所属对象。
defaultName: { type: String, default: "" },
});
const translations = defineModel("translations", { type: Object, default: () => ({}) });
const { message } = App.useApp();
const dialog = reactive({ open: false, key: "", values: {} });
const dialog = reactive({ open: false, key: "", name: "", values: {} });
function openEditor() {
const key = props.i18nKey.trim();
// 仅维护非默认语言;默认语言(zh-CN)由对应实体的 b_name 字段承载。
const editableLocales = computed(() =>
I18N_LOCALES.filter((locale) => locale.value !== props.defaultLocale),
);
const defaultLocaleLabel = computed(
() =>
I18N_LOCALES.find((locale) => locale.value === props.defaultLocale)?.label ||
props.defaultLocale,
);
// 支持两种打开方式:
// 1) 自带按钮点击:使用 props.i18nKey / props.defaultName
// 2) 表格内按行打开:传入 key / name(如字段、权限的某行)
function openEditor(forceKey, forceName) {
const key = String(forceKey ?? props.i18nKey).trim();
const name = forceName ?? props.defaultName;
if (!isValidI18nKey(key)) {
message.warning("请先填写正确的多语言键");
return;
}
dialog.key = key;
dialog.name = name || "";
dialog.values = Object.fromEntries(
I18N_LOCALES.map((locale) => [locale.value, translations.value[key]?.[locale.value] || ""]),
editableLocales.value.map((locale) => [
locale.value,
translations.value[key]?.[locale.value] || "",
]),
);
dialog.open = true;
}
defineExpose({ open: openEditor });
function saveTranslations() {
// 整条 key 只写非默认语言,顺带清掉历史残留的默认语言翻译,杜绝覆盖 b_name。
translations.value = {
...translations.value,
[dialog.key]: { ...dialog.values },
@@ -35,7 +60,7 @@ function saveTranslations() {
</script>
<template>
<a-button :disabled="disabled" @click="openEditor">
<a-button v-if="i18nKey" :disabled="disabled" @click="openEditor">
<template #icon><Languages /></template>
多语言
</a-button>
@@ -49,9 +74,12 @@ function saveTranslations() {
>
<a-form layout="vertical">
<a-form-item label="多语言键">
<a-input :value="dialog.key" disabled />
<a-input :value="dialog.key" readonly />
</a-form-item>
<a-form-item v-for="locale in I18N_LOCALES" :key="locale.value" :label="locale.label">
<a-form-item :label="`${defaultLocaleLabel}(默认语言)`">
<a-input :value="dialog.name" readonly />
</a-form-item>
<a-form-item v-for="locale in editableLocales" :key="locale.value" :label="locale.label">
<a-input v-model:value="dialog.values[locale.value]" :maxlength="500" />
</a-form-item>
</a-form>
@@ -0,0 +1,64 @@
import { h } from "vue";
import { Input } from "antdv-next";
import { Languages } from "@lucide/vue";
const cellStyle = {
display: "flex",
alignItems: "center",
gap: "4px",
width: "100%",
};
const inputStyle = { flex: "1 1 auto", minWidth: 0 };
const textStyle = {
flex: "1 1 auto",
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const iconStyle = {
display: "inline-flex",
flex: "none",
alignItems: "center",
justifyContent: "center",
width: "24px",
height: "24px",
cursor: "pointer",
color: "var(--ant-color-primary, #1677ff)",
};
export function createI18nCellRenderer({ activeRowKey, translationEditorRef }) {
return function renderI18nCell({ row }) {
const editing = activeRowKey.value != null && String(row.b_id) === String(activeRowKey.value);
return h("div", { style: cellStyle }, [
editing
? h("div", { style: inputStyle }, [
h(Input, {
value: row.b_i18n,
size: "small",
maxlength: 150,
allowClear: true,
style: { width: "100%" },
onClick: (event) => event.stopPropagation(),
"onUpdate:value": (value) => {
row.b_i18n = value;
},
}),
])
: h("span", { style: textStyle }, row.b_i18n ?? ""),
h(
"span",
{
style: iconStyle,
title: "编辑翻译",
onClick: (event) => {
event.stopPropagation();
translationEditorRef.value?.open(row.b_i18n, row.b_name);
},
},
[h(Languages, { size: 16 })],
),
]);
};
}
@@ -3,15 +3,20 @@ import { computed, onMounted, reactive, ref } from "vue";
import { App } from "antdv-next";
import { Database, FileText, RotateCcw, Save, ShieldCheck, Trash2 } from "@lucide/vue";
import { loadDataApi, nextIdApi, saveObjectApi } from "@/services/api";
import { createTempId } from "@/utils/tempId";
import { useAuthStore } from "@/stores/auth";
import { usePermissionStore } from "@/stores/permissions";
import { I18N_LOCALE_CODES } from "@/utils/i18n";
// 默认语言:由实体的 b_name 字段承载,不写入 b_i18n 翻译表(方案 A)。
const I18N_DEFAULT_LOCALE = "zh-CN";
import ModuleBasicInfoPanel from "./components/ModuleBasicInfoPanel.vue";
import ModuleFieldDefinitionPanel from "./components/ModuleFieldDefinitionPanel.vue";
import ModulePowerPanel from "./components/ModulePowerPanel.vue";
import ModuleTreePanel from "./components/ModuleTreePanel.vue";
const PROTECTED_MODULE_CODES = ["m_module", "m_i18n", "m_biz"];
// 受保护的模块(禁止删除)。当前放开为可删除,留空即不做保护。
const PROTECTED_MODULE_CODES = [];
const { message } = App.useApp();
const authStore = useAuthStore();
const permissionStore = usePermissionStore();
@@ -22,8 +27,8 @@ const activeTab = ref("basic");
const isNew = ref(false);
const deleteConfirmOpen = ref(false);
const pendingDeleteKey = ref("");
const original = ref("");
const originalTranslationKeys = ref([]);
let originalDraft = null;
const data = reactive({
modules: [],
fields: [],
@@ -38,7 +43,6 @@ const canCreate = computed(() => permissionStore.canPower("m_module", "create"))
const canUpdate = computed(() => permissionStore.canPower("m_module", "update"));
const canDelete = computed(() => permissionStore.canPower("m_module", "delete"));
const canSave = computed(() => (isNew.value ? canCreate.value : canUpdate.value));
const dirty = computed(() => Boolean(draft.module) && serializeDraft() !== original.value);
const parentOptions = computed(() =>
data.modules
.filter((item) => item.b_id !== draft.module?.b_id && item.b_type !== "data")
@@ -61,15 +65,6 @@ function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function serializeDraft() {
return JSON.stringify({
module: draft.module,
fields: draft.fields,
powers: draft.powers,
translations: draft.translations,
});
}
function rowKeys() {
return [
draft.module?.b_i18n,
@@ -85,7 +80,7 @@ function clearDraft() {
draft.fields = [];
draft.powers = [];
draft.translations = {};
original.value = "";
originalDraft = null;
originalTranslationKeys.value = [];
activeTab.value = "basic";
}
@@ -108,12 +103,18 @@ function selectModule(moduleCode) {
data.translations
.filter((item) => keys.has(item.b_key))
.forEach((item) => {
if (item.b_locale === I18N_DEFAULT_LOCALE) return;
draft.translations[item.b_key] ||= {};
draft.translations[item.b_key][item.b_locale] = item.b_value;
});
originalTranslationKeys.value = Array.from(keys);
original.value = serializeDraft();
originalDraft = clone({
module: draft.module,
fields: draft.fields,
powers: draft.powers,
translations: draft.translations,
});
if (module.b_type === "directory") activeTab.value = "basic";
}
@@ -189,12 +190,10 @@ async function addModule(kind, contextKey) {
draft.fields = [];
draft.powers = [];
originalTranslationKeys.value = [];
draft.translations = {
["module." + code]: { "zh-CN": draft.module.b_name },
};
draft.translations = {};
originalDraft = { module: null, fields: [], powers: [], translations: {} };
selectedKey.value = code;
isNew.value = true;
original.value = "";
activeTab.value = "basic";
}
@@ -278,6 +277,10 @@ function diffRows(current, previous) {
};
}
function sameRecord(current, previous) {
return JSON.stringify(current) === JSON.stringify(previous);
}
async function translationChanges(previousKeys) {
const currentKeys = new Set([...rowKeys(), ...Object.keys(draft.translations)]);
const relevantKeys = new Set([...previousKeys, ...currentKeys]);
@@ -285,8 +288,9 @@ async function translationChanges(previousKeys) {
const desired = [];
const missing = [];
const editableLocales = I18N_LOCALE_CODES.filter((locale) => locale !== I18N_DEFAULT_LOCALE);
for (const key of currentKeys) {
for (const locale of I18N_LOCALE_CODES) {
for (const locale of editableLocales) {
const value = draft.translations[key]?.[locale]?.trim();
if (!value) continue;
const old = oldRows.find((item) => item.b_key === key && item.b_locale === locale);
@@ -301,15 +305,10 @@ async function translationChanges(previousKeys) {
.filter((item) => !I18N_LOCALE_CODES.includes(item.b_locale))
.forEach((item) => desired.push(item));
const ids = [];
for (let index = 0; index < missing.length; index += 1) {
const response = await nextIdApi();
ids.push(String(response.data));
}
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
missing.forEach((item, index) => {
missing.forEach((item) => {
desired.push({
b_id: ids[index],
b_id: createTempId(),
b_key: item.key,
b_locale: item.locale,
b_value: item.value,
@@ -339,15 +338,9 @@ async function saveModule() {
saving.value = true;
try {
const oldModule = isNew.value
? null
: data.modules.find((item) => item.b_code === selectedKey.value);
const oldFields = oldModule
? data.fields.filter((item) => item.b_module_id === oldModule.b_id)
: [];
const oldPowers = oldModule
? data.powers.filter((item) => item.b_module_id === oldModule.b_id)
: [];
const oldModule = originalDraft?.module ?? null;
const oldFields = originalDraft?.fields ?? [];
const oldPowers = originalDraft?.powers ?? [];
const previousKeys = new Set(
[
...originalTranslationKeys.value,
@@ -380,18 +373,21 @@ async function saveModule() {
table: "b_module",
key_field: "b_id",
inserts: isNew.value ? [draft.module] : [],
updates: isNew.value ? [] : [draft.module],
updates: !isNew.value && !sameRecord(draft.module, oldModule) ? [draft.module] : [],
deletes: [],
},
{ table: "b_module_field", key_field: "b_id", ...fieldChanges },
{ table: "b_module_power", key_field: "b_id", ...powerChanges },
{ table: "b_i18n", key_field: "b_id", ...i18nChanges },
);
await saveObjectApi(
requests.filter(
(request) => request.inserts.length || request.updates.length || request.deletes.length,
),
const pendingRequests = requests.filter(
(request) => request.inserts.length || request.updates.length || request.deletes.length,
);
if (!pendingRequests.length) {
message.info("没有需要保存的修改");
return;
}
await saveObjectApi(pendingRequests);
const savedKey = draft.module.b_code;
message.success("模块配置已保存");
@@ -553,9 +549,7 @@ onMounted(async () => {
</div>
<div class="header-actions">
<a-button :disabled="!dirty" @click="resetDraft">
<RotateCcw :size="15" />取消
</a-button>
<a-button @click="resetDraft"> <RotateCcw :size="15" />取消 </a-button>
<a-popconfirm
title="确定删除当前模块吗?"
:disabled="
@@ -572,12 +566,7 @@ onMounted(async () => {
<Trash2 :size="15" />删除
</a-button>
</a-popconfirm>
<a-button
type="primary"
:loading="saving"
:disabled="!dirty || !canSave"
@click="saveModule"
>
<a-button type="primary" :loading="saving" :disabled="!canSave" @click="saveModule">
<Save :size="15" />保存
</a-button>
</div>
@@ -604,8 +593,10 @@ onMounted(async () => {
</template>
<ModuleFieldDefinitionPanel
v-model:fields="draft.fields"
v-model:translations="draft.translations"
:module-id="draft.module.b_id"
:module-code="draft.module.b_code"
:view-table="draft.module.b_viewtable || draft.module.b_savetable"
/>
</a-tab-pane>
<a-tab-pane v-if="draft.module.b_type !== 'directory'" key="powers">
@@ -616,6 +607,7 @@ onMounted(async () => {
</template>
<ModulePowerPanel
v-model:powers="draft.powers"
v-model:translations="draft.translations"
:module-id="draft.module.b_id"
:module-code="draft.module.b_code"
/>
+141 -11
View File
@@ -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`