20260722173146
This commit is contained in:
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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+84
-58
@@ -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>
|
||||
|
||||
|
||||
+36
-8
@@ -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
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user