20260728225443

This commit is contained in:
oneao committed 2026-07-28 22:54:43 +08:00
1 parent 5ec7aadef9
commit f6b79ea09a
30 files changed
+1541 -1120

No files matched your search

@@ -60,18 +60,7 @@ GO
b_canuse int NOT NULL CONSTRAINT DF_s_module_canuse DEFAULT (1),
b_xh int NOT NULL CONSTRAINT DF_s_module_xh DEFAULT (0),
CONSTRAINT PK_s_module PRIMARY KEY (b_id),
CONSTRAINT UQ_s_module_code UNIQUE (b_code),
CONSTRAINT CK_s_module_code CHECK (
LEN(b_code) BETWEEN 2 AND 50
AND b_code COLLATE Latin1_General_100_BIN2 LIKE '[a-z]%'
AND b_code COLLATE Latin1_General_100_BIN2 NOT LIKE '%[^a-z0-9_]%'
),
CONSTRAINT CK_s_module_type CHECK (b_module_type IN ('directory', 'page', 'data')),
CONSTRAINT CK_s_module_canmenu CHECK (b_canmenu IN (0, 1)),
CONSTRAINT CK_s_module_canuse CHECK (b_canuse IN (0, 1)),
CONSTRAINT CK_s_module_directory CHECK (b_module_type <> 'directory' OR (b_route IS NULL AND b_viewtable IS NULL AND b_savetable IS NULL)),
CONSTRAINT CK_s_module_page CHECK (b_module_type <> 'page' OR b_route IS NOT NULL),
CONSTRAINT CK_s_module_data CHECK (b_module_type <> 'data' OR (b_canmenu = 0 AND b_route IS NULL))
CONSTRAINT UQ_s_module_code UNIQUE (b_code)
);
CREATE UNIQUE INDEX UX_s_module_route ON dbo.s_module (b_route) WHERE b_route IS NOT NULL;
CREATE INDEX IX_s_module_parent ON dbo.s_module (b_parent_id, b_xh, b_id);
@@ -87,10 +76,7 @@ GO
b_options nvarchar(max) NULL,
b_canuse int NOT NULL CONSTRAINT DF_s_module_field_canuse DEFAULT (1),
CONSTRAINT PK_s_module_field PRIMARY KEY (b_id),
CONSTRAINT UQ_s_module_field UNIQUE (b_module_id, b_field),
CONSTRAINT CK_s_module_field_type CHECK (b_type IN ('input', 'textarea', 'number', 'money', 'date', 'datetime', 'switch', 'select')),
CONSTRAINT CK_s_module_field_canuse CHECK (b_canuse IN (0, 1)),
CONSTRAINT CK_s_module_field_options CHECK (b_options IS NULL OR ISJSON(b_options) = 1)
CONSTRAINT UQ_s_module_field UNIQUE (b_module_id, b_field)
);
CREATE INDEX IX_s_module_field_module ON dbo.s_module_field (b_module_id, b_canuse, b_id);
@@ -102,8 +88,7 @@ GO
b_i18n varchar(150) NULL,
b_xh int NOT NULL CONSTRAINT DF_s_module_field_group_xh DEFAULT (0),
b_canuse int NOT NULL CONSTRAINT DF_s_module_field_group_canuse DEFAULT (1),
CONSTRAINT PK_s_module_field_group PRIMARY KEY (b_id),
CONSTRAINT CK_s_module_field_group_canuse CHECK (b_canuse IN (0, 1))
CONSTRAINT PK_s_module_field_group PRIMARY KEY (b_id)
);
CREATE INDEX IX_s_module_field_group_module ON dbo.s_module_field_group (b_module_id, b_canuse, b_xh, b_id);
@@ -116,9 +101,7 @@ GO
b_visible int NOT NULL CONSTRAINT DF_s_module_field_list_visible DEFAULT (1),
b_xh int NOT NULL CONSTRAINT DF_s_module_field_list_xh DEFAULT (0),
b_width int NULL,
CONSTRAINT PK_s_module_field_list PRIMARY KEY (b_id),
CONSTRAINT CK_s_module_field_list_visible CHECK (b_visible IN (0, 1)),
CONSTRAINT CK_s_module_field_list_width CHECK (b_width IS NULL OR b_width > 0)
CONSTRAINT PK_s_module_field_list PRIMARY KEY (b_id)
);
CREATE UNIQUE INDEX UX_s_module_field_list_field ON dbo.s_module_field_list (b_module_id, b_field_id) WHERE b_field_id IS NOT NULL;
CREATE INDEX IX_s_module_field_list_tree ON dbo.s_module_field_list (b_module_id, b_group_id, b_xh, b_id);
@@ -133,10 +116,7 @@ GO
b_group_id bigint NULL,
b_xh int NOT NULL CONSTRAINT DF_s_module_field_edit_xh DEFAULT (0),
CONSTRAINT PK_s_module_field_edit PRIMARY KEY (b_id),
CONSTRAINT UQ_s_module_field_edit UNIQUE (b_module_id, b_field_id),
CONSTRAINT CK_s_module_field_edit_required CHECK (b_required IN (0, 1)),
CONSTRAINT CK_s_module_field_edit_readonly CHECK (b_readonly IN (0, 1)),
CONSTRAINT CK_s_module_field_edit_disabled CHECK (b_disabled IN (0, 1))
CONSTRAINT UQ_s_module_field_edit UNIQUE (b_module_id, b_field_id)
);
CREATE INDEX IX_s_module_field_edit_module ON dbo.s_module_field_edit (b_module_id, b_xh, b_id);
@@ -150,9 +130,7 @@ GO
b_xh int NOT NULL CONSTRAINT DF_s_module_field_query_xh DEFAULT (0),
b_canuse int NOT NULL CONSTRAINT DF_s_module_field_query_canuse DEFAULT (1),
CONSTRAINT PK_s_module_field_query PRIMARY KEY (b_id),
CONSTRAINT UQ_s_module_field_query UNIQUE (b_module_id, b_field_id),
CONSTRAINT CK_s_module_field_query_operator CHECK (b_operator IN ('=', 'like', 'between', 'in')),
CONSTRAINT CK_s_module_field_query_canuse CHECK (b_canuse IN (0, 1))
CONSTRAINT UQ_s_module_field_query UNIQUE (b_module_id, b_field_id)
);
CREATE INDEX IX_s_module_field_query_module ON dbo.s_module_field_query (b_module_id, b_canuse, b_xh, b_id);
@@ -165,8 +143,7 @@ GO
b_xh int NOT NULL CONSTRAINT DF_s_module_power_xh DEFAULT (0),
b_canuse int NOT NULL CONSTRAINT DF_s_module_power_canuse DEFAULT (1),
CONSTRAINT PK_s_module_power PRIMARY KEY (b_id),
CONSTRAINT UQ_s_module_power UNIQUE (b_module_id, b_code),
CONSTRAINT CK_s_module_power_canuse CHECK (b_canuse IN (0, 1))
CONSTRAINT UQ_s_module_power UNIQUE (b_module_id, b_code)
);
CREATE TABLE dbo.b_user_module (
@@ -34,9 +34,7 @@ BEGIN TRANSACTION;
b_default int NOT NULL CONSTRAINT DF_b_i18n_type_default DEFAULT (0),
b_xh int NOT NULL CONSTRAINT DF_b_i18n_type_xh DEFAULT (0),
CONSTRAINT PK_b_i18n_type PRIMARY KEY (b_id),
CONSTRAINT UQ_b_i18n_type_code UNIQUE (b_code),
CONSTRAINT CK_b_i18n_type_canuse CHECK (b_canuse IN (0, 1)),
CONSTRAINT CK_b_i18n_type_default CHECK (b_default IN (0, 1))
CONSTRAINT UQ_b_i18n_type_code UNIQUE (b_code)
);
CREATE INDEX IX_b_i18n_type_xh ON dbo.b_i18n_type (b_xh, b_id);
@@ -0,0 +1,20 @@
/* 删除数据库中的 CHECK 约束。 */
DECLARE @Sql nvarchar(max) = N'';
SELECT @Sql = @Sql
+ N'ALTER TABLE '
+ QUOTENAME(OBJECT_SCHEMA_NAME(parent_object_id)) + N'.'
+ QUOTENAME(OBJECT_NAME(parent_object_id))
+ N' DROP CONSTRAINT ' + QUOTENAME(name) + N';'
FROM sys.check_constraints;
IF @Sql <> N''
EXEC sys.sp_executesql @Sql;
/* 将旧的 switch 字段类型统一改为 checkbox。 */
IF OBJECT_ID(N'dbo.s_module_field', N'U') IS NOT NULL
BEGIN
UPDATE dbo.s_module_field
SET b_type = 'checkbox'
WHERE b_type = 'switch';
END;
@@ -7,7 +7,6 @@ const props = defineProps({
modelValue: { type: String, default: "" },
disabled: { type: Boolean, default: false },
readonly: { type: Boolean, default: false },
allowClear: { type: Boolean, default: true },
maxlength: { type: Number, default: undefined },
popupWidth: { type: [Number, String], default: 420 },
popupHeight: { type: [Number, String], default: 180 },
@@ -139,7 +138,6 @@ onBeforeUnmount(() => document.removeEventListener("pointerdown", onDocumentPoin
:value="modelValue"
:disabled="disabled"
:readonly="readonly"
:allow-clear="allowClear"
:maxlength="maxlength"
@update:value="updateValue"
@change="onInputChange"
@@ -1,15 +1,26 @@
<script setup>
import { computed, h, nextTick, ref, toRef, useSlots, watch } from "vue";
import { computed, h, nextTick, reactive, ref, shallowRef, useSlots, watch } from "vue";
import { useRoute } from "vue-router";
import { message } from "antdv-next";
import { Columns3, RefreshCw } from "@lucide/vue";
import { loadDataApi, pageDataApi } from "@/services/api";
import { useAppStore } from "@/stores/app";
import {
identifierRe,
buildQueryCondition,
defaultQueryValue,
escapeSqlText,
parseOptions,
requireModuleId,
} from "./moduleListUtils";
import FmsModuleTable from "./FmsModuleTable.vue";
import FmsQueryToolbar from "./FmsQueryToolbar.vue";
import { useModuleList } from "./useModuleList";
const props = defineProps({
dataCode: { type: String, default: "" },
columnExtensions: { type: Object, default: () => ({}) },
rowActions: { type: Array, default: () => [] },
rowSelection: { type: Object, default: null },
rowClassName: { type: Function, default: undefined },
expandable: { type: [Boolean, Object], default: false },
pageSize: { type: Number, default: 20 },
@@ -18,25 +29,304 @@ const props = defineProps({
const emit = defineEmits(["row-click", "cell-click", "row-action", "expand", "loaded"]);
const slots = useSlots();
const route = useRoute();
const appStore = useAppStore();
const tableRef = ref(null);
const moduleList = useModuleList({
dataCode: toRef(props, "dataCode"),
pageSize: props.pageSize,
// ── 内部状态 ──
const dataModule = ref(null);
const activeFields = shallowRef([]);
const activeFieldById = shallowRef(new Map());
const listConfig = shallowRef([]);
const queryItems = shallowRef([]);
const dataSource = shallowRef([]);
const searchConditions = shallowRef([]);
const keyField = ref("");
const configLoading = ref(false);
const dataLoading = ref(false);
const configError = ref("");
const dataError = ref("");
const pagination = reactive({
current: 1,
pageSize: Number(props.pageSize) || 20,
total: 0,
});
const moreMenuItems = [
{ key: "refresh", label: "刷新数据", icon: h(RefreshCw, { size: 14 }) },
{ key: "reset-columns", label: "恢复配置列宽", icon: h(Columns3, { size: 14 }) },
];
const loading = computed(() => configLoading.value || dataLoading.value);
// ── 外部工具 ──
const moreMenuItems = computed(() => [
{
key: "refresh",
label: appStore.t("common.refreshData", "刷新数据"),
icon: h(RefreshCw, { size: 14 }),
},
{
key: "reset-columns",
label: appStore.t("common.resetColumns", "恢复配置列宽"),
icon: h(Columns3, { size: 14 }),
},
]);
const cellSlotNames = computed(() => Object.keys(slots).filter((name) => name.startsWith("cell-")));
// ── 模块配置加载 ──
function resolveKeyField(module, fields) {
if (identifierRe.test(module.b_keyfield || "")) return module.b_keyfield;
for (const field of fields) {
if (field.b_field === "b_id") return field.b_field;
}
for (const field of fields) {
if (identifierRe.test(field.b_field || "")) return field.b_field;
}
return "";
}
function getDataCode() {
return String(props.dataCode || route.meta.dataCode || "").trim();
}
async function loadModuleConfig() {
const code = getDataCode();
const currentPath = route.path;
configLoading.value = true;
configError.value = "";
try {
let runtimeData = null;
if (code) {
const moduleRes = await loadDataApi(
"s_module",
`b_code = N'${escapeSqlText(code)}' AND b_module_type = 'data' AND b_canuse = 1`,
"b_id ASC",
);
runtimeData = moduleRes.data?.[0];
if (!runtimeData) throw new Error(`未找到编码为 ${code} 的启用数据模块`);
const pageId = requireModuleId(runtimeData.b_parent_id);
const pageRes = await loadDataApi(
"s_module",
`b_id = ${pageId} AND b_module_type = 'page' AND b_canuse = 1`,
"b_id ASC",
);
if (!pageRes.data?.[0]) {
throw new Error(`数据编码 ${runtimeData.b_code} 没有对应的启用页面`);
}
} else {
const routePath = escapeSqlText(currentPath);
const pageRes = await loadDataApi(
"s_module",
`b_route = N'${routePath}' AND b_canuse = 1`,
"b_id ASC",
);
const page = pageRes.data?.[0];
if (!page) throw new Error(`未找到路由 ${currentPath} 对应的启用模块`);
if (page.b_viewtable) {
runtimeData = page;
} else {
const pageId = requireModuleId(page.b_id);
const dataRes = await loadDataApi(
"s_module",
`b_parent_id = ${pageId} AND b_module_type = 'data' AND b_canuse = 1`,
"b_xh ASC, b_id ASC",
);
for (const item of dataRes.data || []) {
if (item.b_viewtable) {
runtimeData = item;
break;
}
}
if (!runtimeData) throw new Error("当前页面没有配置可用的数据模块");
}
}
const dataId = requireModuleId(runtimeData.b_id);
const responses = await Promise.all([
loadDataApi("s_module_field", `b_module_id = ${dataId}`, "b_id ASC"),
loadDataApi("s_module_field_list", `b_module_id = ${dataId}`, "b_xh ASC, b_id ASC"),
loadDataApi(
"s_module_field_query",
`b_module_id = ${dataId} AND b_canuse = 1`,
"b_xh ASC, b_id ASC",
),
]);
if (getDataCode() !== code || route.path !== currentPath) return false;
const fields = [];
const fieldById = new Map();
for (const field of responses[0].data || []) {
if (field.b_canuse !== 1 || !identifierRe.test(field.b_field || "")) continue;
fields.push(field);
fieldById.set(String(field.b_id), field);
}
const lists = [];
for (const item of responses[1].data || []) {
if (item.b_visible !== 1) continue;
if (!fieldById.has(String(item.b_field_id))) continue;
lists.push(item);
}
const queries = [];
for (const config of responses[2].data || []) {
const field = fieldById.get(String(config.b_field_id));
if (!field) continue;
const optionInfo = parseOptions(field);
queries.push({
config,
field,
optionConfig: optionInfo.optionConfig,
options: optionInfo.options,
});
}
const conditions = [];
for (const item of queries) {
const condition = buildQueryCondition(item, defaultQueryValue(item));
if (condition) conditions.push(condition);
}
dataModule.value = runtimeData;
activeFields.value = fields;
activeFieldById.value = fieldById;
listConfig.value = lists;
queryItems.value = queries;
searchConditions.value = conditions;
keyField.value = resolveKeyField(runtimeData, fields);
return true;
} catch (error) {
if (getDataCode() === code && route.path === currentPath) {
configError.value = error?.message || String(error);
}
return false;
} finally {
if (getDataCode() === code && route.path === currentPath) {
configLoading.value = false;
}
}
}
// ── 数据查询 ──
function resolveOrderBy() {
if (!dataModule.value) throw new Error("数据模块尚未加载");
const configured = String(dataModule.value.b_orderfield || "").trim();
if (configured) return configured;
if (keyField.value) return `${keyField.value} DESC`;
if (listConfig.value.length > 0) {
const firstConfig = listConfig.value[0];
const firstField = activeFieldById.value.get(String(firstConfig.b_field_id));
if (firstField && identifierRe.test(firstField.b_field || "")) {
return `${firstField.b_field} ASC`;
}
}
throw new Error("数据模块未配置可用的默认排序字段");
}
async function fetchData() {
if (!dataModule.value || !dataModule.value.b_viewtable || dataLoading.value) return;
const currentModule = dataModule.value;
const viewName = currentModule.b_viewtable;
const currentCode = getDataCode();
const currentPath = route.path;
dataLoading.value = true;
dataError.value = "";
try {
const res = await pageDataApi({
viewName,
orderBy: resolveOrderBy(),
searchColumns: [],
searchCondition: searchConditions.value.join(" AND "),
pageNo: pagination.current,
pageSize: pagination.pageSize,
});
if (dataModule.value !== currentModule) return;
if (getDataCode() !== currentCode || route.path !== currentPath) return;
dataSource.value = res.data?.rows || [];
pagination.total = Number(res.data?.total ?? res.data?.row_count) || 0;
} catch (error) {
if (
dataModule.value === currentModule &&
getDataCode() === currentCode &&
route.path === currentPath
) {
dataSource.value = [];
pagination.total = 0;
dataError.value = error?.message || String(error);
}
} finally {
if (
dataModule.value === currentModule &&
getDataCode() === currentCode &&
route.path === currentPath
) {
dataLoading.value = false;
}
}
}
// ── 公开方法 ──
async function initialize() {
dataModule.value = null;
activeFields.value = [];
activeFieldById.value = new Map();
listConfig.value = [];
queryItems.value = [];
searchConditions.value = [];
keyField.value = "";
dataLoading.value = false;
dataError.value = "";
pagination.current = 1;
dataSource.value = [];
pagination.total = 0;
const loaded = await loadModuleConfig();
if (loaded) await fetchData();
}
function search({ entries = [] }) {
if (loading.value) return;
const conditions = [];
for (const entry of entries) {
const condition = buildQueryCondition(entry.item, entry.value);
if (condition) conditions.push(condition);
}
searchConditions.value = conditions;
pagination.current = 1;
return fetchData();
}
function resetSearch() {
if (loading.value) return;
searchConditions.value = [];
pagination.current = 1;
return fetchData();
}
function changePage(page, pageSize) {
if (loading.value) return;
pagination.current = pageSize === pagination.pageSize ? page : 1;
pagination.pageSize = pageSize;
return fetchData();
}
function reload() {
return moduleList.reload();
if (loading.value) return;
return fetchData();
}
async function resetColumns() {
await nextTick();
tableRef.value?.resetColumns?.();
if (tableRef.value) tableRef.value.resetColumns();
}
function resolveRowKey(row) {
if (keyField.value) return row[keyField.value];
return JSON.stringify(row);
}
async function handleMoreAction({ key }) {
@@ -46,57 +336,57 @@ async function handleMoreAction({ key }) {
}
if (key === "reset-columns") {
await resetColumns();
message.success("已恢复配置列宽");
message.success(appStore.t("common.columnsReset", "已恢复配置列宽"));
}
}
// ── 生命周期 ──
watch(() => [getDataCode(), route.path], initialize, { immediate: true });
watch(
() => moduleList.dataSource.value,
() => dataSource.value,
(rows) => {
emit("loaded", {
rows,
total: moduleList.pagination.total,
module: moduleList.dataModule.value,
});
emit("loaded", { rows, total: pagination.total, module: dataModule.value });
},
);
defineExpose({
reload,
resetColumns,
initialize: moduleList.initialize,
dataSource: moduleList.dataSource,
dataModule: moduleList.dataModule,
pagination: moduleList.pagination,
initialize,
dataSource,
dataModule,
pagination,
});
</script>
<template>
<div class="fms-module-list-page">
<div v-if="moduleList.configError.value" class="module-config-error">
<a-result status="error" title="模块配置加载失败" :sub-title="moduleList.configError.value">
<div v-if="configError" class="module-config-error">
<a-result
status="error"
:title="appStore.t('common.moduleConfigLoadFailed', '模块配置加载失败')"
:sub-title="configError"
>
<template #extra>
<a-button type="primary" @click="moduleList.initialize">重新加载</a-button>
<a-button type="primary" @click="initialize">
{{ appStore.t("common.reload", "重新加载") }}
</a-button>
</template>
</a-result>
</div>
<template v-else>
<FmsQueryToolbar
:query-items="moduleList.queryItems.value"
:loading="moduleList.dataLoading.value"
:query-items="queryItems"
:loading="loading"
:more-items="moreMenuItems"
@search="moduleList.search"
@reset="moduleList.resetSearch"
@search="search"
@reset="resetSearch"
@more-action="handleMoreAction"
>
<template #actions>
<slot
name="actions"
:reload="reload"
:data-module="moduleList.dataModule.value"
:loading="moduleList.loading.value"
/>
<slot name="actions" :reload="reload" :data-module="dataModule" :loading="loading" />
</template>
</FmsQueryToolbar>
@@ -104,12 +394,13 @@ defineExpose({
<div class="module-table-stage">
<FmsModuleTable
ref="tableRef"
:fields="moduleList.activeFields.value"
:list-config="moduleList.listConfig.value"
:data-source="moduleList.dataSource.value"
:row-key="moduleList.resolveRowKey"
:fields="activeFields"
:list-config="listConfig"
:data-source="dataSource"
:row-key="resolveRowKey"
:column-extensions="columnExtensions"
:row-actions="rowActions"
:row-selection="rowSelection"
:row-class-name="rowClassName"
:expandable="expandable"
:inline-action-limit="inlineActionLimit"
@@ -129,22 +420,26 @@ defineExpose({
</template>
</FmsModuleTable>
<div v-if="moduleList.loading.value" class="module-table-loading">
<div v-if="loading" class="module-table-loading">
<a-spin />
</div>
</div>
<div v-if="moduleList.dataError.value" class="module-data-error">
{{ moduleList.dataError.value }}
<div v-if="dataError" class="module-data-error">
{{ dataError }}
</div>
<a-pagination
:current="moduleList.pagination.current"
:page-size="moduleList.pagination.pageSize"
:total="moduleList.pagination.total"
:show-total="(total) => `共 ${total} 条`"
:current="pagination.current"
:page-size="pagination.pageSize"
:total="pagination.total"
:show-total="
(total) =>
`${appStore.t('common.total', '共')} ${total} ${appStore.t('common.items', '条')}`
"
:disabled="loading"
show-size-changer
@change="moduleList.changePage"
@change="changePage"
/>
</section>
</template>
@@ -1,6 +1,8 @@
<script setup>
import { computed, h, ref, shallowRef, useSlots, watch } from "vue";
import { Checkbox } from "antdv-next";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { useAppStore } from "@/stores/app";
import { formatDateValue, parseOptions } from "./moduleListUtils";
const props = defineProps({
@@ -10,12 +12,14 @@ const props = defineProps({
rowKey: { type: [String, Function], default: "b_id" },
columnExtensions: { type: Object, default: () => ({}) },
rowActions: { type: Array, default: () => [] },
rowSelection: { type: Object, default: null },
rowClassName: { type: Function, default: undefined },
expandable: { type: [Boolean, Object], default: false },
inlineActionLimit: { type: Number, default: 2 },
});
const emit = defineEmits(["row-click", "cell-click", "row-action", "expand"]);
const appStore = useAppStore();
const slots = useSlots();
const tableRef = ref(null);
const columns = shallowRef([]);
@@ -36,10 +40,8 @@ function formatCellValue(value, field, optionInfo) {
if (value === null || value === undefined || value === "") return "-";
if (field.b_type === "date") return formatDateValue(value, false);
if (field.b_type === "datetime") return formatDateValue(value, true);
if (field.b_type === "switch") {
return value === true || Number(value) === 1
? (optionInfo.optionConfig.trueLabel ?? "是")
: (optionInfo.optionConfig.falseLabel ?? "否");
if (field.b_type === "checkbox") {
return h(Checkbox, { checked: value === true || Number(value) === 1, disabled: true });
}
if (field.b_type === "select") {
const option = optionInfo.options.find((item) => String(item.value) === String(value));
@@ -69,10 +71,12 @@ function createRuntimeColumn(config, field) {
const { formatter, clickable, cellClassName, onClick: _onClick, ...columnProps } = extension;
const slot = slots[`cell-${field.b_field}`];
const column = {
title: field.b_name || field.b_field,
title: appStore.t(field.b_i18n, field.b_name || field.b_field),
dataIndex: field.b_field,
key: field.b_field,
width: Number(config.b_width) || 120,
checkbox: field.b_type === "checkbox",
align: field.b_type === "checkbox" ? "center" : undefined,
...columnProps,
};
@@ -141,12 +145,16 @@ function createActionColumn() {
const inlineActions = actions.slice(0, inlineLimit);
const overflowActions = actions.slice(inlineLimit);
if (overflowActions.length) {
inlineActions.push({ type: "more", title: "更多操作", items: overflowActions });
inlineActions.push({
type: "more",
title: appStore.t("common.moreActions", "更多操作"),
items: overflowActions,
});
}
return {
dataIndex: "__actions__",
key: "__actions__",
title: "操作",
title: appStore.t("common.operation", "操作"),
width: Math.max(70, inlineActions.length * 52),
align: "center",
fixed: "right",
@@ -215,11 +223,11 @@ function handleToggleExpand(payload) {
if (payload.expanded && single && expandedRecord && expandedRecord !== payload.row) {
tableRef.value?.setRowExpand?.(expandedRecord, false, { silent: true });
}
expandedRecord = payload.expanded
? payload.row
: expandedRecord === payload.row
? null
: expandedRecord;
if (payload.expanded) {
expandedRecord = payload.row;
} else if (expandedRecord === payload.row) {
expandedRecord = null;
}
emit("expand", { expanded: payload.expanded, record: payload.row, column: payload.col });
}
@@ -229,6 +237,7 @@ watch(
() => props.listConfig,
() => props.columnExtensions,
() => props.rowActions,
() => appStore.locale,
expansionEnabled,
],
rebuildColumns,
@@ -251,6 +260,7 @@ defineExpose({ resetColumns, clearSelectedArea: () => tableRef.value?.clearSelec
v-model:columns="columns"
:data-source="dataSource"
:row-key="rowKey"
:row-selection="rowSelection"
:row-class-name="rowClassName"
:expand-config="expansionEnabled ? expandConfig : undefined"
:editable="false"
@@ -1,5 +1,6 @@
<script setup>
import { computed } from "vue";
import { useAppStore } from "@/stores/app";
import { isDateQuery, isEmptyValue, isNumericField, isRangeQuery } from "./moduleListUtils";
const props = defineProps({
@@ -9,10 +10,17 @@ const props = defineProps({
});
const emit = defineEmits(["update:modelValue", "enter"]);
const appStore = useAppStore();
const isRange = computed(() => isRangeQuery(props.item));
const isNumeric = computed(() => isNumericField(props.item));
const isDate = computed(() => isDateQuery(props.item));
const isCheckbox = computed(() => props.item.field.b_type === "checkbox");
const checkboxOptions = computed(() => [
{ label: appStore.t("common.all", "全部"), value: "" },
{ label: appStore.t("common.yes", "是"), value: "1" },
{ label: appStore.t("common.no", "否"), value: "0" },
]);
const value = computed({
get: () => props.modelValue,
set: (nextValue) => emit("update:modelValue", nextValue),
@@ -92,6 +100,13 @@ function isDateBeforeLimit(current) {
</template>
</div>
<a-radio-group
v-else-if="isCheckbox"
v-model:value="value"
class="fms-query-control__boolean"
:options="checkboxOptions"
option-type="button"
/>
<a-select
v-else-if="item.config.b_component === 'select'"
v-model:value="value"
@@ -131,6 +146,18 @@ function isDateBeforeLimit(current) {
text-align: center;
}
.fms-query-control__boolean {
display: flex;
width: 100%;
:deep(.ant-radio-button-wrapper) {
min-width: 42px;
flex: 1;
padding-inline: 8px;
text-align: center;
}
}
.fms-query-control__range--compact {
grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr);
gap: 0;
@@ -1,6 +1,7 @@
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { ChevronDown, ChevronUp, Ellipsis, RotateCcw, Search } from "@lucide/vue";
import { useAppStore } from "@/stores/app";
import FmsQueryControl from "./FmsQueryControl.vue";
import {
defaultQueryValue,
@@ -18,6 +19,7 @@ const props = defineProps({
});
const emit = defineEmits(["search", "reset", "more-action"]);
const appStore = useAppStore();
const filters = reactive({});
const quickFieldName = ref("");
@@ -29,7 +31,7 @@ const quickFieldOptions = computed(() =>
props.queryItems.map((item) => ({
key: item.field.b_field,
value: item.field.b_field,
label: item.field.b_name || item.field.b_field,
label: appStore.t(item.field.b_i18n, item.field.b_name || item.field.b_field),
})),
);
const quickItem = computed(() =>
@@ -133,19 +135,30 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true });
type="primary"
class="quick-submit"
:loading="loading"
aria-label="搜索"
:aria-label="appStore.t('common.search', '搜索')"
@click="handleQuickSearch"
>
<Search :size="16" />
</a-button>
</a-space-compact>
<a-tooltip v-if="queryItems.length" :title="advancedOpen ? '收起高级搜索' : '高级搜索'">
<a-tooltip
v-if="queryItems.length"
:title="
advancedOpen
? appStore.t('common.collapseAdvancedSearch', '收起高级搜索')
: appStore.t('common.advancedSearch', '高级搜索')
"
>
<a-button
shape="circle"
class="toolbar-icon-button"
:class="{ 'toolbar-icon-button--active': advancedOpen }"
:aria-label="advancedOpen ? '收起高级搜索' : '高级搜索'"
:aria-label="
advancedOpen
? appStore.t('common.collapseAdvancedSearch', '收起高级搜索')
: appStore.t('common.advancedSearch', '高级搜索')
"
@click="toggleAdvancedSearch"
>
<Search :size="16" />
@@ -154,13 +167,18 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true });
<a-dropdown
v-if="moreItems.length"
:disabled="loading"
:menu="{ items: moreItems, onClick: (event) => $emit('more-action', event) }"
:trigger="['click']"
placement="bottomRight"
>
<span class="more-trigger">
<a-tooltip title="更多">
<a-button shape="circle" class="toolbar-icon-button" aria-label="更多">
<a-tooltip :title="appStore.t('common.more', '更多')">
<a-button
shape="circle"
class="toolbar-icon-button"
:aria-label="appStore.t('common.more', '更多')"
>
<Ellipsis :size="17" />
</a-button>
</a-tooltip>
@@ -176,7 +194,7 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true });
v-for="item in visibleAdvancedQueryItems"
:key="item.field.b_field"
:class="{ 'query-field--wide': isRangeQuery(item) }"
:label="item.field.b_name"
:label="appStore.t(item.field.b_i18n, item.field.b_name)"
>
<FmsQueryControl
v-model="filters[item.field.b_field]"
@@ -195,18 +213,22 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true });
>
<ChevronUp v-if="advancedMoreOpen" :size="15" />
<ChevronDown v-else :size="15" />
{{ advancedMoreOpen ? "收起条件" : `更多条件 (${hiddenAdvancedQueryCount})` }}
{{
advancedMoreOpen
? appStore.t("common.collapseConditions", "收起条件")
: `${appStore.t("common.moreConditions", "更多条件")} (${hiddenAdvancedQueryCount})`
}}
</a-button>
</div>
<div class="query-actions">
<a-button type="primary" :loading="loading" @click="handleAdvancedSearch">
<Search :size="15" />
查询
{{ appStore.t("common.search", "查询") }}
</a-button>
<a-button :disabled="loading" @click="handleAdvancedReset">
<RotateCcw :size="15" />
重置
{{ appStore.t("common.reset", "重置") }}
</a-button>
</div>
</a-form>
@@ -295,9 +317,9 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true });
.query-panel {
display: grid;
width: min(1100px, calc(100% - 24px));
width: min(950px, calc(100% - 24px));
align-self: flex-start;
grid-template-columns: minmax(0, 1fr) 160px;
grid-template-columns: minmax(0, 1fr) 172px;
gap: 14px;
padding: 10px 12px 12px;
border-left: 1px solid var(--fms-border, #e5e7eb);
@@ -350,6 +372,7 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true });
.query-actions {
display: flex;
width: 160px;
margin-left: 12px;
align-self: center;
justify-content: space-between;
gap: 8px;
@@ -1,6 +1,6 @@
// 数据驱动列表的共享工具函数,避免在各组件间重复实现查询/格式化逻辑。
export const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
export const identifierRe = /^[A-Za-z_][A-Za-z0-9_]*$/;
export function escapeSqlText(value) {
return String(value).replaceAll("'", "''");
@@ -20,14 +20,18 @@ export function parseOptions(field) {
if (!field?.b_options) return { optionConfig: {}, options: [] };
try {
const parsed = JSON.parse(field.b_options);
const optionConfig = Array.isArray(parsed) ? {} : parsed || {};
const source = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.options)
? parsed.options
: Array.isArray(parsed?.items)
? parsed.items
: [];
let optionConfig = {};
let source = [];
if (Array.isArray(parsed)) {
source = parsed;
} else if (parsed && typeof parsed === "object") {
optionConfig = parsed;
if (Array.isArray(parsed.options)) {
source = parsed.options;
} else if (Array.isArray(parsed.items)) {
source = parsed.items;
}
}
const options = source.map((option) => {
if (option && typeof option === "object") {
const value = option.value ?? option.key ?? option.id;
@@ -62,6 +66,7 @@ export function isDateQuery(item) {
}
export function emptyQueryValue(item) {
if (item.field.b_type === "checkbox") return "";
if (isRangeQuery(item) || item.config.b_operator === "in") return [];
return undefined;
}
@@ -88,6 +93,72 @@ export function defaultQueryValue(item) {
return value;
}
export function sqlLiteral(value, field, endOfRange = false) {
if (isEmptyValue(value)) return null;
if (field.b_type === "number" || field.b_type === "money") {
const number = Number(value);
return Number.isFinite(number) ? String(number) : null;
}
if (field.b_type === "checkbox") {
return value === true || Number(value) === 1 ? "1" : "0";
}
let normalized = String(value);
const isDate = field.b_type === "date" || field.b_type === "datetime";
if (isDate && value && typeof value.format === "function") {
normalized = value.format("YYYY-MM-DD");
} else if (isDate) {
normalized = String(value).slice(0, 10);
}
if (field.b_type === "datetime") {
normalized += endOfRange ? " 23:59:59" : " 00:00:00";
}
return `N'${escapeSqlText(normalized)}'`;
}
export function buildQueryCondition(item, value) {
const fieldName = item.field.b_field;
if (!identifierRe.test(fieldName)) return "";
const field = `[${fieldName}]`;
if (isRangeQuery(item)) {
const values = Array.isArray(value) ? value : [];
const start = sqlLiteral(values[0], item.field);
const end = sqlLiteral(values[1], item.field, true);
if (start && end) return `${field} BETWEEN ${start} AND ${end}`;
if (start) return `${field} >= ${start}`;
if (end) return `${field} <= ${end}`;
return "";
}
if (item.config.b_operator === "in") {
let values = value;
if (!Array.isArray(values)) {
values = String(value ?? "")
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}
const literals = [];
for (const entry of values) {
const literal = sqlLiteral(entry, item.field);
if (literal) literals.push(literal);
}
return literals.length ? `${field} IN (${literals.join(", ")})` : "";
}
const literal = sqlLiteral(value, item.field);
if (!literal) return "";
if (item.config.b_operator === "like") {
return `${field} LIKE N'%${escapeSqlText(String(value))}%'`;
}
return `${field} = ${literal}`;
}
export function formatDateValue(value, withTime = false) {
if (isEmptyValue(value)) return "";
if (value && typeof value.format === "function") {
@@ -1,390 +0,0 @@
import { computed, onBeforeUnmount, reactive, ref, shallowRef, unref, watch } from "vue";
import { useRoute } from "vue-router";
import { loadDataApi, pageDataApi } from "@/services/api";
import {
IDENTIFIER_RE,
defaultQueryValue,
escapeSqlText,
formatDateValue,
isEmptyValue,
isRangeQuery,
parseOptions,
requireModuleId,
} from "./moduleListUtils";
function formatSqlDate(value, fieldType, endOfRange = false) {
if (value && typeof value.format === "function") {
if (fieldType === "datetime") {
return `${value.format("YYYY-MM-DD")} ${endOfRange ? "23:59:59" : "00:00:00"}`;
}
return value.format("YYYY-MM-DD");
}
const date = String(value).slice(0, 10);
if (fieldType === "datetime" && /^\d{4}-\d{2}-\d{2}$/.test(date)) {
return `${date} ${endOfRange ? "23:59:59" : "00:00:00"}`;
}
return date;
}
function sqlLiteral(value, field, endOfRange = false) {
if (isEmptyValue(value)) return null;
if (field.b_type === "number" || field.b_type === "money") {
const number = Number(value);
return Number.isFinite(number) ? String(number) : null;
}
if (field.b_type === "switch") return value === true || Number(value) === 1 ? "1" : "0";
const normalized =
field.b_type === "date" || field.b_type === "datetime"
? formatSqlDate(value, field.b_type, endOfRange)
: String(value);
return `N'${escapeSqlText(normalized)}'`;
}
function buildQueryCondition(item, value) {
const fieldName = item.field.b_field;
if (!IDENTIFIER_RE.test(fieldName)) return "";
const identifier = `[${fieldName}]`;
const operator = item.config.b_operator;
if (isRangeQuery(item)) {
const [start, end] = Array.isArray(value) ? value : [];
const startLiteral = sqlLiteral(start, item.field);
const endLiteral = sqlLiteral(end, item.field, true);
if (startLiteral && endLiteral) {
return `${identifier} BETWEEN ${startLiteral} AND ${endLiteral}`;
}
if (startLiteral) return `${identifier} >= ${startLiteral}`;
if (endLiteral) return `${identifier} <= ${endLiteral}`;
return "";
}
if (operator === "in") {
const values = Array.isArray(value)
? value
: String(value ?? "")
.split(",")
.map((part) => part.trim())
.filter(Boolean);
const literals = values.map((entry) => sqlLiteral(entry, item.field)).filter(Boolean);
return literals.length ? `${identifier} IN (${literals.join(", ")})` : "";
}
const literal = sqlLiteral(value, item.field);
if (!literal) return "";
if (operator === "like") return `${identifier} LIKE N'%${escapeSqlText(String(value))}%'`;
return `${identifier} = ${literal}`;
}
function displayQueryValue(item, value) {
if (isEmptyValue(value)) return "";
if (item.field.b_type === "date" || item.field.b_type === "datetime") {
return formatDateValue(value);
}
const option = item.options.find((entry) => String(entry.value) === String(value));
return option?.label ?? String(value);
}
function formatAppliedFilterValue(item, value) {
if (isRangeQuery(item)) {
const [start, end] = Array.isArray(value) ? value : [];
const startText = displayQueryValue(item, start);
const endText = displayQueryValue(item, end);
if (startText && endText) return `${startText} - ${endText}`;
if (startText) return `${startText} 起`;
if (endText) return `${endText} 止`;
return "";
}
if (Array.isArray(value)) {
return value
.map((entry) => displayQueryValue(item, entry))
.filter(Boolean)
.join("、");
}
return displayQueryValue(item, value);
}
function createAppliedFilter(item, value) {
const condition = buildQueryCondition(item, value);
if (!condition) return null;
return {
key: item.field.b_field,
label: item.field.b_name || item.field.b_field,
value: formatAppliedFilterValue(item, value),
condition,
};
}
function resolveKeyField(module, activeFields) {
if (IDENTIFIER_RE.test(module?.b_keyfield || "")) return module.b_keyfield;
return (
activeFields.find((field) => field.b_field === "b_id")?.b_field ||
activeFields.find((field) => IDENTIFIER_RE.test(field.b_field || ""))?.b_field ||
""
);
}
export function useModuleList(options = {}) {
const route = useRoute();
const pageModule = ref(null);
const dataModule = ref(null);
const activeFields = shallowRef([]);
const listConfig = shallowRef([]);
const queryItems = shallowRef([]);
const dataSource = shallowRef([]);
const appliedFilters = shallowRef([]);
const configLoading = ref(false);
const dataLoading = ref(false);
const configError = ref("");
const dataError = ref("");
const pagination = reactive({
current: 1,
pageSize: Number(unref(options.pageSize)) || 20,
total: 0,
});
let requestVersion = 0;
let dataRequestVersion = 0;
let disposed = false;
const loading = computed(() => configLoading.value || dataLoading.value);
const appliedSearchCondition = computed(() =>
appliedFilters.value.map((item) => item.condition).join(" AND "),
);
function commitAppliedFilters(filters) {
appliedFilters.value = filters.filter(Boolean);
}
async function resolvePageModule(runtimeData) {
const pageId = requireModuleId(runtimeData.b_parent_id);
const response = await loadDataApi(
"s_module",
`b_id = ${pageId} AND b_module_type = 'page' AND b_canuse = 1`,
"b_id ASC",
);
const module = response.data?.[0];
if (!module) throw new Error(`数据编码 ${runtimeData.b_code} 没有对应的启用页面`);
return module;
}
async function resolveConfiguredModules() {
const dataCode = String(unref(options.dataCode) || route.meta.dataCode || "").trim();
if (dataCode) {
const response = await loadDataApi(
"s_module",
`b_code = N'${escapeSqlText(dataCode)}' AND b_module_type = 'data' AND b_canuse = 1`,
"b_id ASC",
);
const runtimeData = response.data?.[0];
if (!runtimeData) throw new Error(`未找到编码为 ${dataCode} 的启用数据模块`);
return { runtimePage: await resolvePageModule(runtimeData), runtimeData };
}
const routePath = escapeSqlText(route.path);
const response = await loadDataApi(
"s_module",
`b_route = N'${routePath}' AND b_canuse = 1`,
"b_id ASC",
);
const runtimePage = response.data?.[0];
if (!runtimePage) throw new Error(`未找到路由 ${route.path} 对应的启用模块`);
return { runtimePage, runtimeData: await resolveDataModule(runtimePage) };
}
async function resolveDataModule(runtimePage) {
if (runtimePage.b_viewtable) return runtimePage;
const pageId = requireModuleId(runtimePage.b_id);
const response = await loadDataApi(
"s_module",
`b_parent_id = ${pageId} AND b_module_type = 'data' AND b_canuse = 1`,
"b_xh ASC, b_id ASC",
);
const module = response.data?.find((item) => item.b_viewtable);
if (!module) throw new Error("当前页面没有配置可用的数据模块");
return module;
}
async function loadModuleConfig(version) {
configLoading.value = true;
configError.value = "";
try {
const { runtimePage, runtimeData } = await resolveConfiguredModules();
const dataId = requireModuleId(runtimeData.b_id);
const [fieldResponse, listResponse, queryResponse] = await Promise.all([
loadDataApi("s_module_field", `b_module_id = ${dataId}`, "b_id ASC"),
loadDataApi("s_module_field_list", `b_module_id = ${dataId}`, "b_xh ASC, b_id ASC"),
loadDataApi(
"s_module_field_query",
`b_module_id = ${dataId} AND b_canuse = 1`,
"b_xh ASC, b_id ASC",
),
]);
if (disposed || version !== requestVersion) return false;
const fields = (fieldResponse.data || []).filter(
(field) => field.b_canuse === 1 && IDENTIFIER_RE.test(field.b_field || ""),
);
const fieldById = new Map(fields.map((field) => [String(field.b_id), field]));
const lists = (listResponse.data || []).filter(
(item) => item.b_visible === 1 && fieldById.has(String(item.b_field_id)),
);
const queries = (queryResponse.data || [])
.map((config) => {
const field = fieldById.get(String(config.b_field_id));
if (!field) return null;
return { config, field, ...parseOptions(field) };
})
.filter(Boolean);
runtimeData.resolvedKeyField = resolveKeyField(runtimeData, fields);
pageModule.value = runtimePage;
dataModule.value = runtimeData;
activeFields.value = fields;
listConfig.value = lists;
queryItems.value = queries;
commitAppliedFilters(
queries.map((item) => createAppliedFilter(item, defaultQueryValue(item))),
);
return true;
} catch (error) {
if (disposed || version !== requestVersion) return false;
configError.value = error?.message || String(error);
throw error;
} finally {
if (version === requestVersion) configLoading.value = false;
}
}
function resolveOrderBy() {
const configured = String(dataModule.value?.b_orderfield || "").trim();
if (configured) return configured;
const keyField = dataModule.value?.resolvedKeyField;
if (keyField) return `${keyField} DESC`;
const fieldById = new Map(activeFields.value.map((field) => [String(field.b_id), field]));
const firstField = fieldById.get(String(listConfig.value[0]?.b_field_id))?.b_field;
if (IDENTIFIER_RE.test(firstField || "")) return `${firstField} ASC`;
throw new Error("数据模块未配置可用的默认排序字段");
}
async function fetchData(version = requestVersion) {
if (!dataModule.value?.b_viewtable || version !== requestVersion) return;
const dataVersion = ++dataRequestVersion;
dataLoading.value = true;
dataError.value = "";
try {
const fieldById = new Map(activeFields.value.map((field) => [String(field.b_id), field]));
const searchColumns = [
...new Set(
[
dataModule.value.resolvedKeyField,
...listConfig.value.map((item) => fieldById.get(String(item.b_field_id))?.b_field),
].filter(Boolean),
),
];
const response = await pageDataApi({
viewName: dataModule.value.b_viewtable,
orderBy: resolveOrderBy(),
searchColumns,
searchCondition: appliedSearchCondition.value,
pageNo: pagination.current,
pageSize: pagination.pageSize,
});
if (disposed || version !== requestVersion || dataVersion !== dataRequestVersion) return;
dataSource.value = response.data?.rows || [];
pagination.total = Number(response.data?.total ?? response.data?.row_count) || 0;
} catch (error) {
if (disposed || version !== requestVersion || dataVersion !== dataRequestVersion) return;
dataSource.value = [];
pagination.total = 0;
dataError.value = error?.message || String(error);
} finally {
if (version === requestVersion && dataVersion === dataRequestVersion) {
dataLoading.value = false;
}
}
}
async function initialize() {
const version = ++requestVersion;
dataRequestVersion += 1;
dataLoading.value = false;
pagination.current = 1;
dataSource.value = [];
pagination.total = 0;
try {
const loaded = await loadModuleConfig(version);
if (loaded) await fetchData(version);
} catch {
dataSource.value = [];
}
}
function search({ entries = [] }) {
commitAppliedFilters(entries.map(({ item, value }) => createAppliedFilter(item, value)));
pagination.current = 1;
return fetchData();
}
function resetSearch() {
commitAppliedFilters([]);
pagination.current = 1;
return fetchData();
}
function removeFilter(fieldName) {
commitAppliedFilters(appliedFilters.value.filter((filter) => filter.key !== fieldName));
pagination.current = 1;
return fetchData();
}
function changePage(page, pageSize) {
pagination.current = pageSize === pagination.pageSize ? page : 1;
pagination.pageSize = pageSize;
return fetchData();
}
function reload() {
return fetchData();
}
function resolveRowKey(row) {
const keyField = dataModule.value?.resolvedKeyField;
return keyField ? row?.[keyField] : JSON.stringify(row);
}
watch(
() => [String(unref(options.dataCode) || route.meta.dataCode || ""), route.path],
initialize,
{
immediate: true,
},
);
onBeforeUnmount(() => {
disposed = true;
requestVersion += 1;
dataRequestVersion += 1;
});
return {
pageModule,
dataModule,
activeFields,
listConfig,
queryItems,
dataSource,
appliedFilters,
pagination,
configLoading,
dataLoading,
loading,
configError,
dataError,
initialize,
search,
resetSearch,
removeFilter,
changePage,
reload,
resolveRowKey,
};
}
@@ -146,6 +146,16 @@ const dragRowColumn = {
resizable: false,
};
function getHeaderAlign(column) {
if (column.headerAlign) return column.headerAlign;
if (isSelectionColumn(column)) return "center";
if (column.dataIndex === "actions") return "center";
if (column.actions && column.actions.length) return "center";
if (column.checkbox === true) return "center";
if (column.editor && column.editor.type === "checkbox") return "center";
return "left";
}
const displayColumns = computed(() => {
const cols = props.columns.map((col) => resolveCustomCell(col, editingCtx));
if (props.rowDraggable) cols.unshift(dragRowColumn);
@@ -160,7 +170,10 @@ const displayColumns = computed(() => {
resizable: false,
});
}
return cols;
return cols.map((column) => ({
...column,
headerAlign: getHeaderAlign(column),
}));
});
// 剥离内部列(选择列、序号列、拖拽列)后回传,避免污染父组件 columns
@@ -12,22 +12,52 @@ const permissionStore = usePermissionStore();
const menuIcon = (icon) => () => h(icon, { size: 16, strokeWidth: 1.8 });
const openKeys = ref(["system"]);
const effectiveCollapsed = computed(() => appStore.sidebarCollapsed);
function moduleLabel(moduleId, path, fallback) {
const module = permissionStore.modules.find(
(item) => item.b_code === moduleId || item.b_route === path,
);
return appStore.t(module?.b_i18n, fallback);
}
function directoryLabel(fallback) {
const module = permissionStore.modules.find(
(item) => item.b_module_type === "directory" && item.b_name === fallback,
);
return appStore.t(module?.b_i18n, fallback);
}
function canAccessMenu(item) {
if (item.moduleId) return permissionStore.canAccess(item.moduleId);
return true;
}
const menuItems = computed(() => {
const systemPages = [
{ key: "/system/module-management", label: "模块管理", moduleId: "m_metadata" },
{ key: "/system/i18n-type", label: "多语言类型", moduleId: "b_i18n_type" },
{ key: "/system/login-log", label: "登录日志", moduleId: "d_login_log" },
{
key: "/system/module-management",
label: moduleLabel("m_metadata", "/system/module-management", "模块管理"),
moduleId: "m_metadata",
},
{
key: "/system/i18n-type",
label: moduleLabel("b_i18n_type", "/system/i18n-type", "多语言类型"),
moduleId: "b_i18n_type",
},
{
key: "/system/login-log",
label: moduleLabel("d_login_log", "/system/login-log", "登录日志"),
moduleId: "d_login_log",
},
].filter(canAccessMenu);
const items = [{ key: "/dashboard", icon: menuIcon(House), label: "工作台" }];
const items = [
{
key: "/dashboard",
icon: menuIcon(House),
label: appStore.t("common.dashboard", "工作台"),
},
];
if (systemPages.length)
items.push({
key: "system",
icon: menuIcon(Settings),
label: "系统管理",
label: directoryLabel("系统管理"),
children: systemPages,
});
return items;
@@ -59,7 +89,7 @@ onMounted(() => permissionStore.load().catch(() => {}));
>
<div class="brand" :class="{ 'brand--collapsed': effectiveCollapsed }">
<div class="brand__mark">F</div>
<strong>FMS 管理系统</strong>
<strong>{{ appStore.t("common.appName", "FMS 管理系统") }}</strong>
</div>
<div class="sider-menu">
<a-menu
@@ -72,7 +102,13 @@ onMounted(() => permissionStore.load().catch(() => {}));
/>
</div>
<div class="sider-footer">
<a-tooltip :title="effectiveCollapsed ? '展开侧栏' : '收起侧栏'" placement="right"
<a-tooltip
:title="
effectiveCollapsed
? appStore.t('common.expandSidebar', '展开侧栏')
: appStore.t('common.collapseSidebar', '收起侧栏')
"
placement="right"
><a-button type="text" class="sider-collapse" @click="appStore.toggleSidebar"
><PanelLeftOpen v-if="effectiveCollapsed" /><PanelLeftClose v-else /></a-button
></a-tooltip>
@@ -13,10 +13,12 @@ import {
} from "@lucide/vue";
import { theme } from "antdv-next";
import { useAppStore } from "@/stores/app";
import { usePermissionStore } from "@/stores/permissions";
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const permissionStore = usePermissionStore();
const { token } = theme.useToken();
const headerRef = ref(null);
const trackRef = ref(null);
@@ -34,6 +36,7 @@ watch(
appStore.addVisitedTab({
key: route.fullPath,
label: getTabLabel(),
i18nKey: getTabI18nKey(),
closable: route.fullPath !== "/dashboard",
});
}
@@ -74,6 +77,18 @@ function getTabLabel() {
return route.meta.title;
}
function getTabI18nKey() {
if (route.path === "/dashboard") return "common.dashboard";
const module = permissionStore.modules.find(
(item) => item.b_code === route.meta.moduleId || item.b_route === route.path,
);
return module?.b_i18n || "";
}
function getDisplayTabLabel(item) {
return appStore.t(item.i18nKey, item.label);
}
function closeTab(targetKey) {
if (typeof targetKey !== "string" || targetKey === "/dashboard") return;
@@ -226,7 +241,7 @@ function toggleMaximize() {
@click="changeTab(item.key)"
@keydown="handleTabKeydown($event, index)"
>
<span>{{ item.label }}</span>
<span>{{ getDisplayTabLabel(item) }}</span>
</button>
<button
v-if="item.closable"
@@ -1,14 +1,17 @@
<script setup>
import { computed, h, ref, watch } from "vue";
import { computed, h, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { message } from "antdv-next";
import { Bell, ChevronDown, Languages, LogOut, Settings, UserRound } from "@lucide/vue";
import { useAppStore } from "@/stores/app";
import { useAuthStore } from "@/stores/auth";
import { usePermissionStore } from "@/stores/permissions";
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const authStore = useAuthStore();
const permissionStore = usePermissionStore();
const profileOpen = ref(false);
const logoutOpen = ref(false);
const menuIcon = (icon) => () => h(icon, { size: 16, strokeWidth: 1.8 });
@@ -16,35 +19,74 @@ const menuIcon = (icon) => () => h(icon, { size: 16, strokeWidth: 1.8 });
const breadcrumbItems = computed(() => {
const items = [];
if (route.path === "/dashboard") return [{ label: "工作台" }];
if (route.meta.section) items.push({ label: route.meta.section });
if (route.meta.title) items.push({ label: route.meta.title });
if (route.path === "/dashboard") {
return [{ label: appStore.t("common.dashboard", "工作台") }];
}
if (route.meta.section) {
const directory = permissionStore.modules.find(
(item) => item.b_module_type === "directory" && item.b_name === route.meta.section,
);
items.push({ label: appStore.t(directory?.b_i18n, route.meta.section) });
}
if (route.meta.title) {
const module = permissionStore.modules.find(
(item) => item.b_code === route.meta.moduleId || item.b_route === route.path,
);
items.push({ label: appStore.t(module?.b_i18n, route.meta.title) });
}
return items;
});
const languageMenu = computed(() => ({
items: [
{ key: "zh-CN", label: "简体中文" },
{ key: "en", label: "English" },
],
onClick: ({ key }) => appStore.setLocale(key),
items: appStore.languages.map((item) => ({
key: String(item.b_code),
label: item.b_name || item.b_code,
})),
onClick: async ({ key }) => {
try {
await appStore.setLocale(key);
} catch (error) {
message.error("切换语言失败:" + (error?.message || error));
}
},
selectable: true,
selectedKeys: [appStore.locale],
}));
const userMenu = {
const userMenu = computed(() => ({
items: [
{ key: "profile", icon: menuIcon(UserRound), label: "个人资料" },
{ key: "settings", icon: menuIcon(Settings), label: "账户设置" },
{
key: "profile",
icon: menuIcon(UserRound),
label: appStore.t("common.profile", "个人资料"),
},
{
key: "settings",
icon: menuIcon(Settings),
label: appStore.t("common.accountSettings", "账户设置"),
},
{ type: "divider" },
{ key: "logout", icon: menuIcon(LogOut), label: "退出登录", danger: true },
{
key: "logout",
icon: menuIcon(LogOut),
label: appStore.t("common.logout", "退出登录"),
danger: true,
},
],
onClick: handleUserMenu,
};
}));
const displayName = computed(() => authStore.user?.name || authStore.user?.id || "当前用户");
onMounted(async () => {
try {
await appStore.loadLanguages();
} catch (error) {
message.error("加载语言配置失败:" + (error?.message || error));
}
});
watch(
() => appStore.locale,
(locale) => document.documentElement.setAttribute("lang", locale),
@@ -72,15 +114,30 @@ function confirmLogout() {
</a-breadcrumb>
<div class="toolbar-actions">
<a-tooltip title="切换语言" placement="bottom">
<a-dropdown :menu="languageMenu" :trigger="['click']" placement="bottomRight">
<a-button type="text" class="toolbar-action" aria-label="切换语言">
<a-tooltip :title="appStore.t('common.switchLanguage', '切换语言')" placement="bottom">
<a-dropdown
:menu="languageMenu"
:disabled="
appStore.languagesLoading || appStore.translationLoading || !appStore.languages.length
"
:trigger="['click']"
placement="bottomRight"
>
<a-button
type="text"
class="toolbar-action"
:aria-label="appStore.t('common.switchLanguage', '切换语言')"
>
<template #icon><Languages /></template>
</a-button>
</a-dropdown>
</a-tooltip>
<a-tooltip title="通知提醒" placement="bottom">
<a-button type="text" class="toolbar-action" aria-label="通知提醒">
<a-tooltip :title="appStore.t('common.notifications', '通知提醒')" placement="bottom">
<a-button
type="text"
class="toolbar-action"
:aria-label="appStore.t('common.notifications', '通知提醒')"
>
<a-badge dot><Bell /></a-badge>
</a-button>
</a-tooltip>
+81 -4
View File
@@ -1,12 +1,19 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { loadDataApi } from "@/services/api";
const createDefaultVisitedTabs = () => [{ key: "/dashboard", label: "工作台", closable: false }];
const createDefaultVisitedTabs = () => [
{ key: "/dashboard", label: "工作台", i18nKey: "common.dashboard", closable: false },
];
export const useAppStore = defineStore(
"app",
() => {
const locale = ref("zh-CN");
const locale = ref("");
const languages = ref([]);
const languagesLoading = ref(false);
const translations = ref({});
const translationLoading = ref(false);
const sidebarCollapsed = ref(false);
const visitedTabs = ref(createDefaultVisitedTabs());
const contentMaximized = ref(false);
@@ -29,8 +36,71 @@ export const useAppStore = defineStore(
return routeRefreshKeys.value[String(routeKey || "")] || 0;
}
function setLocale(value) {
locale.value = value;
async function loadLanguages() {
if (languagesLoading.value) return;
languagesLoading.value = true;
try {
const response = await loadDataApi(
"b_i18n_type",
"b_canuse = 1",
"b_default DESC, b_xh ASC, b_id ASC",
[],
);
languages.value = response.data || [];
let selectedLanguage = languages.value.find(
(item) => String(item.b_code) === String(locale.value),
);
if (!selectedLanguage) {
const defaultLanguage = languages.value.find((item) => Number(item.b_default) === 1);
selectedLanguage = defaultLanguage || languages.value[0];
}
const code = String(selectedLanguage?.b_code || "");
await loadTranslations(code);
locale.value = code;
} finally {
languagesLoading.value = false;
}
}
async function loadTranslations(code) {
if (!code) {
translations.value = {};
return;
}
translationLoading.value = true;
try {
const escapedCode = String(code).replaceAll("'", "''");
const response = await loadDataApi(
"b_i18n",
`b_locale = N'${escapedCode}'`,
"b_key ASC",
[],
);
const values = {};
for (const item of response.data || []) {
if (item.b_key) values[item.b_key] = item.b_value;
}
translations.value = values;
} finally {
translationLoading.value = false;
}
}
async function setLocale(value) {
const code = String(value || "");
const exists = languages.value.some((item) => String(item.b_code) === code);
if (!exists) return;
await loadTranslations(code);
locale.value = code;
}
function t(key, fallback = "") {
const value = translations.value[String(key || "")];
if (value !== undefined && value !== null && value !== "") return value;
return fallback || String(key || "");
}
function addVisitedTab(tab) {
@@ -46,6 +116,10 @@ export const useAppStore = defineStore(
return {
locale,
languages,
languagesLoading,
translations,
translationLoading,
sidebarCollapsed,
visitedTabs,
contentMaximized,
@@ -54,6 +128,9 @@ export const useAppStore = defineStore(
refreshRoute,
getRouteRefreshKey,
setLocale,
loadLanguages,
loadTranslations,
t,
addVisitedTab,
removeVisitedTab,
};
@@ -127,6 +127,7 @@ export const usePermissionStore = defineStore("permissions", () => {
canAccess,
canPower,
clear,
modules,
grantedPageIds,
moduleCodes,
powerCodes,
-10
View File
@@ -1,13 +1,3 @@
export const I18N_LOCALES = [
{ value: "zh-CN", label: "简体中文", shortLabel: "简体" },
{ value: "en-US", label: "English", shortLabel: "English" },
{ value: "zh-TW", label: "繁體中文", shortLabel: "繁體" },
];
export const I18N_LOCALE_CODES = I18N_LOCALES.map((item) => item.value);
// 与数据库 s_module 的 CHECK 约束 CK_s_module_code 保持一致:
// 长度 2-50,以小写字母开头,仅含小写字母/数字/下划线。
export function validateModuleCode(code) {
const value = String(code ?? "").trim();
if (!value) return "编码不能为空";
@@ -44,9 +44,12 @@ const treeData = computed(() =>
}),
);
const selectedGroup = computed(() =>
localGroups.value.find((g) => String(g.b_id) === selectedGroupId.value),
);
const selectedGroup = computed(() => {
for (const group of localGroups.value) {
if (String(group.b_id) === selectedGroupId.value) return group;
}
return null;
});
function onTreeSelect(keys) {
if (keys.length > 0) {
@@ -72,14 +75,25 @@ function addGroup(parentId) {
function deleteGroup() {
if (!selectedGroupId.value) return;
const hasChildren = localGroups.value.some(
(group) => String(group.b_parent_id) === selectedGroupId.value,
);
let hasChildren = false;
for (const group of localGroups.value) {
if (String(group.b_parent_id) === selectedGroupId.value) {
hasChildren = true;
break;
}
}
if (hasChildren) {
message.warning("该分组下还有子分组,请先删除子分组");
return;
}
if (props.referencedGroupIds.map(String).includes(selectedGroupId.value)) {
let isReferenced = false;
for (const groupId of props.referencedGroupIds) {
if (String(groupId) === selectedGroupId.value) {
isReferenced = true;
break;
}
}
if (isReferenced) {
message.warning("该分组仍被列表或编辑配置引用,请先清空引用");
return;
}
@@ -88,7 +102,14 @@ function deleteGroup() {
}
async function saveAllGroups() {
if (localGroups.value.some((group) => !String(group.b_title || "").trim())) {
let hasEmptyTitle = false;
for (const group of localGroups.value) {
if (!String(group.b_title || "").trim()) {
hasEmptyTitle = true;
break;
}
}
if (hasEmptyTitle) {
message.warning("请填写分组标题");
return;
}
@@ -161,7 +182,7 @@ function onClose() {
<a-input-number v-model:value="selectedGroup.b_xh" :min="0" />
</a-form-item>
<a-form-item label="启用">
<a-switch
<a-checkbox
:checked="selectedGroup.b_canuse === 1"
@update:checked="(v) => (selectedGroup.b_canuse = v ? 1 : 0)"
/>
@@ -2,30 +2,18 @@
import { ref } from "vue";
import { message } from "antdv-next";
import { loadDataApi, saveObjectApi } from "@/services/api";
import { useAppStore } from "@/stores/app";
// 快速设置多语言弹窗:按 key 加载 b_i18n 各语言值,保存后 emit("saved", key)。
// 调用方通过 ref 调用 open(key, defaultValue),defaultValue 用于默认语言的初始值。
const emit = defineEmits(["saved"]);
const appStore = useAppStore();
const visible = ref(false);
const saving = ref(false);
const i18nKey = ref("");
const entries = ref([]);
const langTypes = ref([]);
async function loadLangTypes() {
try {
const res = await loadDataApi(
"b_i18n_type",
"b_canuse = 1",
"b_default DESC, b_xh ASC, b_id ASC",
);
langTypes.value = res.data || [];
} catch (e) {
message.error("加载语言类型失败:" + (e.message || e));
}
}
async function open(key, defaultValue = "") {
if (!key) {
@@ -35,22 +23,38 @@ async function open(key, defaultValue = "") {
i18nKey.value = key;
const escaped = key.replace(/'/g, "''");
try {
const [i18nRes] = await Promise.all([
const responses = await Promise.all([
loadDataApi("b_i18n", `b_key = '${escaped}'`, "b_locale ASC"),
loadLangTypes(),
loadDataApi("b_i18n_type", "b_canuse = 1", "b_default DESC, b_xh ASC, b_id ASC"),
]);
const i18nRes = responses[0];
const langRes = responses[1];
const rows = i18nRes.data || [];
const byLocale = Object.fromEntries(rows.map((r) => [r.b_locale, r]));
entries.value = langTypes.value.map((t) => {
const ex = byLocale[t.b_code];
const isDefault = t.b_default === 1 || t.b_code === "zh-CN";
return {
b_id: ex ? ex.b_id : null,
b_locale: t.b_code,
b_name: t.b_name,
b_value: ex ? ex.b_value : isDefault ? defaultValue : "",
};
});
const languages = langRes.data || [];
const byLocale = {};
for (const row of rows) {
byLocale[row.b_locale] = row;
}
const defaultLanguage = languages.find((item) => Number(item.b_default) === 1);
const defaultLocale = String(defaultLanguage?.b_code || languages[0]?.b_code || "");
const values = [];
for (const language of languages) {
const existing = byLocale[language.b_code];
let value = "";
if (existing) {
value = existing.b_value;
} else if (language.b_code === defaultLocale) {
value = defaultValue;
}
values.push({
b_id: existing ? existing.b_id : null,
b_locale: language.b_code,
b_name: language.b_name,
b_value: value,
});
}
entries.value = values;
} catch {
entries.value = [];
}
@@ -66,7 +70,7 @@ async function save() {
try {
const req = { table: "b_i18n", key_field: "b_id", inserts: [], updates: [] };
for (const e of entries.value) {
const value = e.b_value ?? "";
const value = e.b_value == null ? "" : e.b_value;
if (!e.b_id && !value) continue;
const row = { b_key: i18nKey.value, b_locale: e.b_locale, b_value: value };
if (e.b_id) req.updates.push({ ...row, b_id: e.b_id });
@@ -74,6 +78,7 @@ async function save() {
}
if (req.inserts.length || req.updates.length) {
await saveObjectApi([req]);
if (appStore.locale) await appStore.loadTranslations(appStore.locale);
}
message.success("多语言已保存");
visible.value = false;
@@ -1,6 +1,7 @@
<script setup>
import { computed, ref, watch } from "vue";
import { Languages } from "@lucide/vue";
import FmsMemoInput from "@/components/FmsMemoInput.vue";
import { buildModuleI18nKey } from "@/utils/i18n";
import I18nQuickSetModal from "./I18nQuickSetModal.vue";
@@ -24,8 +25,12 @@ watch(
{ immediate: true },
);
const isPage = computed(() => formData.value?.b_module_type === "page");
const isData = computed(() => formData.value?.b_module_type === "data");
const isPage = computed(() => {
return formData.value && formData.value.b_module_type === "page";
});
const isData = computed(() => {
return formData.value && formData.value.b_module_type === "data";
});
function emitUpdate() {
if (formData.value) emit("update", formData.value);
@@ -55,10 +60,12 @@ function onModuleTypeChange(v) {
const i18nModalRef = ref(null);
function openI18nModal() {
const key =
formData.value?.b_i18n ||
buildModuleI18nKey(formData.value?.b_module_type, formData.value?.b_code);
i18nModalRef.value?.open(key, formData.value?.b_name || "");
if (!formData.value || !i18nModalRef.value) return;
let key = formData.value.b_i18n;
if (!key) {
key = buildModuleI18nKey(formData.value.b_module_type, formData.value.b_code);
}
i18nModalRef.value.open(key, formData.value.b_name || "");
}
function onI18nSaved(key) {
@@ -155,9 +162,10 @@ function onI18nSaved(key) {
</a-col>
<a-col v-if="isData" :span="8">
<a-form-item label="默认排序">
<a-input
:value="formData.b_orderfield"
@update:value="(v) => setField('b_orderfield', v)"
<FmsMemoInput
:model-value="formData.b_orderfield || ''"
:disabled="loading"
@update:model-value="(v) => setField('b_orderfield', v)"
/>
</a-form-item>
</a-col>
@@ -22,7 +22,11 @@ watch(
() => props.editConfig,
(val) => {
if (val === localEdit.value) return;
localEdit.value = val?.map((r) => ({ ...r })) || [];
const rows = [];
for (const row of val || []) {
rows.push({ ...row });
}
localEdit.value = rows;
},
{ immediate: true },
);
@@ -32,24 +36,31 @@ function emitUpdate() {
}
function onRowOrderChange(rows) {
localEdit.value = rows.map((row, index) => ({
...row,
b_xh: (index + 1) * 10,
}));
const sortedRows = [];
for (let index = 0; index < rows.length; index++) {
sortedRows.push({ ...rows[index], b_xh: (index + 1) * 10 });
}
localEdit.value = sortedRows;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
.map((f) => ({ label: f.b_name || f.b_field, value: String(f.b_id) })),
);
const availableFields = computed(() => {
const options = [];
for (const field of props.fields || []) {
if (field.b_canuse !== 1) continue;
options.push({ label: field.b_name || field.b_field, value: String(field.b_id) });
}
return options;
});
const groupOptions = computed(() =>
(props.groups || [])
.filter((g) => g.b_canuse === 1)
.map((g) => ({ label: g.b_title || `分组-${g.b_id}`, value: String(g.b_id) })),
);
const groupOptions = computed(() => {
const options = [];
for (const group of props.groups || []) {
if (group.b_canuse !== 1) continue;
options.push({ label: group.b_title || `分组-${group.b_id}`, value: String(group.b_id) });
}
return options;
});
function deleteRow(record) {
if (props.loading) return;
@@ -57,16 +68,6 @@ function deleteRow(record) {
emitUpdate();
}
function makeSwitchCol(field, title, width) {
return {
dataIndex: field,
title,
width: width || 60,
align: "center",
editor: { type: "checkbox" },
};
}
const columns = shallowRef([
{
dataIndex: "b_field_id",
@@ -77,9 +78,27 @@ const columns = shallowRef([
options: () => availableFields.value,
},
},
makeSwitchCol("b_required", "必填"),
makeSwitchCol("b_readonly", "只读"),
makeSwitchCol("b_disabled", "禁用"),
{
dataIndex: "b_required",
title: "必填",
width: 60,
align: "center",
editor: { type: "checkbox" },
},
{
dataIndex: "b_readonly",
title: "只读",
width: 60,
align: "center",
editor: { type: "checkbox" },
},
{
dataIndex: "b_disabled",
title: "禁用",
width: 60,
align: "center",
editor: { type: "checkbox" },
},
{
dataIndex: "b_group_id",
title: "编辑分组",
@@ -1,7 +1,10 @@
<script setup>
import { ref, watch } from "vue";
import { h, ref, watch } from "vue";
import { message } from "antdv-next";
import { Languages } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { syncModuleItemI18nKeys } from "@/utils/i18n";
import { buildModuleItemI18nKey, syncModuleItemI18nKeys } from "@/utils/i18n";
import I18nQuickSetModal from "./I18nQuickSetModal.vue";
const FIELD_TYPES = [
{ label: "输入框", value: "input" },
@@ -10,7 +13,7 @@ const FIELD_TYPES = [
{ label: "金额", value: "money" },
{ label: "日期", value: "date" },
{ label: "日期时间", value: "datetime" },
{ label: "开关", value: "switch" },
{ label: "复选框", value: "checkbox" },
{ label: "下拉选择", value: "select" },
];
@@ -21,16 +24,20 @@ const props = defineProps({
loading: { type: Boolean, default: false },
});
const emit = defineEmits(["update"]);
const emit = defineEmits(["update", "i18nSaved"]);
const localFields = ref([]);
const activeKey = ref(null);
const i18nModalRef = ref(null);
watch(
() => [props.fields, props.moduleCode],
([val, moduleCode], previous = []) => {
if (val === localFields.value && moduleCode === previous[1]) return;
const rows = val?.map((row) => ({ ...row })) || [];
const rows = [];
for (const row of val || []) {
rows.push({ ...row });
}
const changed = syncModuleItemI18nKeys(rows, "field", moduleCode, "b_field");
localFields.value = rows;
if (changed) emit("update", rows);
@@ -48,6 +55,59 @@ function onCellChange() {
emitUpdate();
}
function openI18n(row) {
const key = buildModuleItemI18nKey("field", props.moduleCode, row.b_field);
if (!key) {
message.warning("请先填写字段名和模块编码");
return;
}
if (i18nModalRef.value) {
i18nModalRef.value.open(key, row.b_name || row.b_field || "");
}
}
const I18nKeyCell = ({ row, cellValue }) =>
h(
"span",
{
style: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "4px",
width: "100%",
},
},
[
h(
"span",
{ style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
cellValue || "",
),
h(
"button",
{
type: "button",
title: "设置多语言",
style: {
flex: "none",
border: "none",
background: "transparent",
cursor: "pointer",
padding: "2px",
display: "inline-flex",
alignItems: "center",
},
onClick: (event) => {
event.stopPropagation();
openI18n(row);
},
},
[h(Languages, { size: 14 })],
),
],
);
const columns = ref([
{ dataIndex: "b_field", title: "字段名", width: 140 },
{
@@ -75,6 +135,7 @@ const columns = ref([
dataIndex: "b_i18n",
title: "多语言键",
width: 220,
customCell: I18nKeyCell,
},
{
dataIndex: "b_canuse",
@@ -99,6 +160,7 @@ const columns = ref([
@change="onCellChange"
/>
</div>
<I18nQuickSetModal ref="i18nModalRef" @saved="(key) => emit('i18nSaved', key)" />
</div>
</template>
@@ -24,49 +24,59 @@ const langOptions = ref([]);
// 扁平原始数据(b_id, b_key, b_locale, b_value),仅用于保存时差量对比。
const originalsFlat = ref([]);
// 透视后的行数据:一行一个 key,每种语言的值挂在对应 locale 字段上。
// 形如 { _id, b_key, "zh-CN": "…", "en-US": "…", _localeIds: { "zh-CN": b_id, … } }
const pivotRows = ref([]);
const isLoading = ref(false);
const searchKey = ref("");
const activeKey = ref(null);
const tableRef = ref(null);
let loadRequestId = 0;
const managedItems = computed(() => (props.i18nItems || []).filter((item) => item?.managed));
const requestedKeys = computed(() => [
...new Set(
(props.i18nItems || [])
.flatMap((item) => [item?.key, item?.sourceKey])
.filter(Boolean)
.map(String),
),
]);
const managedItems = computed(() => {
const items = [];
for (const item of props.i18nItems || []) {
if (item && item.managed) items.push(item);
}
return items;
});
const requestedKeys = computed(() => {
const keys = new Set();
for (const item of props.i18nItems || []) {
if (!item) continue;
if (item.key) keys.add(String(item.key));
if (item.sourceKey) keys.add(String(item.sourceKey));
}
return Array.from(keys);
});
function isManagedRow(row) {
return Boolean(row?._managedId);
return Boolean(row && row._managedId);
}
// 保证模块、字段、权限的受管键始终存在。简体中文为空或仍为上一次默认名称时,
// 保证模块、字段、权限的受管键始终存在。默认语言为空或仍为上一次默认名称时,
// 自动跟随当前名称;人工修改后的翻译不再被名称变化覆盖。
// itemsOverride 用于保存时按最终草稿态生成受管键(不传则使用 props.i18nItems)。
function reconcileManagedRows(itemsOverride) {
const items = itemsOverride || managedItems.value;
const rows = [...pivotRows.value];
const rowByManagedId = new Map(
rows.filter((row) => row._managedId).map((row) => [String(row._managedId), row]),
);
const unclaimedByKey = new Map(
rows.filter((row) => !row._managedId).map((row) => [String(row.b_key), row]),
);
const rowByManagedId = new Map();
const unclaimedByKey = new Map();
for (const row of rows) {
if (row._managedId) {
rowByManagedId.set(String(row._managedId), row);
} else {
unclaimedByKey.set(String(row.b_key), row);
}
}
const currentManagedIds = new Set();
const defaultLocale =
langOptions.value.find((item) => item.default)?.value || langOptions.value[0]?.value || "";
for (const item of items) {
const managedId = String(item.id || item.key);
currentManagedIds.add(managedId);
let row =
rowByManagedId.get(managedId) ||
unclaimedByKey.get(String(item.key)) ||
unclaimedByKey.get(String(item.sourceKey));
let row = rowByManagedId.get(managedId);
if (!row) row = unclaimedByKey.get(String(item.key));
if (!row && item.sourceKey) row = unclaimedByKey.get(String(item.sourceKey));
if (!row) {
row = { _id: createTempId(), b_key: item.key, _localeIds: {} };
rows.push(row);
@@ -74,8 +84,13 @@ function reconcileManagedRows(itemsOverride) {
const previousDefault = row._defaultValue;
const nextDefault = item.defaultValue == null ? "" : String(item.defaultValue);
if (row["zh-CN"] == null || row["zh-CN"] === "" || row["zh-CN"] === previousDefault) {
row["zh-CN"] = nextDefault;
if (
defaultLocale &&
(row[defaultLocale] == null ||
row[defaultLocale] === "" ||
row[defaultLocale] === previousDefault)
) {
row[defaultLocale] = nextDefault;
}
row.b_key = String(item.key);
row._managedId = managedId;
@@ -103,10 +118,15 @@ async function loadLangTypes() {
"b_canuse = 1",
"b_default DESC, b_xh ASC, b_id ASC",
);
langOptions.value = (res.data || []).map((t) => ({
label: `${t.b_name} (${t.b_code})`,
value: t.b_code,
}));
const options = [];
for (const language of res.data || []) {
options.push({
label: `${language.b_name} (${language.b_code})`,
value: language.b_code,
default: Number(language.b_default) === 1,
});
}
langOptions.value = options;
} catch (e) {
message.error("加载语言类型失败:" + (e.message || e));
}
@@ -132,32 +152,40 @@ function buildPivot(flat) {
}
async function loadI18n() {
const requestId = ++loadRequestId;
const moduleId = String(props.moduleId);
isLoading.value = true;
try {
const keys = requestedKeys.value;
const quotedKeys = keys.map((key) => "'" + key.replace(/'/g, "''") + "'");
const where = keys.length ? "b_key IN (" + quotedKeys.join(", ") + ")" : null;
const [i18nRes] = await Promise.all([
const responses = await Promise.all([
where
? loadDataApi("b_i18n", where, "b_key ASC, b_locale ASC")
: Promise.resolve({ data: [] }),
loadLangTypes(),
]);
const flat = (i18nRes.data || []).map((r) => ({
b_id: r.b_id,
b_key: r.b_key,
b_locale: r.b_locale,
b_value: r.b_value,
}));
if (requestId !== loadRequestId) return;
const i18nRes = responses[0];
const flat = [];
for (const row of i18nRes.data || []) {
flat.push({
b_id: row.b_id,
b_key: row.b_key,
b_locale: row.b_locale,
b_value: row.b_value,
});
}
if (String(props.moduleId) !== moduleId) return;
originalsFlat.value = cloneData(flat);
pivotRows.value = buildPivot(flat);
reconcileManagedRows();
} catch (e) {
if (requestId === loadRequestId) message.error("加载多语言失败:" + (e.message || e));
if (String(props.moduleId) === moduleId) {
message.error("加载多语言失败:" + (e.message || e));
}
} finally {
if (requestId === loadRequestId) isLoading.value = false;
if (String(props.moduleId) === moduleId) {
isLoading.value = false;
}
}
}
@@ -166,12 +194,15 @@ async function refreshKey(key) {
const escaped = String(key).replace(/'/g, "''");
try {
const res = await loadDataApi("b_i18n", "b_key = '" + escaped + "'", "b_locale ASC");
const rows = (res.data || []).map((row) => ({
b_id: row.b_id,
b_key: row.b_key,
b_locale: row.b_locale,
b_value: row.b_value,
}));
const rows = [];
for (const row of res.data || []) {
rows.push({
b_id: row.b_id,
b_key: row.b_key,
b_locale: row.b_locale,
b_value: row.b_value,
});
}
originalsFlat.value = [
...originalsFlat.value.filter((row) => row.b_key !== key),
...cloneData(rows),
@@ -194,7 +225,9 @@ async function addRow() {
activeKey.value = String(newId);
await nextTick();
await tableRef.value?.editRow(String(newId), "b_key");
if (tableRef.value) {
await tableRef.value.editRow(String(newId), "b_key");
}
}
function deleteRow(record) {
@@ -272,7 +305,7 @@ function flatten(rows) {
// 由父级「同步字段」调用:把默认翻译条目({ key, locale, value, managedId })并入草稿行。
// 已存在同 b_key 的行只补全空值(不覆盖用户配置),避免新增重复键;逻辑见 utils/i18n.mergeI18nDefaults。
function mergeDefaults(entries) {
if (!entries?.length) return;
if (!entries || entries.length === 0) return;
pivotRows.value = mergeI18nDefaults(pivotRows.value, entries);
}
@@ -296,7 +329,11 @@ async function loadExistingByKeyLocale(keys) {
// 由父级统一保存调用:返回 b_i18n 的变更请求(无变更返回 null),保存成功后父级调用 markSaved 同步基线
async function buildSaveRequest() {
// 保存涉及的键(受管键可能在新建/改名模块时才生成,打开面板时未被加载)
const keys = [...new Set(pivotRows.value.map((r) => String(r.b_key)).filter(Boolean))];
const keySet = new Set();
for (const row of pivotRows.value) {
if (row.b_key) keySet.add(String(row.b_key));
}
const keys = Array.from(keySet);
if (keys.length) {
const existing = await loadExistingByKeyLocale(keys);
if (existing.size) {
@@ -362,7 +399,6 @@ const columns = computed(() => {
title: "键",
width: 240,
align: "left",
headerAlign: "left",
editor: { type: "input", disabled: (row) => isManagedRow(row) },
},
];
@@ -372,7 +408,6 @@ const columns = computed(() => {
title: opt.label,
width: 180,
align: "left",
headerAlign: "left",
editor: { type: "input" },
});
}
@@ -22,7 +22,11 @@ watch(
() => props.listConfig,
(val) => {
if (val === localList.value) return;
localList.value = val?.map((r) => ({ ...r })) || [];
const rows = [];
for (const row of val || []) {
rows.push({ ...row });
}
localList.value = rows;
},
{ immediate: true },
);
@@ -32,24 +36,31 @@ function emitUpdate() {
}
function onRowOrderChange(rows) {
localList.value = rows.map((row, index) => ({
...row,
b_xh: (index + 1) * 10,
}));
const sortedRows = [];
for (let index = 0; index < rows.length; index++) {
sortedRows.push({ ...rows[index], b_xh: (index + 1) * 10 });
}
localList.value = sortedRows;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
.map((f) => ({ label: f.b_name || f.b_field, value: String(f.b_id) })),
);
const availableFields = computed(() => {
const options = [];
for (const field of props.fields || []) {
if (field.b_canuse !== 1) continue;
options.push({ label: field.b_name || field.b_field, value: String(field.b_id) });
}
return options;
});
const groupOptions = computed(() =>
(props.groups || [])
.filter((g) => g.b_canuse === 1)
.map((g) => ({ label: g.b_title || `分组-${g.b_id}`, value: String(g.b_id) })),
);
const groupOptions = computed(() => {
const options = [];
for (const group of props.groups || []) {
if (group.b_canuse !== 1) continue;
options.push({ label: group.b_title || `分组-${group.b_id}`, value: String(group.b_id) });
}
return options;
});
function deleteRow(record) {
if (props.loading) return;
@@ -25,7 +25,10 @@ watch(
() => [props.powers, props.moduleCode],
([val, moduleCode], previous = []) => {
if (val === localPowers.value && moduleCode === previous[1]) return;
const rows = (val || []).map((row) => ({ ...row }));
const rows = [];
for (const row of val || []) {
rows.push({ ...row });
}
const changed = syncModuleItemI18nKeys(rows, "power", moduleCode, "b_code");
localPowers.value = rows;
if (changed) emit("update", rows);
@@ -40,7 +43,7 @@ function emitUpdate() {
async function addRow() {
if (props.loading) return;
const newId = createTempId();
localPowers.value.push({
const newRow = {
b_id: newId,
b_module_id: props.moduleId,
b_code: "",
@@ -48,12 +51,17 @@ async function addRow() {
b_i18n: null,
b_xh: (localPowers.value.length + 1) * 10,
b_canuse: 1,
});
};
const rows = localPowers.value.slice();
rows.push(newRow);
localPowers.value = rows;
emitUpdate();
activeKey.value = String(newId);
await nextTick();
await tableRef.value?.editRow(String(newId), "b_code");
if (tableRef.value) {
await tableRef.value.editRow(String(newId), "b_code");
}
}
function deleteRow(record) {
@@ -112,7 +120,9 @@ function openI18n(row) {
message.warning("请先填写操作编码");
return;
}
i18nModalRef.value?.open(key, row.b_name || "");
if (i18nModalRef.value) {
i18nModalRef.value.open(key, row.b_name || "");
}
}
// 多语言键单元格:值 + 右侧翻译 icon 按钮(原生 button,避免 antdv Button 组件开销)
@@ -159,10 +169,30 @@ const I18nKeyCell = ({ row, cellValue }) =>
);
const columns = ref([
{ dataIndex: "b_code", title: "操作编码", width: 160, editor: { type: "input" } },
{ dataIndex: "b_name", title: "操作名称", width: 160, editor: { type: "input" } },
{ dataIndex: "b_i18n", title: "多语言键", width: 160, customCell: I18nKeyCell },
{ dataIndex: "b_xh", title: "排序", width: 80, editor: { type: "input" } },
{
dataIndex: "b_code",
title: "操作编码",
width: 160,
editor: { type: "input" },
},
{
dataIndex: "b_name",
title: "操作名称",
width: 160,
editor: { type: "input" },
},
{
dataIndex: "b_i18n",
title: "多语言键",
width: 160,
customCell: I18nKeyCell,
},
{
dataIndex: "b_xh",
title: "排序",
width: 80,
editor: { type: "input" },
},
{
dataIndex: "b_canuse",
title: "启用",
@@ -34,7 +34,11 @@ watch(
() => props.queryConfig,
(val) => {
if (val === localQuery.value) return;
localQuery.value = val?.map((r) => ({ ...r })) || [];
const rows = [];
for (const row of val || []) {
rows.push({ ...row });
}
localQuery.value = rows;
},
{ immediate: true },
);
@@ -44,18 +48,22 @@ function emitUpdate() {
}
function onRowOrderChange(rows) {
localQuery.value = rows.map((row, index) => ({
...row,
b_xh: (index + 1) * 10,
}));
const sortedRows = [];
for (let index = 0; index < rows.length; index++) {
sortedRows.push({ ...rows[index], b_xh: (index + 1) * 10 });
}
localQuery.value = sortedRows;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
.map((f) => ({ label: f.b_name || f.b_field, value: String(f.b_id) })),
);
const availableFields = computed(() => {
const options = [];
for (const field of props.fields || []) {
if (field.b_canuse !== 1) continue;
options.push({ label: field.b_name || field.b_field, value: String(field.b_id) });
}
return options;
});
function addRow() {
if (props.loading) return;
@@ -4,6 +4,7 @@ import { message, Modal } from "antdv-next";
import FmsLoadingOverlay from "@/components/FmsLoadingOverlay.vue";
import FmsTree from "@/components/FmsTree.vue";
import { loadDataApi, saveObjectApi, nextIdApi, describeApi } from "@/services/api";
import { useAppStore } from "@/stores/app";
import {
cloneData,
diffRows,
@@ -30,6 +31,7 @@ import ModulePowersPanel from "./components/ModulePowersPanel.vue";
import ModuleI18nPanel from "./components/ModuleI18nPanel.vue";
// ---- 状态 ----
const appStore = useAppStore();
const treeData = ref([]);
const treeLoading = ref(false);
const expandedTreeKeys = ref([]);
@@ -80,8 +82,6 @@ const configSaving = ref(false);
const configSyncing = ref(false);
const i18nPanelRef = ref(null);
const loadedModuleId = ref(null);
let configRequestId = 0;
let operationId = 0;
// 使用函数 ref,避免组件位于 TabPane 的 v-for 内时被 Vue 收集成实例数组。
function setI18nPanelRef(instance) {
@@ -94,10 +94,6 @@ const busy = computed(() => configLoading.value || configSaving.value || configS
const syncDisabled = computed(
() => busy.value || !String(selectedModule.value?.b_viewtable || "").trim(),
);
const isCurrentModule = computed(
() =>
loadedModuleId.value != null && String(loadedModuleId.value) === String(selectedModuleId.value),
);
// 构建受管多语言项。managedSource 提供模块/字段/权限的受管键来源,
// plainSource 提供分组/列表标题等非受管键来源(手动填写,需即时反馈)。
function buildI18nItems(managedSource, plainSource) {
@@ -245,64 +241,70 @@ async function loadTree() {
}
// ---- 模块配置加载 ----
async function loadModuleConfig(moduleId, requestId) {
async function loadModuleConfig(moduleId) {
configLoading.value = true;
try {
const id = String(moduleId);
const moduleType = findNode(id)?.b_module_type;
const requests = [["module", loadDataApi("s_module", `b_id = ${id}`)]];
let moduleResponse;
let fieldsResponse;
let groupsResponse;
let listResponse;
let editResponse;
let queryResponse;
let powersResponse;
if (moduleType === "data") {
requests.push(
["fields", loadDataApi("s_module_field", `b_module_id = ${id}`, "b_id ASC")],
[
"groups",
loadDataApi("s_module_field_group", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
],
[
"listConfig",
loadDataApi("s_module_field_list", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
],
[
"editConfig",
loadDataApi("s_module_field_edit", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
],
[
"queryConfig",
loadDataApi("s_module_field_query", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
],
);
const responses = await Promise.all([
loadDataApi("s_module", `b_id = ${id}`),
loadDataApi("s_module_field", `b_module_id = ${id}`, "b_id ASC"),
loadDataApi("s_module_field_group", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module_field_list", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module_field_edit", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module_field_query", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
]);
moduleResponse = responses[0];
fieldsResponse = responses[1];
groupsResponse = responses[2];
listResponse = responses[3];
editResponse = responses[4];
queryResponse = responses[5];
} else if (moduleType === "page") {
requests.push([
"powers",
const responses = await Promise.all([
loadDataApi("s_module", `b_id = ${id}`),
loadDataApi("s_module_power", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
]);
moduleResponse = responses[0];
powersResponse = responses[1];
} else {
moduleResponse = await loadDataApi("s_module", `b_id = ${id}`);
}
const responses = Object.fromEntries(
await Promise.all(requests.map(async ([key, request]) => [key, await request])),
);
if (String(selectedModuleId.value) !== id) return;
const original = {
module: responses.module.data?.[0] || null,
fields: responses.fields?.data || [],
groups: responses.groups?.data || [],
listConfig: responses.listConfig?.data || [],
editConfig: responses.editConfig?.data || [],
queryConfig: responses.queryConfig?.data || [],
powers: responses.powers?.data || [],
module: moduleResponse.data?.[0] || null,
fields: fieldsResponse ? fieldsResponse.data || [] : [],
groups: groupsResponse ? groupsResponse.data || [] : [],
listConfig: listResponse ? listResponse.data || [] : [],
editConfig: editResponse ? editResponse.data || [] : [],
queryConfig: queryResponse ? queryResponse.data || [] : [],
powers: powersResponse ? powersResponse.data || [] : [],
};
if (requestId !== configRequestId) return;
originals.value = original;
drafts.value = cloneData(original);
loadedModuleId.value = id;
activeTab.value = "basic";
} catch (e) {
if (requestId === configRequestId) {
if (String(selectedModuleId.value) === String(moduleId)) {
loadedModuleId.value = null;
drafts.value.module = null;
message.error("模块配置加载失败:" + (e.message || e));
}
} finally {
if (requestId === configRequestId) configLoading.value = false;
if (String(selectedModuleId.value) === String(moduleId)) {
configLoading.value = false;
}
}
}
@@ -315,8 +317,6 @@ function onTreeSelect(keys) {
}
watch(selectedModuleId, (id) => {
const requestId = ++configRequestId;
operationId++;
if (!id) {
configLoading.value = false;
loadedModuleId.value = null;
@@ -329,7 +329,7 @@ watch(selectedModuleId, (id) => {
return;
}
loadedModuleId.value = null;
void loadModuleConfig(id, requestId);
void loadModuleConfig(id);
});
// ---- 子面板数据变更 ----
@@ -345,7 +345,7 @@ function onSetField(field, value) {
// 分组管理已自行保存到后端,同步更新 originals 和 drafts
function onGroupsSaved(newGroups, idMapping) {
if (idMapping?.size) {
if (idMapping && idMapping.size) {
remapForeignKeys(drafts.value.listConfig || [], ["b_group_id"], idMapping);
remapForeignKeys(drafts.value.editConfig || [], ["b_group_id"], idMapping);
}
@@ -354,7 +354,9 @@ function onGroupsSaved(newGroups, idMapping) {
}
function onI18nSaved(key) {
void i18nPanelRef.value?.refreshKey?.(key);
if (i18nPanelRef.value) {
void i18nPanelRef.value.refreshKey(key);
}
}
// ---- 新增模块 ----
@@ -442,7 +444,7 @@ function findNode(key) {
function hasChildNode(moduleId) {
const node = findNode(moduleId);
return !!node?.children?.length;
return Boolean(node && node.children && node.children.length);
}
async function onDeleteNodeById(moduleId) {
@@ -486,9 +488,10 @@ async function onDeleteNodeById(moduleId) {
// ---- 统一保存 ----
async function saveAll() {
if (!selectedModule.value || !isCurrentModule.value || busy.value) return;
if (!selectedModule.value || busy.value) return;
if (loadedModuleId.value == null) return;
if (String(loadedModuleId.value) !== String(selectedModuleId.value)) return;
const moduleId = String(selectedModule.value.b_id);
const currentOperationId = ++operationId;
configSaving.value = true;
try {
@@ -497,9 +500,13 @@ async function saveAll() {
// s_module 主表
const moduleData = drafts.value.module;
const moduleOriginal = originals.value.module;
const moduleCode = String(moduleData?.b_code || "").trim();
const moduleCode = String(moduleData.b_code || "").trim();
if (!moduleCode) {
message.warning(`请填写${moduleData?.b_module_type === "data" ? "模块编码" : "菜单编码"}`);
if (moduleData.b_module_type === "data") {
message.warning("请填写模块编码");
} else {
message.warning("请填写菜单编码");
}
return;
}
const codeError = validateModuleCode(moduleCode);
@@ -507,7 +514,7 @@ async function saveAll() {
message.error(`编码格式不正确:${codeError}`);
return;
}
if (!String(moduleData?.b_name || "").trim()) {
if (!String(moduleData.b_name || "").trim()) {
message.warning("请填写模块名称");
return;
}
@@ -522,13 +529,18 @@ async function saveAll() {
syncModuleItemI18nKeys(drafts.value.fields, "field", moduleData.b_code, "b_field");
syncModuleItemI18nKeys(drafts.value.powers, "power", moduleData.b_code, "b_code");
moduleData.b_i18n = buildModuleI18nKey(moduleData.b_module_type, moduleData.b_code);
const isNew = !moduleOriginal?.b_code;
const isNew = !moduleOriginal || !moduleOriginal.b_code;
if (moduleData && JSON.stringify(moduleData) !== JSON.stringify(moduleOriginal)) {
saveReqs.push({
const moduleRequest = {
table: "s_module",
key_field: "b_id",
[isNew ? "inserts" : "updates"]: [moduleData],
});
};
if (isNew) {
moduleRequest.inserts = [moduleData];
} else {
moduleRequest.updates = [moduleData];
}
saveReqs.push(moduleRequest);
}
// 子表:fields, listConfig, editConfig, queryConfig, powers
@@ -558,14 +570,13 @@ async function saveAll() {
}
// 多语言:由各子面板收集变更,统一在此保存
if (currentOperationId !== operationId || String(selectedModule.value?.b_id) !== moduleId)
return;
if (!selectedModule.value || String(selectedModule.value.b_id) !== moduleId) return;
const i18nPanel = i18nPanelRef.value;
if (i18nPanel && typeof i18nPanel.reconcileManagedRows === "function") {
// 保存时才按最终草稿态生成受管键(模块编码/字段/权限),避免编辑过程中实时生成。
i18nPanel.reconcileManagedRows(finalI18nItems.value);
}
if (typeof i18nPanel?.buildSaveRequest === "function") {
if (i18nPanel && typeof i18nPanel.buildSaveRequest === "function") {
const i18nReq = await i18nPanel.buildSaveRequest();
if (i18nReq) saveReqs.push(i18nReq);
}
@@ -579,8 +590,11 @@ async function saveAll() {
// 更新 originals
originals.value = cloneData(drafts.value);
i18nPanelRef.value?.markSaved?.();
if (i18nPanelRef.value) {
i18nPanelRef.value.markSaved();
}
updateSavedTreeNode(moduleData);
if (appStore.locale) await appStore.loadTranslations(appStore.locale);
message.success("保存成功");
} catch (e) {
message.error("保存失败:" + (e.message || e));
@@ -591,12 +605,16 @@ async function saveAll() {
// ---- 字段同步与默认配置 ----
function configuredFieldIds(section) {
return new Set(
[...(originals.value[section] || []), ...(drafts.value[section] || [])]
.map((row) => row.b_field_id)
.filter((id) => id != null)
.map(String),
);
const ids = new Set();
const originalRows = originals.value[section] || [];
const draftRows = drafts.value[section] || [];
for (const row of originalRows) {
if (row.b_field_id != null) ids.add(String(row.b_field_id));
}
for (const row of draftRows) {
if (row.b_field_id != null) ids.add(String(row.b_field_id));
}
return ids;
}
function buildMissingDefaultConfigs(module, fields) {
@@ -606,77 +624,84 @@ function buildMissingDefaultConfigs(module, fields) {
const queryFieldIds = configuredFieldIds("queryConfig");
const generated = { listConfig: [], editConfig: [], queryConfig: [] };
fields
.filter((field) => field.b_canuse === 1)
.forEach((field, index) => {
const fieldId = String(field.b_id);
const order = (index + 1) * 10;
let orderIndex = 0;
for (const field of fields) {
if (field.b_canuse !== 1) continue;
orderIndex++;
const fieldId = String(field.b_id);
const order = orderIndex * 10;
if (!listFieldIds.has(fieldId)) {
generated.listConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_group_id: null,
b_title: null,
b_i18n: field.b_i18n || null,
b_visible: 1,
b_xh: order,
b_width: 100,
b_sortable: 0,
});
}
if (!listFieldIds.has(fieldId)) {
generated.listConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_group_id: null,
b_title: null,
b_i18n: field.b_i18n || null,
b_visible: 1,
b_xh: order,
b_width: 100,
b_sortable: 0,
});
}
if (!editFieldIds.has(fieldId)) {
generated.editConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_group_id: null,
b_xh: order,
});
}
if (!editFieldIds.has(fieldId)) {
generated.editConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_group_id: null,
b_xh: order,
});
}
if (!queryFieldIds.has(fieldId)) {
generated.queryConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_component: "input",
b_operator: "=",
b_default_value: null,
b_xh: order,
b_canuse: 1,
});
}
});
if (!queryFieldIds.has(fieldId)) {
generated.queryConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_component: "input",
b_operator: "=",
b_default_value: null,
b_xh: order,
b_canuse: 1,
});
}
}
return generated;
}
async function syncFields() {
const module = selectedModule.value;
const view = String(module?.b_viewtable || "").trim();
if (!module) return;
const view = String(module.b_viewtable || "").trim();
if (!view || busy.value) return;
const moduleId = String(module.b_id);
const currentOperationId = ++operationId;
try {
configSyncing.value = true;
const res = await describeApi(view);
if (currentOperationId !== operationId || String(selectedModule.value?.b_id) !== moduleId)
return;
if (!selectedModule.value || String(selectedModule.value.b_id) !== moduleId) return;
const columns = res.data || [];
const existingFields = drafts.value.fields || [];
// 构建视图列名集合
const viewColumnSet = new Set(columns.map((c) => c.name.toLowerCase()));
const viewColumnSet = new Set();
for (const column of columns) {
viewColumnSet.add(column.name.toLowerCase());
}
// 已有字段映射(按字段名)
const existingMap = new Map(existingFields.map((f) => [f.b_field?.toLowerCase(), f]));
const existingMap = new Map();
for (const field of existingFields) {
if (!field.b_field) continue;
existingMap.set(field.b_field.toLowerCase(), field);
}
const mergedFields = [];
@@ -704,15 +729,16 @@ async function syncFields() {
// 视图中不存在的已有字段标记为不可用
for (const existing of existingFields) {
if (!viewColumnSet.has(existing.b_field?.toLowerCase())) {
const fieldName = existing.b_field ? existing.b_field.toLowerCase() : "";
if (!viewColumnSet.has(fieldName)) {
mergedFields.push({ ...existing, b_canuse: 0 });
}
}
// 多语言键始终按 field.<模块代码>.<字段编码> 自动生成。
mergedFields.forEach((field) => {
for (const field of mergedFields) {
field.b_i18n = buildModuleItemI18nKey("field", module.b_code, field.b_field);
});
}
// 先为新字段分配真实 ID,后续配置通过外键引用这些 ID。
const changes = diffRows(mergedFields, existingFields);
@@ -725,38 +751,48 @@ async function syncFields() {
// 为"有 b_i18n 键"的字段生成多语言默认配置(b_i18n 草稿行),注入 i18n 面板,
// 由顶部"保存"统一落库;已存在的键由面板去重,不覆盖用户配置。
const candidateI18n = mergedFields
.filter((f) => f.b_canuse === 1 && f.b_i18n)
.map((f) => ({
key: f.b_i18n,
value: f.b_name || f.b_field,
managedId: `field:${f.b_id}`,
}));
let primaryLocale = "zh-CN";
const candidateI18n = [];
for (const field of mergedFields) {
if (field.b_canuse !== 1 || !field.b_i18n) continue;
candidateI18n.push({
key: field.b_i18n,
value: field.b_name || field.b_field,
managedId: `field:${field.b_id}`,
});
}
let primaryLocale = "";
try {
const langRes = await loadDataApi("b_i18n_type", "b_canuse = 1", "b_xh ASC, b_id ASC");
const langRes = await loadDataApi(
"b_i18n_type",
"b_canuse = 1",
"b_default DESC, b_xh ASC, b_id ASC",
);
const langs = langRes.data || [];
if (langs.length) {
// 默认字段名翻译写到"简体中文"(zh-CN),找不到再退回排序第一的语言
const zh = langs.find((l) => l.b_code === "zh-CN");
primaryLocale = (zh || langs[0]).b_code;
const defaultLanguage = langs.find((item) => Number(item.b_default) === 1);
primaryLocale = String(defaultLanguage?.b_code || langs[0].b_code || "");
}
} catch {
primaryLocale = "";
}
const i18nEntries = [];
if (primaryLocale) {
for (const item of candidateI18n) {
i18nEntries.push({
key: item.key,
locale: primaryLocale,
value: item.value,
managedId: item.managedId,
});
}
} catch (e) {
// 取不到语言类型时回退默认值
}
const i18nEntries = candidateI18n.map((e) => ({
key: e.key,
locale: primaryLocale,
value: e.value,
managedId: e.managedId,
}));
const generated = buildMissingDefaultConfigs(module, mergedFields);
const generatedSections = ["listConfig", "editConfig", "queryConfig"];
const generatedCount = generatedSections.reduce(
(total, section) => total + generated[section].length,
0,
);
let generatedCount = 0;
for (const section of generatedSections) {
generatedCount += generated[section].length;
}
if (!hasChanges(fieldReq) && !generatedCount && !i18nEntries.length) {
message.success("同步成功");
@@ -765,8 +801,8 @@ async function syncFields() {
// 多语言默认配置注入 i18n 面板草稿;字段已预分配真实 ID(见上方 allocateTemporaryIds),
// 列表/编辑/查询配置稍后追加到草稿。全部由顶部"保存"统一提交,期间不写库。
if (i18nEntries.length) {
i18nPanelRef.value?.mergeDefaults?.(i18nEntries);
if (i18nEntries.length && i18nPanelRef.value) {
i18nPanelRef.value.mergeDefaults(i18nEntries);
}
for (const section of generatedSections) {
@@ -850,14 +886,14 @@ void loadTree().catch((e) => message.error("模块树加载失败:" + (e.messa
<div class="mm-header-center">
<a-space size="middle">
<span>启用</span>
<a-switch
<a-checkbox
:checked="drafts.module?.b_canuse === 1"
:disabled="busy"
@update:checked="(v) => onSetField('b_canuse', v ? 1 : 0)"
/>
<a-divider type="vertical" />
<span>显示菜单</span>
<a-switch
<a-checkbox
:checked="drafts.module?.b_canmenu === 1"
:disabled="busy || drafts.module?.b_module_type === 'data'"
@update:checked="(v) => onSetField('b_canmenu', v ? 1 : 0)"
@@ -891,6 +927,7 @@ void loadTree().catch((e) => message.error("模块树加载失败:" + (e.messa
:fields="drafts.fields"
:loading="busy"
@update="(v) => onUpdate('fields', v)"
@i18n-saved="onI18nSaved"
/>
<ModuleListConfigPanel
v-else-if="tab.key === 'list'"
@@ -1,163 +1,143 @@
<script setup>
import { computed, h, onMounted, ref, shallowRef } from "vue";
import { message, Switch, Button, Popconfirm, Tag } from "antdv-next";
import { Plus } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { loadDataApi, saveObjectApi, nextIdApi } from "@/services/api";
import { usePermissionStore } from "@/stores/permissions";
import { computed, ref } from "vue";
import { message, Modal } from "antdv-next";
import { Plus, Trash2 } from "@lucide/vue";
import FmsModuleListPage from "@/components/fms-module-list/FmsModuleListPage.vue";
import { loadDataApi, nextIdApi, saveObjectApi } from "@/services/api";
import { useAppStore } from "@/stores/app";
const permissionStore = usePermissionStore();
const data = ref([]);
const loading = ref(false);
const appStore = useAppStore();
const listRef = ref(null);
const selectedRowKeys = ref([]);
const saving = ref(false);
const canCreate = computed(() => permissionStore.canPower("m_i18n_type", "create"));
const canUpdate = computed(() => permissionStore.canPower("m_i18n_type", "update"));
const canDelete = computed(() => permissionStore.canPower("m_i18n_type", "delete"));
const rowSelection = computed(() => {
return {
selectedRowKeys: selectedRowKeys.value,
getCheckboxProps: (row) => ({ disabled: Number(row.b_default) === 1 }),
onChange: (keys) => {
selectedRowKeys.value = keys;
},
};
});
async function loadData() {
loading.value = true;
const rowActions = computed(() => {
return [
{
key: "edit",
label: appStore.t("common.edit", "编辑"),
disabled: () => saving.value,
onClick: openEdit,
},
{
key: "delete",
type: "delete",
label: appStore.t("common.delete", "删除"),
confirm: appStore.t("common.confirmDeleteLanguage", "确定删除该语言?"),
disabled: ({ record }) => saving.value || Number(record.b_default) === 1,
onClick: removeRow,
},
];
});
async function reloadList() {
selectedRowKeys.value = [];
if (listRef.value) await listRef.value.reload();
try {
const res = await loadDataApi("b_i18n_type", "", "b_xh ASC, b_id ASC");
data.value = res.data || [];
} catch (e) {
message.error("加载多语言类型失败:" + (e?.message || e));
} finally {
loading.value = false;
await appStore.loadLanguages();
} catch (error) {
message.error("刷新语言配置失败:" + (error?.message || error));
}
}
onMounted(loadData);
/* ---------- 行内单元格渲染 ---------- */
function createDefaultCell() {
return (props) =>
props.row.b_default === 1
? h(Tag, { color: "blue" }, "默认")
: h("span", { class: "muted" }, "—");
}
function createCanuseCell() {
return (props) => {
const row = props.row;
return h(Switch, {
checked: row.b_canuse === 1,
size: "small",
disabled: !canUpdate.value,
"onUpdate:checked": (val) => {
const next = val ? 1 : 0;
if (row.b_canuse === next) return;
row.b_canuse = next;
saveRow(row, { b_canuse: next });
},
});
};
}
function createActionsCell() {
return (props) => {
const row = props.row;
const isDefault = row.b_default === 1;
const children = [];
if (canUpdate.value) {
children.push(
h(
Button,
{
type: "link",
size: "small",
onClick: (e) => {
e.stopPropagation();
openEdit(row);
},
},
"编辑",
),
);
}
if (canDelete.value && !isDefault) {
children.push(
h(
Popconfirm,
{
title: "确定删除该语言?",
okText: "删除",
okButtonProps: { danger: true },
onConfirm: () => removeRow(row),
},
{
default: () =>
h(
Button,
{ type: "link", size: "small", danger: true, onClick: (e) => e.stopPropagation() },
"删除",
),
},
),
);
}
return h("span", { class: "actions-cell" }, children);
};
}
const columns = shallowRef([
{ dataIndex: "b_code", title: "简称 / 编码", width: 150 },
{ dataIndex: "b_name", title: "名称", width: 160 },
{
dataIndex: "b_default",
title: "默认",
width: 80,
align: "center",
customCell: createDefaultCell(),
},
{
dataIndex: "b_canuse",
title: "是否启用",
width: 100,
align: "center",
customCell: createCanuseCell(),
},
{ dataIndex: "b_xh", title: "排序", width: 80, align: "center" },
{
dataIndex: "actions",
title: "操作",
width: 130,
align: "center",
customCell: createActionsCell(),
},
]);
/* ---------- 行内开关即时保存 ---------- */
async function saveRow(row, patch) {
try {
await saveObjectApi([
{ table: "b_i18n_type", key_field: "b_id", updates: [{ b_id: row.b_id, ...patch }] },
]);
message.success("已更新");
} catch (e) {
message.error("更新失败:" + (e?.message || e));
await loadData();
async function getOtherDefaultUpdates(currentId) {
const response = await loadDataApi("b_i18n_type", "b_default = 1", "b_id ASC");
const updates = [];
for (const row of response.data || []) {
if (String(row.b_id) === String(currentId)) continue;
updates.push({ b_id: row.b_id, b_default: 0 });
}
return updates;
}
async function removeRow(row) {
if (row.b_default === 1) return;
async function updateCheckbox(row, field, checked) {
if (saving.value) return;
saving.value = true;
try {
const value = checked ? 1 : 0;
const updates = [{ b_id: row.b_id, [field]: value }];
if (field === "b_default" && value === 1) {
updates.push(...(await getOtherDefaultUpdates(row.b_id)));
}
await saveObjectApi([
{ table: "b_i18n_type", key_field: "b_id", deletes: [{ b_id: row.b_id }] },
{ table: "b_i18n_type", key_field: "b_id", inserts: [], updates, deletes: [] },
]);
message.success("删除成功");
await loadData();
} catch (e) {
message.error("删除失败:" + (e?.message || e));
message.success(appStore.t("common.updateSuccess", "更新成功"));
await reloadList();
} catch (error) {
message.error(appStore.t("common.updateFailed", "更新失败") + ":" + (error?.message || error));
await reloadList();
} finally {
saving.value = false;
}
}
/* ---------- 新增 / 编辑弹窗 ---------- */
async function removeRow(row) {
if (saving.value || Number(row.b_default) === 1) return;
saving.value = true;
try {
await saveObjectApi([
{
table: "b_i18n_type",
key_field: "b_id",
inserts: [],
updates: [],
deletes: [{ b_id: row.b_id }],
},
]);
message.success(appStore.t("common.deleteSuccess", "删除成功"));
await reloadList();
} catch (error) {
message.error(appStore.t("common.deleteFailed", "删除失败") + ":" + (error?.message || error));
} finally {
saving.value = false;
}
}
function batchDelete() {
if (saving.value || !selectedRowKeys.value.length) return;
const keys = [...selectedRowKeys.value];
Modal.confirm({
title: `${appStore.t("common.confirmBatchDelete", "确定删除选中的记录?")} (${keys.length})`,
okText: appStore.t("common.delete", "删除"),
okType: "danger",
cancelText: appStore.t("common.cancel", "取消"),
onOk: async () => {
saving.value = true;
try {
const deletes = keys.map((b_id) => ({ b_id }));
await saveObjectApi([
{
table: "b_i18n_type",
key_field: "b_id",
inserts: [],
updates: [],
deletes,
},
]);
message.success(appStore.t("common.deleteSuccess", "删除成功"));
await reloadList();
} catch (error) {
message.error(
appStore.t("common.deleteFailed", "删除失败") + ":" + (error?.message || error),
);
} finally {
saving.value = false;
}
},
});
}
const modalVisible = ref(false);
const modalMode = ref("create");
const editingId = ref(null);
@@ -174,63 +154,69 @@ function openCreate() {
modalMode.value = "create";
editingId.value = null;
editingDefault.value = false;
form.value = { b_code: "", b_name: "", b_xh: 0, defaultOn: false, canuseOn: true };
form.value = {
b_code: "",
b_name: "",
b_xh: 0,
defaultOn: false,
canuseOn: true,
};
modalVisible.value = true;
}
function openEdit(row) {
modalMode.value = "edit";
editingId.value = row.b_id;
editingDefault.value = row.b_default === 1;
editingDefault.value = Number(row.b_default) === 1;
form.value = {
b_code: row.b_code,
b_name: row.b_name,
b_code: row.b_code || "",
b_name: row.b_name || "",
b_xh: row.b_xh ?? 0,
defaultOn: row.b_default === 1,
canuseOn: row.b_canuse === 1,
defaultOn: Number(row.b_default) === 1,
canuseOn: Number(row.b_canuse) === 1,
};
modalVisible.value = true;
}
async function saveForm() {
const f = form.value;
if (!f.b_code || !f.b_code.trim()) {
message.warning("请填写语言编码");
const current = form.value;
if (!String(current.b_code || "").trim()) {
message.warning(appStore.t("common.enterLanguageCode", "请填写语言编码"));
return;
}
if (!f.b_name || !f.b_name.trim()) {
message.warning("请填写语言名称");
if (!String(current.b_name || "").trim()) {
message.warning(appStore.t("common.enterLanguageName", "请填写语言名称"));
return;
}
saving.value = true;
try {
const isNew = modalMode.value === "create" || !editingId.value;
const b_id = isNew ? (await nextIdApi(1)).data : editingId.value;
const isNew = modalMode.value === "create";
const b_id = isNew ? (await nextIdApi()).data : editingId.value;
const row = {
b_id,
b_code: f.b_code.trim(),
b_name: f.b_name.trim(),
b_canuse: f.canuseOn ? 1 : 0,
b_default: f.defaultOn ? 1 : 0,
b_xh: f.b_xh ?? 0,
b_code: current.b_code.trim(),
b_name: current.b_name.trim(),
b_default: current.defaultOn ? 1 : 0,
b_canuse: current.canuseOn ? 1 : 0,
b_xh: current.b_xh ?? 0,
};
const request = {
table: "b_i18n_type",
key_field: "b_id",
inserts: isNew ? [row] : [],
updates: isNew ? [] : [row],
deletes: [],
};
const req = { table: "b_i18n_type", key_field: "b_id", inserts: [], updates: [], deletes: [] };
if (isNew) req.inserts.push(row);
else req.updates.push(row);
// 保证仅一个默认语言:若本次设为默认,则将其它语言取消默认
if (row.b_default === 1) {
for (const r of data.value) {
if (r.b_id !== b_id && r.b_default === 1) {
req.updates.push({ b_id: r.b_id, b_default: 0 });
}
}
request.updates.push(...(await getOtherDefaultUpdates(b_id)));
}
await saveObjectApi([req]);
message.success("保存成功");
await saveObjectApi([request]);
message.success(appStore.t("common.saveSuccess", "保存成功"));
modalVisible.value = false;
await loadData();
} catch (e) {
message.error("保存失败:" + (e?.message || e));
await reloadList();
} catch (error) {
message.error(appStore.t("common.saveFailed", "保存失败") + ":" + (error?.message || error));
} finally {
saving.value = false;
}
@@ -239,106 +225,90 @@ async function saveForm() {
<template>
<div class="i18n-type-page">
<div class="panel-card">
<div class="panel-toolbar">
<a-button v-if="canCreate" type="primary" @click="openCreate">
<Plus :size="16" /> 新增语言
</a-button>
</div>
<div class="panel-body">
<FmsTable
v-model:columns="columns"
:data-source="data"
row-key="b_id"
:loading="loading"
:pagination="false"
<FmsModuleListPage
ref="listRef"
:row-actions="rowActions"
:row-selection="rowSelection"
@loaded="selectedRowKeys = []"
>
<template #actions="{ loading }">
<a-space>
<a-button
type="primary"
:disabled="loading || saving"
@click="openCreate"
>
<Plus :size="16" />
{{ appStore.t("common.create", "新增") }}
</a-button>
<a-button
danger
:disabled="loading || saving || !selectedRowKeys.length"
@click="batchDelete"
>
<Trash2 :size="16" />
{{ appStore.t("common.batchDelete", "批量删除")
}}{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : "" }}
</a-button>
</a-space>
</template>
<template #cell-b_canuse="{ value, record }">
<a-checkbox
:checked="Number(value) === 1"
:disabled="saving"
@mousedown.stop
@click.stop
@update:checked="(checked) => updateCheckbox(record, 'b_canuse', checked)"
/>
</div>
</div>
</template>
<template #cell-b_default="{ value, record }">
<a-checkbox
:checked="Number(value) === 1"
:disabled="saving"
@mousedown.stop
@click.stop
@update:checked="(checked) => updateCheckbox(record, 'b_default', checked)"
/>
</template>
</FmsModuleListPage>
<a-modal
v-model:open="modalVisible"
:title="modalMode === 'create' ? '新增语言' : '编辑语言'"
:title="
modalMode === 'create'
? appStore.t('common.createLanguage', '新增语言')
: appStore.t('common.editLanguage', '编辑语言')
"
:confirm-loading="saving"
@ok="saveForm"
@cancel="modalVisible = false"
>
<a-form layout="vertical">
<a-form-item label="语言编码(简称)" required>
<a-form-item :label="appStore.t('common.languageCode', '语言编码')" required>
<a-input v-model:value="form.b_code" :disabled="editingDefault" />
</a-form-item>
<a-form-item label="语言名称" required>
<a-form-item :label="appStore.t('common.languageName', '语言名称')" required>
<a-input v-model:value="form.b_name" />
</a-form-item>
<a-form-item label="是否默认">
<a-switch v-model:checked="form.defaultOn" :disabled="editingDefault" />
<span class="form-tip">设为默认后,其它语言将自动取消默认</span>
<a-form-item :label="appStore.t('common.defaultLanguage', '默认语言')">
<a-checkbox v-model:checked="form.defaultOn" />
</a-form-item>
<a-form-item label="是否启用">
<a-switch v-model:checked="form.canuseOn" />
<a-form-item :label="appStore.t('common.enabled', '启用')">
<a-checkbox v-model:checked="form.canuseOn" />
</a-form-item>
<a-form-item label="排序">
<a-input-number v-model:value="form.b_xh" :min="0" style="width: 100%" />
<a-form-item :label="appStore.t('common.sort', '排序')">
<a-input-number v-model:value="form.b_xh" style="width: 100%" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<style scoped lang="scss">
<style scoped>
.i18n-type-page {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
gap: 12px;
}
.panel-card {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
overflow: hidden;
}
.panel-toolbar {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
&__title {
font-size: 14px;
font-weight: 500;
color: #1f2329;
}
}
.panel-body {
flex: 1;
min-height: 0;
padding: 12px 16px;
}
.actions-cell {
display: inline-flex;
gap: 4px;
align-items: center;
}
.muted {
color: #c0c4cc;
}
.form-tip {
margin-left: 8px;
font-size: 12px;
color: #646a73;
}
</style>
+7 -10
View File
@@ -57,11 +57,11 @@ s_module / s_module_field / s_module_field_list / s_module_field_group / s_modul
约束:
- `b_parent_id` 外键引用 `s_module.b_id`;保存服务校验父节点不形成循环。
- `b_code` 唯一,格式为 `^[a-z][a-z0-9_]{1,49}$`;`b_route` 非空时唯一。
- `b_code` 唯一;`b_route` 非空时唯一。
- `directory` 不能配置 `b_route`、`b_viewtable` 或 `b_savetable`。
- `page` 必须配置 `b_route`;其业务数据由页面组合的数据模块提供。
- `data` 不能显示菜单且不能配置 `b_route`;必须配置 `b_viewtable`,保存场景再按需配置 `b_savetable` 与 `b_keyfield`。
- `b_canmenu`、`b_canuse` 只能为 `0` 或 `1`。建议建立 `(b_parent_id, b_xh, b_id)` 索引。
- 建议建立 `(b_parent_id, b_xh, b_id)` 索引。
## 字段来源与同步
@@ -76,7 +76,7 @@ s_module / s_module_field / s_module_field_list / s_module_field_group / s_modul
| `b_field` | `varchar(50)` | 视图字段名 |
| `b_name` | `nvarchar(100)` | 默认字段名称,由管理员维护 |
| `b_i18n` | `varchar(150)` | 多语言键 |
| `b_type` | `varchar(30)` | 字段控件类型:`input`、`textarea`、`number`、`money`、`date`、`datetime`、`switch` 或 `select` |
| `b_type` | `varchar(30)` | 字段控件类型:`input`、`textarea`、`number`、`money`、`date`、`datetime`、`checkbox` 或 `select` |
| `b_default_value` | `nvarchar(500)` | 新增记录时的默认值 |
| `b_options` | `nvarchar(max)` | 类型专属 JSON 配置,例如选择器来源、日期格式或金额精度 |
| `b_canuse` | `int` | 是否启用 |
@@ -84,7 +84,7 @@ s_module / s_module_field / s_module_field_list / s_module_field_group / s_modul
约束:
- `b_module_id` 外键引用 `s_module.b_id`,`(b_module_id, b_field)` 唯一。
- `b_canuse` 只能为 `0` 或 `1`;`b_options` 非空时通过 `ISJSON` 校验。建议建立 `(b_module_id, b_canuse, b_id)` 索引。
- 建议建立 `(b_module_id, b_canuse, b_id)` 索引。
- 字段没有 list、edit 或 query 配置记录时,不参与对应场景。
- `b_type` 是展示和编辑的唯一类型来源:Table 按它渲染默认显示器,Form 或可编辑 Table 按它选择控件。
- `select` 是否允许自由输入、选项来源、值字段和显示字段均由 `b_options` 控制。
@@ -95,7 +95,7 @@ s_module / s_module_field / s_module_field_list / s_module_field_group / s_modul
1. 后端校验视图名并读取列名、SQL 类型和可空性等结构信息。
2. 按列名查找同模块的 `s_module_field`;新列创建字段基础记录。
3. 新字段按 SQL 类型初始化 `b_type`,例如字符串映射为 `input`、长文本映射为 `textarea`、数值映射为 `number`、日期映射为 `date` 或 `datetime`、位值映射为 `switch`。已有字段不覆盖管理员维护的 `b_type` 与 `b_options`;`select`、`money` 等业务类型由管理员按语义调整。
3. 新字段按 SQL 类型初始化 `b_type`,例如字符串映射为 `input`、长文本映射为 `textarea`、数值映射为 `number`、日期映射为 `date` 或 `datetime`、位值映射为 `checkbox`。已有字段不覆盖管理员维护的 `b_type` 与 `b_options`;`select`、`money` 等业务类型由管理员按语义调整。
4. 本次视图中不存在的已有字段保留其记录和全部场景配置,但将 `b_canuse` 置为 `0`。
5. 不覆盖人工维护的 `b_name`、`b_i18n`、`b_default_value` 与 `b_options`,不物理删除字段或场景配置。
@@ -120,7 +120,6 @@ s_module / s_module_field / s_module_field_list / s_module_field_group / s_modul
- `b_module_id` 外键引用 `s_module.b_id`,`b_field_id` 外键引用 `s_module_field.b_id`,`b_group_id` 引用同模块的 `s_module_field_group`。
- `b_field_id` 必填;同一字段在同一模块的列表中最多出现一次,使用 `(b_module_id, b_field_id)` 唯一约束。
- `b_visible` 只能为 `0` 或 `1`。
- 字段列的显示器由关联字段的 `b_type` 与 `b_options` 决定,例如 `money`、`date`、`datetime` 均由前端按统一规则渲染。建议建立 `(b_module_id, b_group_id, b_xh, b_id)` 索引。
- 不保存 `level`、`colspan`、`rowspan`;前端加载 Table 分组树和字段列后,按分组的 `b_parent_id`、`b_xh` 组装嵌套表头。隐藏分组的后代不显示;没有可见字段后代的分组不渲染。
@@ -152,7 +151,6 @@ s_module / s_module_field / s_module_field_list / s_module_field_group / s_modul
约束:
- `b_module_id` 外键引用 `s_module.b_id`,`b_field_id` 外键引用 `s_module_field.b_id`;`(b_module_id, b_field_id)` 唯一,保存服务校验字段属于同一模块。
- `b_required`、`b_readonly`、`b_disabled` 只能为 `0` 或 `1`。
- `b_group_id` 必须关联同模块的 `s_module_field_group`;分组为空时字段进入默认编辑区域。
- 已分组字段按分组的 `b_xh` 排列,组内按 `b_xh` 排列;默认区域字段按 `b_xh` 排列。
@@ -179,7 +177,7 @@ s_module / s_module_field / s_module_field_list / s_module_field_group / s_modul
- `b_module_id` 外键引用 `s_module.b_id`;`b_id` 是分组的稳定关联键。
- `b_parent_id` 必须引用同模块的分组且不能形成循环。
- 多级分组可用于 FmsTable 表头;FmsForm 与 FmsTable 如何消费编辑分组由各自实现决定。
- `b_canuse` 只能为 `0` 或 `1`。建议建立 `(b_module_id, b_canuse, b_xh, b_id)` 索引。
- 建议建立 `(b_module_id, b_canuse, b_xh, b_id)` 索引。
- 无字段引用的分组不渲染;分组停用时,其关联字段或列不显示。
视图型选择器的 `b_options` 示例:
@@ -216,7 +214,6 @@ s_module / s_module_field / s_module_field_list / s_module_field_group / s_modul
约束:
- `b_module_id` 外键引用 `s_module.b_id`,`b_field_id` 外键引用 `s_module_field.b_id`;`(b_module_id, b_field_id)` 唯一,保存服务校验模块一致性。
- `b_canuse` 只能为 `0` 或 `1`;`b_operator` 必须属于定义的操作符集合。
- 查询组件与操作符由后端白名单校验;查询值必须参数化,不能由配置直接拼接 SQL。
- 建议建立 `(b_module_id, b_canuse, b_xh, b_id)` 索引。
@@ -236,7 +233,7 @@ s_module / s_module_field / s_module_field_list / s_module_field_group / s_modul
| `b_xh` | `int` | 显示顺序 |
| `b_canuse` | `int` | 是否启用,默认 `1` |
约束:`b_module_id` 外键引用 `s_module.b_id`,`(b_module_id, b_code)` 唯一,`b_canuse` 只能为 `0` 或 `1`。
约束:`b_module_id` 外键引用 `s_module.b_id`,`(b_module_id, b_code)` 唯一。
### `dbo.b_user_module`
+2 -2
View File
@@ -39,7 +39,7 @@ fms-api/config/migrations/003_module_surrogate_key.sql
脚本包含:
- 主键、外键、唯一约束和布尔字段 `0/1` 检查约束。
- 主键、外键和唯一约束。
- 模块树、字段列表和个人权限所需索引。
- `XACT_ABORT ON` 和显式事务。
- `IF NOT EXISTS`,保证脚本可以重复执行。
@@ -148,7 +148,7 @@ b_i18n
- 同一模块字段名和按钮编码不重复。
- 表名、视图名和字段名只允许合法数据库标识符。
数据库外键、唯一约束和检查约束负责最后兜底,任一表失败时由现有 `DataSaveService` 回滚整个请求。
数据库外键和唯一约束负责最后兜底,任一表失败时由现有 `DataSaveService` 回滚整个请求。
### 模块删除