Files
workspace/code/fms/fms-vue/src/views/module-management/index.vue
T
2026-08-07 17:30:51 +08:00

1443 lines
45 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, h, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { message, Modal } from "antdv-next";
import { Plus, Trash2 } from "@lucide/vue";
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 { usePermissionStore } from "@/stores/permissions";
import {
cloneData,
diffRows,
allocateTemporaryIds,
remapForeignKeys,
tableChange,
hasChanges,
} from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
import { buildTree } from "@/utils/tree";
import {
buildModuleI18nKey,
buildModuleItemI18nKey,
syncModuleItemI18nKeys,
validateModuleCode,
} from "@/utils/i18n";
import ModuleBasicPanel from "./components/ModuleBasicPanel.vue";
import ModuleFieldsPanel from "./components/ModuleFieldsPanel.vue";
import ModuleListConfigPanel from "./components/ModuleListConfigPanel.vue";
import ModuleEditConfigPanel from "./components/ModuleEditConfigPanel.vue";
import ModuleQueryConfigPanel from "./components/ModuleQueryConfigPanel.vue";
import ModuleAutoCodePanel from "./components/ModuleAutoCodePanel.vue";
import ModulePowersPanel from "./components/ModulePowersPanel.vue";
import ModuleI18nPanel from "./components/ModuleI18nPanel.vue";
import { createAutoCodeDraft } from "./common";
// ---- 状态 ----
const appStore = useAppStore();
const permissionStore = usePermissionStore();
const treeData = ref([]);
const treeLoading = ref(false);
const expandedTreeKeys = ref([]);
const selectedModuleId = ref(null);
const skipLoad = ref(false);
const activeTab = ref("basic");
const treeSearch = ref("");
function filterTree(nodes, keyword) {
if (!keyword) return nodes;
const kw = keyword.toLowerCase();
const result = [];
for (const node of nodes) {
const title = (node.b_name || node.b_code || node.title || "").toLowerCase();
const children = filterTree(node.children || [], kw);
if (title.includes(kw) || children.length) {
result.push({ ...node, children });
}
}
return result;
}
const filteredTreeData = computed(() => {
return filterTree(treeData.value, treeSearch.value);
});
function buildTreeContextMenu(node) {
const items = [];
const iconSize = 14;
if (!node) return { items };
if (node.isFmsRoot) {
if (canManage("create"))
items.push({
label: "添加目录",
key: "add-directory",
icon: h(Plus, { size: iconSize }),
});
return { items };
}
if (node.b_module_type === "directory") {
if (canManage("create")) {
items.push(
{ label: "添加目录", key: "add-directory", icon: h(Plus, { size: iconSize }) },
{ label: "添加页面", key: "add-page", icon: h(Plus, { size: iconSize }) },
);
}
} else if (["page", "data_category"].includes(node.b_module_type)) {
if (canManage("create")) {
items.push(
{ label: "添加数据分类", key: "add-data_category", icon: h(Plus, { size: iconSize }) },
{ label: "添加数据", key: "add-data", icon: h(Plus, { size: iconSize }) },
);
}
}
if (canManage("delete")) {
if (items.length) items.push({ type: "divider" });
items.push({ label: "删除", key: "delete", danger: true, icon: h(Trash2, { size: iconSize }) });
}
return { items };
}
function canManage(actionCode) {
return permissionStore.canPower("m_metadata", actionCode);
}
// 原始数据(加载时保存,用于 diff)
const originals = ref({
module: null,
fields: [],
groups: [],
listConfig: [],
editConfig: [],
queryConfig: [],
autoCode: [],
powers: [],
});
// 编辑草稿
const drafts = ref({
module: null,
fields: [],
groups: [],
listConfig: [],
editConfig: [],
queryConfig: [],
autoCode: [],
powers: [],
});
const configLoading = ref(false);
const configSaving = ref(false);
const configSyncing = ref(false);
const i18nPanelRef = ref(null);
const loadedModuleId = ref(null);
// 使用函数 ref,避免组件位于 TabPane 的 v-for 内时被 Vue 收集成实例数组。
function setI18nPanelRef(instance) {
i18nPanelRef.value = instance || null;
}
// 当前选中模块对象(从 drafts.module 获取)
const selectedModule = computed(() => drafts.value.module);
const autoCodeEnabled = computed(() => Number(drafts.value.autoCode?.[0]?.b_canuse) === 1);
const busy = computed(() => configLoading.value || configSaving.value || configSyncing.value);
const syncDisabled = computed(
() => busy.value || !String(selectedModule.value?.b_viewtable || "").trim(),
);
/* ---- 左侧面板拖拽调整宽度 — 抄 AppSidebar 的 sidebar-resize-handle ---- */
const MIN_SIDEBAR = 210;
const MAX_SIDEBAR = 440;
const leftPanelWidth = ref(260);
const isResizing = ref(false);
let resizeStartX = 0;
let resizeStartWidth = 0;
function onResizeStart(e) {
e.preventDefault();
isResizing.value = true;
resizeStartX = e.clientX;
resizeStartWidth = leftPanelWidth.value;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}
function onResizeMove(e) {
if (!isResizing.value) return;
const delta = e.clientX - resizeStartX;
leftPanelWidth.value = Math.min(MAX_SIDEBAR, Math.max(MIN_SIDEBAR, resizeStartWidth + delta));
}
function onResizeEnd() {
if (!isResizing.value) return;
isResizing.value = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
onMounted(() => {
document.addEventListener("mousemove", onResizeMove);
document.addEventListener("mouseup", onResizeEnd);
});
onBeforeUnmount(() => {
document.removeEventListener("mousemove", onResizeMove);
document.removeEventListener("mouseup", onResizeEnd);
});
// 构建受管多语言项。managedSource 提供模块/字段/权限的受管键来源,
// plainSource 提供分组/列表标题等非受管键来源(手动填写,需即时反馈)。
function buildI18nItems(managedSource, plainSource) {
const items = new Map();
const add = (item) => {
if (!item.key) return;
const key = String(item.key);
const existing = items.get(key);
items.set(key, existing?.managed ? existing : { ...item, key });
};
const module = managedSource.module;
if (module?.b_code) {
const moduleKey = buildModuleI18nKey(module.b_module_type, module.b_code);
add({
id: `module:${module.b_id}`,
key: moduleKey,
sourceKey: module.b_i18n,
defaultValue: module.b_name || module.b_code,
managed: true,
});
}
for (const field of managedSource.fields || []) {
add({
id: `field:${field.b_id}`,
key: buildModuleItemI18nKey("field", module?.b_code, field.b_field),
sourceKey: field.b_i18n,
defaultValue: field.b_name || field.b_field,
managed: true,
});
}
for (const power of managedSource.powers || []) {
add({
id: `power:${power.b_id}`,
key: buildModuleItemI18nKey("power", module?.b_code, power.b_code),
sourceKey: power.b_i18n,
defaultValue: power.b_name || power.b_code,
managed: true,
});
}
// 分组和列表标题仍显示在本模块的多语言面板中,但不是系统生成的受管键。
for (const section of ["groups", "listConfig"]) {
for (const row of plainSource[section] || []) {
add({ key: row.b_i18n, managed: false });
}
}
return [...items.values()];
}
// 实时展示:受管键基于已保存快照(originals),编辑过程中不随草稿实时生成;
// 非受管键(分组/列表标题)基于草稿,保留手动编辑的即时反馈。
const i18nItems = computed(() => buildI18nItems(originals.value, drafts.value));
// 保存时:受管键基于最终草稿态(drafts),此时才按模块编码生成并落库。
const finalI18nItems = computed(() => buildI18nItems(drafts.value, drafts.value));
const referencedGroupIds = computed(() => {
const ids = new Set();
for (const section of ["listConfig", "editConfig"]) {
for (const row of drafts.value[section] || []) {
if (row.b_group_id != null) ids.add(String(row.b_group_id));
}
}
return [...ids];
});
// 上级模块名称(从模块树解析 b_parent_id 对应的名称,用于基本信息展示)
const parentModuleName = computed(() => {
const pid = selectedModule.value?.b_parent_id;
if (!pid) return "";
const find = (nodes) => {
for (const n of nodes || []) {
if (String(n.b_id) === String(pid)) return n.b_name || n.b_code;
const child = find(n.children);
if (child) return child;
}
return "";
};
return find(treeData.value);
});
// 可见 Tab
const visibleTabs = computed(() => {
const type = selectedModule.value?.b_module_type;
if (!type) return [];
const tabs = [{ key: "basic", tab: "基本信息" }];
if (type === "data") {
tabs.push(
{ key: "fields", tab: "字段定义" },
{ key: "list", tab: "列表配置" },
{ key: "edit", tab: "编辑配置" },
{ key: "query", tab: "查询配置" },
{ key: "auto-code", tab: "自动编码" },
);
}
if (type === "page") {
tabs.push({ key: "powers", tab: "权限配置" });
}
if (type !== "directory") {
tabs.push({ key: "i18n", tab: "多语言" });
}
return tabs;
});
// ---- 模块树 ----
function buildModuleTree(modules) {
modules.forEach((m) => {
m.key = String(m.b_id);
m.title = m.b_name || m.b_code;
m.b_parent_id = m.b_parent_id != null ? String(m.b_parent_id) : null;
});
const tree = buildTree(modules);
return tree;
}
// 普通保存后直接更新现有节点,避免重新请求并重建整棵树导致展开状态丢失。
function updateSavedTreeNode(moduleData) {
let node = findNode(moduleData.b_id);
if (!node) {
node = { ...cloneData(moduleData), children: [] };
const parent = moduleData.b_parent_id ? findNode(moduleData.b_parent_id) : null;
if (parent) {
if (!parent.children) parent.children = [];
parent.children.push(node);
} else {
treeData.value.push(node);
}
}
const children = node.children;
Object.assign(node, cloneData(moduleData), {
key: String(moduleData.b_id),
title: moduleData.b_name || moduleData.b_code,
});
if (children) node.children = children;
}
async function loadTree() {
treeLoading.value = true;
try {
const res = await loadDataApi("s_module", "b_canuse = 1", "b_xh ASC, b_id ASC");
treeData.value = buildModuleTree(res.data || []);
unsavedModuleIds.clear();
} finally {
treeLoading.value = false;
}
}
const unsavedModuleIds = new Set();
// ---- 模块配置加载 ----
function waitForNextPaint() {
if (typeof requestAnimationFrame !== "function") return Promise.resolve();
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
}
async function loadModuleConfig(moduleId, options = {}) {
const id = String(moduleId);
const applyReady = options.applyReady;
if (options.waitForPaint) await waitForNextPaint();
if (String(selectedModuleId.value) !== id) return;
configLoading.value = true;
try {
const moduleType = findNode(id)?.b_module_type;
let moduleResponse;
let fieldsResponse;
let groupsResponse;
let listResponse;
let editResponse;
let queryResponse;
let autoCodeResponse;
let powersResponse;
if (moduleType === "data") {
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"),
loadDataApi("s_module_auto_code", `b_module_id = ${id}`, "b_id ASC"),
]);
moduleResponse = responses[0];
fieldsResponse = responses[1];
groupsResponse = responses[2];
listResponse = responses[3];
editResponse = responses[4];
queryResponse = responses[5];
autoCodeResponse = responses[6];
} else if (moduleType === "page") {
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}`);
}
if (String(selectedModuleId.value) !== id) return;
if (applyReady && !(await applyReady)) return;
if (String(selectedModuleId.value) !== id) return;
const original = {
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 || [] : [],
autoCode: autoCodeResponse ? autoCodeResponse.data || [] : [],
powers: powersResponse ? powersResponse.data || [] : [],
};
originals.value = original;
drafts.value = cloneData(original);
loadedModuleId.value = id;
activeTab.value = "basic";
} catch (e) {
if (String(selectedModuleId.value) === String(moduleId)) {
loadedModuleId.value = null;
drafts.value.module = null;
message.error("模块配置加载失败:" + (e.message || e));
}
} finally {
if (String(selectedModuleId.value) === String(moduleId)) {
configLoading.value = false;
}
}
}
// ---- 树节点选择 ----
function onTreeSelect(keys, info) {
if (busy.value) return;
if (info?.node?.isFmsRoot) {
selectedModuleId.value = null;
return;
}
const nodeKey = info?.node?.key ?? keys[0];
if (nodeKey == null) return;
selectedModuleId.value = String(nodeKey);
}
watch(selectedModuleId, (id) => {
if (!id) {
configLoading.value = false;
loadedModuleId.value = null;
drafts.value.module = null;
return;
}
if (skipLoad.value) {
skipLoad.value = false;
configLoading.value = false;
return;
}
loadedModuleId.value = null;
void loadModuleConfig(id);
});
// ---- 子面板数据变更 ----
function onUpdate(section, newData) {
drafts.value[section] = newData;
}
function isFieldEnabled(field) {
if (!field) return false;
return Number(field.b_canuse) === 1;
}
function applyDisabledFieldDefaults(previousFields, nextFields) {
const previousEnabledStates = new Map();
for (const field of previousFields || []) {
if (field.b_id != null) {
previousEnabledStates.set(String(field.b_id), isFieldEnabled(field));
}
}
const disabledFieldIds = new Set();
for (const field of nextFields || []) {
if (field.b_id == null) continue;
const fieldId = String(field.b_id);
if (previousEnabledStates.get(fieldId) && !isFieldEnabled(field)) {
disabledFieldIds.add(fieldId);
}
}
if (disabledFieldIds.size === 0) return;
for (const row of drafts.value.listConfig || []) {
if (disabledFieldIds.has(String(row.b_field_id))) row.b_visible = 0;
}
for (const row of drafts.value.editConfig || []) {
if (disabledFieldIds.has(String(row.b_field_id))) row.b_visible = 0;
}
for (const row of drafts.value.queryConfig || []) {
if (disabledFieldIds.has(String(row.b_field_id))) row.b_canuse = 0;
}
}
function onFieldsUpdate(newFields) {
applyDisabledFieldDefaults(drafts.value.fields, newFields);
drafts.value.fields = newFields;
}
function onAutoCodeUpdate(rows) {
const previousRule = drafts.value.autoCode?.[0];
const previousFieldId =
previousRule && Number(previousRule.b_canuse) === 1 ? previousRule.b_field_id : null;
drafts.value.autoCode = rows;
const rule = rows && rows.length ? rows[0] : null;
const nextFieldId = rule && Number(rule.b_canuse) === 1 ? rule.b_field_id : null;
if (previousFieldId != null && String(previousFieldId) !== String(nextFieldId)) {
const original = (originals.value.editConfig || []).find(
(config) => String(config.b_field_id) === String(previousFieldId),
);
for (const config of drafts.value.editConfig || []) {
if (String(config.b_field_id) === String(previousFieldId)) {
config.b_readonly = Number(original?.b_readonly) === 1 ? 1 : 0;
}
}
}
if (nextFieldId == null) return;
for (const config of drafts.value.editConfig || []) {
if (String(config.b_field_id) === String(nextFieldId)) {
config.b_readonly = 1;
}
}
}
function setAutoCodeEnabled(enabled) {
const current = drafts.value.autoCode?.[0];
const rule = current
? { ...current, b_canuse: enabled ? 1 : 0 }
: createAutoCodeDraft(selectedModule.value?.b_id, drafts.value.fields, enabled);
onAutoCodeUpdate([rule]);
}
function validateAutoCode() {
const rows = drafts.value.autoCode || [];
const rule = rows.length ? rows[0] : null;
if (!rule || Number(rule.b_canuse) !== 1) return "";
if (!String(selectedModule.value?.b_savetable || "").trim()) {
return "启用自动编码前请先配置保存表";
}
const field = (drafts.value.fields || []).find(
(item) => String(item.b_id) === String(rule.b_field_id),
);
if (!field || Number(field.b_canuse) !== 1) return "请选择已启用的业务编号字段";
if (field.b_type !== "input" && field.b_type !== "textarea") {
return "业务编号字段必须使用文本控件类型";
}
if (!Number.isInteger(Number(rule.b_start_value)) || Number(rule.b_start_value) < 0) {
return "起始值必须是大于或等于 0 的整数";
}
if (!["none", "year", "month", "day"].includes(rule.b_reset_type)) {
return "请选择正确的重置周期";
}
const width = Number(rule.b_sequence_width);
if (!Number.isInteger(width) || width < 1 || width > 18) {
return "流水号位数必须是 1 到 18 之间的整数";
}
if (String(rule.b_prefix || "").length > 100) return "固定前缀长度不能超过 100 个字符";
if (String(rule.b_separator || "").length > 10) return "分隔符长度不能超过 10 个字符";
const dateFormat = String(rule.b_date_format || "").trim();
if (dateFormat.length > 30) return "日期格式长度不能超过 30 个字符";
const dateLiterals = dateFormat
.replaceAll("yyyy", "")
.replaceAll("yy", "")
.replaceAll("MM", "")
.replaceAll("dd", "");
if (/[A-Za-z]/.test(dateLiterals)) return "日期格式只支持 yyyy、yy、MM 和 dd";
const hasYear = dateFormat.includes("yyyy") || dateFormat.includes("yy");
const hasMonth = dateFormat.includes("MM");
const hasDay = dateFormat.includes("dd");
if (rule.b_reset_type === "year" && !hasYear) return "按年重置时,日期格式必须包含年份";
if (rule.b_reset_type === "month" && (!hasYear || !hasMonth)) {
return "按月重置时,日期格式必须包含年份和月份";
}
if (rule.b_reset_type === "day" && (!hasYear || !hasMonth || !hasDay)) {
return "按日重置时,日期格式必须包含年月日";
}
for (const config of drafts.value.editConfig || []) {
if (String(config.b_field_id) === String(rule.b_field_id)) config.b_readonly = 1;
}
return "";
}
function onSetField(field, value) {
if (drafts.value.module) {
drafts.value.module[field] = value;
}
}
// 分组管理已自行保存到后端,同步更新 originals 和 drafts
function onGroupsSaved(newGroups, idMapping) {
if (idMapping && idMapping.size) {
remapForeignKeys(drafts.value.listConfig || [], ["b_group_id"], idMapping);
remapForeignKeys(drafts.value.editConfig || [], ["b_group_id"], idMapping);
}
originals.value.groups = cloneData(newGroups);
drafts.value.groups = cloneData(newGroups);
}
function onI18nSaved(key) {
if (i18nPanelRef.value) {
void i18nPanelRef.value.refreshKey(key);
}
}
// ---- 新增模块 ----
async function onAddModule(parentId, moduleType = "directory") {
if (!canManage("create")) return;
const res = await nextIdApi();
const newId = String(res.data);
const newModule = {
b_id: newId,
b_code: "",
b_parent_id: parentId ? String(parentId) : null,
b_name: "",
b_i18n: null,
b_module_type: moduleType,
b_route: null,
b_viewtable: null,
b_savetable: null,
b_keyfield: null,
b_orderfield: null,
b_canmenu: 0,
b_canuse: 1,
b_xh: 0,
};
// 更新 originals 和 drafts
originals.value = {
module: cloneData(newModule),
fields: [],
groups: [],
listConfig: [],
editConfig: [],
queryConfig: [],
autoCode: [],
powers: [],
};
drafts.value = cloneData(originals.value);
skipLoad.value = true;
selectedModuleId.value = newId;
loadedModuleId.value = newId;
activeTab.value = "basic";
unsavedModuleIds.add(newId);
updateSavedTreeNode(newModule);
if (parentId && !expandedTreeKeys.value.includes(String(parentId))) {
expandedTreeKeys.value = [...expandedTreeKeys.value, String(parentId)];
}
}
// ---- 右键菜单 ----
function onTreeMenuClick({ key, node }) {
if (busy.value) return;
if (key.startsWith("add-")) {
const parentId = node.isFmsRoot ? null : node.key;
onAddModule(parentId, key.replace("add-", ""));
} else if (key === "delete") {
if (hasChildNode(node.key)) {
Modal.warning({
title: "无法删除",
content: "该节点下还有子节点,请先删除子节点后再删除当前节点。",
});
return;
}
Modal.confirm({
title: `确定删除「${node.title}」?`,
content: "此操作不可恢复",
okText: "删除",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => onDeleteNodeById(node.key),
});
}
}
function findNode(key) {
const walk = (nodes) => {
for (const n of nodes || []) {
if (String(n.b_id) === String(key)) return n;
const child = walk(n.children);
if (child) return child;
}
return null;
};
return walk(treeData.value);
}
function hasChildNode(moduleId) {
const node = findNode(moduleId);
return Boolean(node && node.children && node.children.length);
}
async function onDeleteNodeById(moduleId) {
if (!canManage("delete")) return;
if (hasChildNode(moduleId)) {
Modal.warning({
title: "无法删除",
content: "该节点下还有子节点,请先删除子节点后再删除当前节点。",
});
return;
}
try {
const id = String(moduleId);
const deleteByModuleId = (table) => ({
table,
key_field: "b_module_id",
deletes: [{ b_module_id: id }],
});
await saveObjectApi([
deleteByModuleId("b_user_module"),
deleteByModuleId("b_user_power"),
deleteByModuleId("s_module_field_list"),
deleteByModuleId("s_module_field_edit"),
deleteByModuleId("s_module_field_query"),
deleteByModuleId("s_module_auto_code"),
deleteByModuleId("s_module_power"),
deleteByModuleId("s_module_field"),
deleteByModuleId("s_module_field_group"),
deleteByModuleId("b_i18n"),
{ table: "s_module", key_field: "b_id", deletes: [{ b_id: id }] },
]);
message.success("模块已删除");
if (selectedModuleId.value === moduleId) {
selectedModuleId.value = null;
drafts.value.module = null;
}
await loadTree();
} catch (e) {
message.error("删除失败:" + (e.message || e));
}
}
// ---- 统一保存 ----
async function saveAll() {
if (!canManage("update") || !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);
configSaving.value = true;
try {
const saveReqs = [];
const autoCodeError = validateAutoCode();
if (autoCodeError) {
message.warning(autoCodeError);
return;
}
// s_module 主表
const moduleData = drafts.value.module;
const moduleOriginal = originals.value.module;
const moduleCode = String(moduleData.b_code || "").trim();
if (!moduleCode) {
if (moduleData.b_module_type === "data") {
message.warning("请填写模块编码");
} else if (moduleData.b_module_type === "data_category") {
message.warning("请填写分类编码");
} else {
message.warning("请填写菜单编码");
}
return;
}
const codeError = validateModuleCode(moduleCode);
if (codeError) {
message.error(`编码格式不正确:${codeError}`);
return;
}
if (!String(moduleData.b_name || "").trim()) {
message.warning("请填写模块名称");
return;
}
if (moduleData.b_module_type === "page" && !String(moduleData.b_route || "").trim()) {
message.warning("请填写页面路由");
return;
}
if (moduleData.b_module_type === "data" && !String(moduleData.b_viewtable || "").trim()) {
message.warning("请填写查询视图");
return;
}
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 = unsavedModuleIds.has(moduleId);
if (moduleData && (isNew || JSON.stringify(moduleData) !== JSON.stringify(moduleOriginal))) {
const moduleRequest = {
table: "s_module",
key_field: "b_id",
};
if (isNew) {
moduleRequest.inserts = [moduleData];
} else {
moduleRequest.updates = [moduleData];
}
saveReqs.push(moduleRequest);
}
// 子表:fields, listConfig, editConfig, queryConfig, autoCode, powers
const subTables = [
["s_module_field", "fields"],
["s_module_field_list", "listConfig"],
["s_module_field_edit", "editConfig"],
["s_module_field_query", "queryConfig"],
["s_module_auto_code", "autoCode"],
["s_module_power", "powers"],
];
for (const [table, section] of subTables) {
const current = drafts.value[section] || [];
const previous = originals.value[section] || [];
const changes = diffRows(current, previous);
if (table === "s_module_auto_code") {
changes.updates = changes.updates.map((row) => {
const { b_current_period: _period, b_current_value: _value, ...config } = row;
return config;
});
}
const req = tableChange(table, changes);
if (hasChanges(req)) {
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
if (table === "s_module_power" && changes.deletes.length) {
saveReqs.push({
table: "b_user_power",
key_field: "b_power_id",
deletes: changes.deletes.map(({ b_id }) => ({ b_power_id: String(b_id) })),
});
}
saveReqs.push(req);
}
}
// 多语言:由各子面板收集变更,统一在此保存
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 (i18nPanel && typeof i18nPanel.buildSaveRequest === "function") {
const i18nReq = await i18nPanel.buildSaveRequest();
if (i18nReq) saveReqs.push(i18nReq);
}
if (saveReqs.length === 0) {
message.info("没有需要保存的变更");
return;
}
await saveObjectApi(saveReqs);
// 更新 originals
originals.value = cloneData(drafts.value);
if (i18nPanelRef.value) {
i18nPanelRef.value.markSaved();
}
unsavedModuleIds.delete(moduleId);
updateSavedTreeNode(moduleData);
if (appStore.locale) await appStore.loadTranslations(appStore.locale);
// 刷新权限数据,让菜单立即反映模块结构变更
permissionStore.load().catch(() => {});
message.success("保存成功");
} catch (e) {
message.error("保存失败:" + (e.message || e));
} finally {
configSaving.value = false;
}
}
// ---- 字段同步与默认配置 ----
function configuredFieldIds(section) {
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) {
const moduleId = String(module.b_id);
const listFieldIds = configuredFieldIds("listConfig");
const editFieldIds = configuredFieldIds("editConfig");
const queryFieldIds = configuredFieldIds("queryConfig");
const generated = { listConfig: [], editConfig: [], queryConfig: [] };
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 (!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_visible: 1,
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,
});
}
}
return generated;
}
async function syncFields() {
const module = selectedModule.value;
if (!module) return;
const view = String(module.b_viewtable || "").trim();
if (!view || busy.value) return;
const moduleId = String(module.b_id);
try {
configSyncing.value = true;
const res = await describeApi(view);
if (!selectedModule.value || String(selectedModule.value.b_id) !== moduleId) return;
const columns = res.data || [];
const existingFields = drafts.value.fields || [];
// 构建视图列名集合
const viewColumnSet = new Set();
for (const column of columns) {
viewColumnSet.add(column.name.toLowerCase());
}
// 已有字段映射(按字段名)
const existingMap = new Map();
for (const field of existingFields) {
if (!field.b_field) continue;
existingMap.set(field.b_field.toLowerCase(), field);
}
const mergedFields = [];
// 遍历视图列
for (const col of columns) {
const existing = existingMap.get(col.name.toLowerCase());
if (existing) {
// 已有字段:保留原配置
mergedFields.push({ ...existing, b_canuse: 1 });
} else {
// 新字段:创建默认记录
mergedFields.push({
b_id: createTempId(),
b_module_id: String(module.b_id),
b_field: col.name,
b_name: col.name,
b_i18n: null,
b_type: "input",
b_default_value: null,
b_options: null,
b_canuse: 1,
});
}
}
// 视图中不存在的已有字段:从字段定义中移除,并清理其在列表/编辑/查询配置中的残留行。
const removedFieldIds = new Set();
for (const existing of existingFields) {
const fieldName = existing.b_field ? existing.b_field.toLowerCase() : "";
if (!viewColumnSet.has(fieldName)) {
removedFieldIds.add(String(existing.b_id));
}
}
if (removedFieldIds.size > 0) {
for (const section of ["listConfig", "editConfig", "queryConfig"]) {
drafts.value[section] = (drafts.value[section] || []).filter(
(row) => !removedFieldIds.has(String(row.b_field_id)),
);
}
// 多语言面板中移除旧字段对应的受管翻译行(_managedId 格式为 field:<b_id>)
if (i18nPanelRef.value) {
const managedIds = new Set(Array.from(removedFieldIds).map((id) => `field:${id}`));
i18nPanelRef.value.removeByManagedIds(managedIds);
}
}
// 多语言键始终按 field.<模块代码>.<字段编码> 自动生成。
for (const field of mergedFields) {
field.b_i18n = buildModuleItemI18nKey("field", module.b_code, field.b_field);
}
// 先为新字段分配真实 ID,后续配置通过外键引用这些 ID。
const changes = diffRows(mergedFields, existingFields);
const fieldReq = tableChange("s_module_field", changes);
await allocateTemporaryIds([
fieldReq.inserts || [],
fieldReq.updates || [],
fieldReq.deletes || [],
]);
// 为"有 b_i18n 键"的字段生成多语言默认配置(b_i18n 草稿行),注入 i18n 面板,
// 由顶部"保存"统一落库;已存在的键由面板去重,不覆盖用户配置。
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_default DESC, b_xh ASC, b_id ASC",
);
const langs = langRes.data || [];
if (langs.length) {
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,
});
}
}
applyDisabledFieldDefaults(existingFields, mergedFields);
const generated = buildMissingDefaultConfigs(module, mergedFields);
const generatedSections = ["listConfig", "editConfig", "queryConfig"];
let generatedCount = 0;
for (const section of generatedSections) {
generatedCount += generated[section].length;
}
if (!hasChanges(fieldReq) && !generatedCount && !i18nEntries.length) {
message.success("同步成功");
return;
}
// 多语言默认配置注入 i18n 面板草稿;字段已预分配真实 ID(见上方 allocateTemporaryIds),
// 列表/编辑/查询配置稍后追加到草稿。全部由顶部"保存"统一提交,期间不写库。
if (i18nEntries.length && i18nPanelRef.value) {
i18nPanelRef.value.mergeDefaults(i18nEntries);
}
for (const section of generatedSections) {
if (!generated[section].length) continue;
drafts.value[section] = [...(drafts.value[section] || []), ...cloneData(generated[section])];
}
drafts.value.fields = cloneData(mergedFields);
message.success("同步成功");
} catch (e) {
message.error("字段同步失败:" + (e.message || e));
} finally {
configSyncing.value = false;
}
}
// ---- 初始化 ----
void permissionStore
.load()
.then(loadTree)
.catch((e) => message.error("模块树加载失败:" + (e.message || e)));
</script>
<template>
<div class="mm-page">
<!-- 左侧模块树 -->
<aside class="mm-sidebar" :style="{ width: leftPanelWidth + 'px' }">
<div class="mm-tree-search">
<a-input-search
v-model:value="treeSearch"
allow-clear
/>
</div>
<div class="mm-tree-stage">
<div v-if="treeLoading" class="mm-tree-empty">
<a-spin />
</div>
<div v-else class="mm-tree-scroll">
<FmsTree
:disabled="configSaving"
:tree-data="filteredTreeData"
v-model:expanded-keys="expandedTreeKeys"
:selected-keys="selectedModuleId ? [selectedModuleId] : []"
:context-menu="buildTreeContextMenu"
:context-menu-disabled="busy"
@select="onTreeSelect"
@menu-click="onTreeMenuClick"
>
<template #title="node">
<span class="mm-tree-node">
<span>{{
appStore.t(node.b_i18n, node.b_name || node.b_code || node.title)
}}</span>
</span>
</template>
</FmsTree>
</div>
</div>
</aside>
<div class="mm-resize-handle" @mousedown="onResizeStart" />
<!-- 右侧详情 -->
<main class="mm-main">
<FmsLoadingOverlay :loading="configLoading">
<template v-if="selectedModule">
<!-- 顶部栏:tabs + 开关 + 操作 -->
<div class="mm-tab-bar">
<nav class="mm-tab-nav">
<button
v-for="tab in visibleTabs"
:key="tab.key"
type="button"
class="mm-tab-nav__item"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.tab }}
</button>
</nav>
<div class="mm-tab-bar__right">
<label class="mm-switch"><span>启用</span><a-checkbox
:checked="drafts.module?.b_canuse === 1"
:disabled="busy"
@update:checked="(v) => onSetField('b_canuse', v ? 1 : 0)"
/></label>
<template v-if="drafts.module?.b_module_type !== 'data'">
<label class="mm-switch"><span>显示菜单</span><a-checkbox
:checked="drafts.module?.b_canmenu === 1"
:disabled="busy || ['data', 'data_category'].includes(drafts.module?.b_module_type)"
@update:checked="(v) => onSetField('b_canmenu', v ? 1 : 0)"
/></label>
</template>
<a-button
v-if="canManage('update')"
type="primary"
:loading="configSaving"
:disabled="configLoading || configSyncing"
@click="saveAll"
>
保存
</a-button>
</div>
</div>
<!-- 面板内容 -->
<div class="mm-body">
<ModuleBasicPanel
v-if="activeTab === 'basic'"
:module-id="String(selectedModule.b_id)"
:data="drafts.module"
:parent-name="parentModuleName"
:loading="busy"
@update="(v) => onUpdate('module', v)"
@i18n-saved="onI18nSaved"
/>
<ModuleFieldsPanel
v-else-if="activeTab === 'fields'"
:module-id="String(selectedModule.b_id)"
:module-code="selectedModule?.b_code || ''"
:fields="drafts.fields"
:loading="busy"
@update="onFieldsUpdate"
@i18n-saved="onI18nSaved"
@sync-fields="syncFields"
/>
<ModuleListConfigPanel
v-else-if="activeTab === 'list'"
:module-id="String(selectedModule.b_id)"
:list-config="drafts.listConfig"
:edit-config="drafts.editConfig"
:query-config="drafts.queryConfig"
:fields="drafts.fields"
:groups="drafts.groups"
:referenced-group-ids="referencedGroupIds"
:loading="busy"
@update="(v) => onUpdate('listConfig', v)"
@groups-saved="onGroupsSaved"
@sync-edit="(v) => onUpdate('editConfig', v)"
@sync-query="(v) => onUpdate('queryConfig', v)"
/>
<ModuleEditConfigPanel
v-else-if="activeTab === 'edit'"
:module-id="String(selectedModule.b_id)"
:edit-config="drafts.editConfig"
:list-config="drafts.listConfig"
:query-config="drafts.queryConfig"
:fields="drafts.fields"
:groups="drafts.groups"
:referenced-group-ids="referencedGroupIds"
:loading="busy"
@update="(v) => onUpdate('editConfig', v)"
@groups-saved="onGroupsSaved"
@sync-list="(v) => onUpdate('listConfig', v)"
@sync-query="(v) => onUpdate('queryConfig', v)"
/>
<ModuleQueryConfigPanel
v-else-if="activeTab === 'query'"
:module-id="String(selectedModule.b_id)"
:query-config="drafts.queryConfig"
:fields="drafts.fields"
:loading="busy"
@update="(v) => onUpdate('queryConfig', v)"
/>
<ModuleAutoCodePanel
v-else-if="activeTab === 'auto-code'"
:module-id="String(selectedModule.b_id)"
:auto-code="drafts.autoCode"
:fields="drafts.fields"
:loading="busy"
@update="onAutoCodeUpdate"
/>
<ModulePowersPanel
v-else-if="activeTab === 'powers'"
:module-id="String(selectedModule.b_id)"
:module-code="selectedModule?.b_code || ''"
:powers="drafts.powers"
:loading="busy"
@update="(v) => onUpdate('powers', v)"
@i18n-saved="onI18nSaved"
/>
<ModuleI18nPanel
v-else-if="activeTab === 'i18n'"
:ref="setI18nPanelRef"
:module-id="String(selectedModule.b_id)"
:i18n-items="i18nItems"
:loading="busy"
/>
</div>
</template>
<!-- 未选中模块 -->
<div v-else class="mm-empty">
<a-result status="info" title="请选择模块" sub-title="从左侧树选择一个模块查看详情" />
</div>
</FmsLoadingOverlay>
</main>
</div>
</template>
<style scoped lang="scss">
.mm-page {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
background: #ffffff;
}
/* 拖拽手柄 — 抄 AppSidebar 的 sidebar-resize-handle */
.mm-resize-handle {
flex: none;
width: 4px;
cursor: col-resize;
background: transparent;
transition: background-color 0.15s ease;
&:hover {
background: rgb(0 0 0 / 6%);
}
}
.mm-sidebar {
display: flex;
flex: none;
height: 100%;
min-width: 0;
min-height: 0;
flex-direction: column;
background: var(--fms-surface);
}
.mm-tree-search {
flex: none;
padding: 8px 0;
}
.mm-tree-stage {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
flex-direction: column;
padding-bottom: 8px;
}
.mm-tree-scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.mm-tree-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.mm-tree-node {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 4px;
user-select: none;
}
.mm-main {
display: flex;
flex: 1;
height: 100%;
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: hidden;
background: var(--fms-surface);
}
/* ---- 顶部栏:tabs + 开关 + 操作 ---- */
.mm-tab-bar {
display: flex;
flex: none;
align-items: center;
height: 44px;
padding: 0 20px;
&__right {
display: flex;
flex: none;
align-items: center;
gap: 12px;
}
}
.mm-switch {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #71717a;
cursor: pointer;
}
/* ---- 自定义 tab 导航 ---- */
.mm-tab-nav {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: 2px;
&__item {
display: inline-flex;
height: 32px;
align-items: center;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: #71717a;
cursor: pointer;
font-size: 13px;
transition:
background-color 0.15s ease,
color 0.15s ease;
&:hover {
background: #f4f4f5;
color: #3f3f46;
}
&.active {
background: #f4f4f5;
color: #0a0a0a;
font-weight: 500;
&:hover {
background: #ebebeb;
color: #0a0a0a;
}
}
}
}
.mm-body {
flex: 1;
min-height: 0;
overflow: hidden;
padding: 16px 20px 0;
}
.mm-loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
}
.mm-empty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
</style>