20260811165111

This commit is contained in:
oneao committed 2026-08-11 16:51:11 +08:00
1 parent 5adbf59425
commit ce78653207
34 files changed
+3793 -366

No files matched your search

@@ -1,128 +1,608 @@
<script setup lang="ts">
import { ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
Info, Table2, List, Pencil, Search as SearchIcon, Hash, Shield, Globe,
} from "@lucide/vue";
import { toast } from "vue-sonner";
import { Button } from "@/components/ui/button";
import { useConfirm } from "@/composables/useConfirm";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import FmsTree from "@/components/FmsTree.vue";
import FmsLoadingOverlay from "@/components/FmsLoadingOverlay.vue";
import { loadDataApi, saveObjectApi, nextIdApi, describeApi } from "@/services/api";
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 type { TreeNode, ContextMenuItemDef } from "@/components/FmsTreeNode.vue";
import type { SectionKey, ManagedI18nItem } from "@/types/module";
const expandedKeys = ref<string[]>(["__fms_root__"]);
const selectedKeys = ref<string[]>([]);
const selectedNode = ref<TreeNode | null>(null);
const permissionStore = usePermissionStore();
const { confirm } = useConfirm();
const treeData: TreeNode[] = [
{
key: "sys",
title: "系统管理",
children: [
{
key: "sys_user",
title: "用户管理",
children: [
{ key: "sys_user_list", title: "用户列表" },
{ key: "sys_user_role", title: "角色配置" },
],
},
{
key: "sys_log",
title: "日志管理",
children: [
{ key: "sys_log_login", title: "登录日志" },
{ key: "sys_log_op", title: "操作日志" },
],
},
{ key: "sys_i18n", title: "国际化管理" },
],
},
{
key: "biz",
title: "业务模块",
children: [
{
key: "biz_order",
title: "订单管理",
children: [
{ key: "biz_order_list", title: "订单列表" },
{ key: "biz_order_setting", title: "订单配置" },
],
},
{ key: "biz_product", title: "产品管理" },
],
},
{
key: "report",
title: "报表中心",
children: [
{ key: "report_daily", title: "日报" },
{ key: "report_monthly", title: "月报" },
],
},
];
function buildContextMenu(node: TreeNode): ContextMenuItemDef[] {
return [
{ key: "add_child", label: "新增子节点" },
{ key: "edit", label: "编辑" },
{ key: "delete", label: "删除" },
];
// ---- Tab config ----
interface TabItem { key: SectionKey; label: string; icon: typeof Info }
function sectionTabs(moduleType: unknown): TabItem[] {
const tabs: TabItem[] = [{ key: "basic", label: "基本信息", icon: Info }];
if (moduleType === "data") {
tabs.push(
{ key: "fields", label: "字段定义", icon: Table2 },
{ key: "list", label: "列表配置", icon: List },
{ key: "edit", label: "编辑配置", icon: Pencil },
{ key: "query", label: "查询配置", icon: SearchIcon },
{ key: "auto-code", label: "自动编码", icon: Hash },
);
}
if (moduleType === "page") {
tabs.push({ key: "powers", label: "权限配置", icon: Shield });
}
if (moduleType !== "directory") {
tabs.push({ key: "i18n", label: "多语言", icon: Globe });
}
return tabs;
}
function onSelect(_keys: string[], info: { node: TreeNode }) {
selectedNode.value = info.node;
// ---- State ----
const treeData = ref<Record<string, unknown>[]>([]);
const treeLoading = ref(false);
const expandedTreeKeys = ref<string[]>([]);
const selectedModuleId = ref<string | null>(null);
const skipLoad = ref(false);
const treeSearch = ref("");
function filterTree(nodes: Record<string, unknown>[], keyword: string): Record<string, unknown>[] {
if (!keyword) return nodes;
const kw = keyword.toLowerCase();
return nodes.reduce<Record<string, unknown>[]>((acc, node) => {
const title = String(node.b_name || node.b_code || "").toLowerCase();
const children = filterTree((node.children as Record<string, unknown>[]) || [], kw);
if (title.includes(kw) || children.length) acc.push({ ...node, children });
return acc;
}, []);
}
function onMenuClick({ key, node }: { key: string; node: TreeNode }) {
switch (key) {
case "add_child":
toast.info(`新增子节点 — 父节点: ${node.title}`);
break;
case "edit":
toast.info(`编辑节点: ${node.title}`);
break;
case "delete":
toast.warning(`删除节点: ${node.title}`);
break;
const filteredTreeData = computed(() => filterTree(treeData.value, treeSearch.value));
function buildTreeContextMenu(node: TreeNode): ContextMenuItemDef[] {
const items: ContextMenuItemDef[] = [];
if (!node || node.isFmsRoot) {
if (canManage("create")) items.push({ key: "add-directory", label: "添加目录" });
return items;
}
const type = node.b_module_type as string;
if (type === "directory" && canManage("create")) {
items.push({ key: "add-directory", label: "添加目录" }, { key: "add-page", label: "添加页面" });
} else if (["page", "data_category"].includes(type) && canManage("create")) {
items.push({ key: "add-data_category", label: "添加数据分类" }, { key: "add-data", label: "添加数据" });
}
if (canManage("delete")) items.push({ key: "delete", label: "删除" });
return items;
}
function canManage(actionCode: string): boolean {
return permissionStore.canPower?.("m_metadata", actionCode) ?? false;
}
// ---- Originals / Drafts ----
const originals = ref({
module: null as Record<string, unknown> | null,
fields: [] as Record<string, unknown>[], groups: [] as Record<string, unknown>[],
listConfig: [] as Record<string, unknown>[], editConfig: [] as Record<string, unknown>[],
queryConfig: [] as Record<string, unknown>[], autoCode: [] as Record<string, unknown>[],
powers: [] as Record<string, unknown>[],
});
const drafts = ref(cloneData(originals.value));
const configLoading = ref(false);
const configSaving = ref(false);
const configSyncing = ref(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const i18nPanelRef = ref<any>(null);
const loadedModuleId = ref<string | null>(null);
function setI18nPanelRef(instance: unknown) { i18nPanelRef.value = instance; }
const selectedModule = computed(() => drafts.value.module);
const isDataModule = computed(() => selectedModule.value?.b_module_type === "data");
const isPageModule = computed(() => selectedModule.value?.b_module_type === "page");
const isDirectoryModule = computed(() => selectedModule.value?.b_module_type === "directory");
const moduleTypeLabel = computed(() => {
const map: Record<string, string> = { directory: "DIR", page: "PAGE", data: "DATA", data_category: "CAT" };
return map[String(selectedModule.value?.b_module_type || "")] || "";
});
const activeSection = ref<SectionKey>("basic");
const tabs = computed(() => sectionTabs(selectedModule.value?.b_module_type));
watch(tabs, (list) => {
if (!list.some((t) => t.key === activeSection.value)) activeSection.value = "basic";
});
const busy = computed(() => configLoading.value || configSaving.value || configSyncing.value);
const isDirty = computed(() => {
const m = selectedModule.value;
if (!m) return false;
if (unsavedModuleIds.has(String(m.b_id))) return true;
return JSON.stringify(drafts.value) !== JSON.stringify(originals.value);
});
function buildI18nItems(managedSource: typeof originals.value, plainSource: typeof drafts.value): ManagedI18nItem[] {
const items = new Map<string, ManagedI18nItem>();
const add = (item: ManagedI18nItem) => {
if (!item.key) return;
const existing = items.get(item.key);
items.set(item.key, existing?.managed ? existing : { ...item, key: item.key });
};
const module = managedSource.module;
if (module?.b_code) {
add({ id: `module:${module.b_id}`, key: buildModuleI18nKey(module.b_module_type as string, module.b_code) || "",
sourceKey: module.b_i18n as string, defaultValue: String(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 as string, defaultValue: String(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 as string, defaultValue: String(power.b_name || power.b_code), managed: true });
}
for (const section of ["groups", "listConfig"] as const) {
for (const row of plainSource[section] || []) add({ key: row.b_i18n as string, managed: false });
}
return [...items.values()];
}
const i18nItems = computed(() => buildI18nItems(originals.value, drafts.value));
const finalI18nItems = computed(() => buildI18nItems(drafts.value, drafts.value));
const referencedGroupIds = computed(() => {
const ids = new Set<string>();
for (const s of ["listConfig", "editConfig"] as const)
for (const row of drafts.value[s] || []) if (row.b_group_id != null) ids.add(String(row.b_group_id));
return [...ids];
});
const parentModuleName = computed(() => {
const pid = selectedModule.value?.b_parent_id;
if (!pid) return "";
const find = (nodes: Record<string, unknown>[]): string => {
for (const n of nodes || []) {
if (String(n.b_id) === String(pid)) return String(n.b_name || n.b_code);
const c = find((n.children as Record<string, unknown>[]) || []);
if (c) return c;
}
return "";
};
return find(treeData.value);
});
// ---- Sidebar Resize ----
const leftPanelWidth = ref(260);
const isResizing = ref(false);
let resizeStartX = 0, resizeStartWidth = 0;
function onResizeStart(e: MouseEvent) { 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: MouseEvent) { if (!isResizing.value) return; leftPanelWidth.value = Math.min(440, Math.max(210, resizeStartWidth + (e.clientX - resizeStartX))); }
function onResizeEnd() { if (!isResizing.value) return; isResizing.value = false; document.body.style.cursor = ""; document.body.style.userSelect = ""; }
function onBeforeWindowUnload(e: BeforeUnloadEvent) { if (isDirty.value) { e.preventDefault(); e.returnValue = ""; } }
onMounted(() => { document.addEventListener("mousemove", onResizeMove); document.addEventListener("mouseup", onResizeEnd); window.addEventListener("beforeunload", onBeforeWindowUnload); });
onBeforeUnmount(() => { document.removeEventListener("mousemove", onResizeMove); document.removeEventListener("mouseup", onResizeEnd); window.removeEventListener("beforeunload", onBeforeWindowUnload); });
// ---- Module Tree ----
function buildModuleTree(modules: Record<string, unknown>[]) {
const list = modules.map((m) => ({ ...m, key: String(m.b_id), title: String(m.b_name || m.b_code), b_parent_id: m.b_parent_id != null ? String(m.b_parent_id) : null }));
return buildTree(list as never) as unknown as Record<string, unknown>[];
}
function findNode(key: string): Record<string, unknown> | null {
const walk = (nodes: Record<string, unknown>[]): Record<string, unknown> | null => {
for (const n of nodes || []) { if (String(n.b_id) === String(key)) return n; const c = walk((n.children as Record<string, unknown>[]) || []); if (c) return c; }
return null;
};
return walk(treeData.value);
}
function updateSavedTreeNode(moduleData: Record<string, unknown>) {
let node: Record<string, unknown> | null = findNode(String(moduleData.b_id));
if (!node) {
node = { ...cloneData(moduleData), children: [] };
const parent = moduleData.b_parent_id ? findNode(String(moduleData.b_parent_id)) : null;
if (parent) { if (!parent.children) parent.children = []; (parent.children as Record<string, unknown>[]).push(node); }
else treeData.value.push(node);
}
const children = (node as Record<string, unknown>).children;
Object.assign(node, cloneData(moduleData), { key: String(moduleData.b_id), title: String(moduleData.b_name || moduleData.b_code) });
if (children) (node as Record<string, unknown>).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 || []) as Record<string, unknown>[]);
unsavedModuleIds.clear();
} finally { treeLoading.value = false; }
}
const unsavedModuleIds = new Set<string>();
// ---- Load Module Config ----
async function loadModuleConfig(moduleId: string) {
const id = String(moduleId);
if (String(selectedModuleId.value) !== id) return;
configLoading.value = true;
try {
const node = findNode(id);
const moduleType = node?.["b_module_type"] as string | undefined;
let moduleData: Record<string, unknown>[] = [], fieldsData: Record<string, unknown>[] = [];
let groupsData: Record<string, unknown>[] = [], listData: Record<string, unknown>[] = [], editData: Record<string, unknown>[] = [];
let queryData: Record<string, unknown>[] = [], autoCodeData: Record<string, unknown>[] = [], powersData: Record<string, unknown>[] = [];
if (moduleType === "data") {
const res = 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"),
]);
[moduleData, fieldsData, groupsData, listData, editData, queryData, autoCodeData] = res.map((r) => (r.data || []) as Record<string, unknown>[]);
} else if (moduleType === "page") {
const res = await Promise.all([
loadDataApi("s_module", `b_id = ${id}`),
loadDataApi("s_module_power", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
]);
[moduleData, powersData] = res.map((r) => (r.data || []) as Record<string, unknown>[]);
} else {
const r = await loadDataApi("s_module", `b_id = ${id}`);
moduleData = (r.data || []) as Record<string, unknown>[];
}
if (String(selectedModuleId.value) !== id) return;
originals.value = { module: moduleData[0] || null, fields: fieldsData, groups: groupsData, listConfig: listData, editConfig: editData, queryConfig: queryData, autoCode: autoCodeData, powers: powersData };
drafts.value = cloneData(originals.value);
loadedModuleId.value = id;
activeSection.value = "basic";
} catch (e: unknown) {
if (String(selectedModuleId.value) === String(moduleId)) { loadedModuleId.value = null; drafts.value.module = null; toast.error("模块配置加载失败:" + ((e as Error).message || e)); }
} finally {
if (String(selectedModuleId.value) === String(moduleId)) configLoading.value = false;
}
}
async function onTreeSelect(keys: string[], info?: { node?: TreeNode }) {
if (busy.value) return;
if (info?.node?.isFmsRoot) {
if (isDirty.value && !(await confirm({ title: "放弃未保存的修改", description: "当前模块有未保存的修改,切换到根节点将丢弃这些修改。", cancelText: "取消", confirmText: "仍然切换", danger: true }))) return;
selectedModuleId.value = null; return;
}
const nk = info?.node?.key ?? keys[0];
if (nk == null) return;
if (String(nk) === String(selectedModuleId.value)) return;
if (isDirty.value && !(await confirm({ title: "放弃未保存的修改", description: "当前模块有未保存的修改,切换模块将丢弃这些修改。", cancelText: "取消", confirmText: "仍然切换", danger: true }))) return;
selectedModuleId.value = String(nk);
}
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: string, newData: Record<string, unknown>[]) { (drafts.value as Record<string, unknown>)[section] = newData; }
// ---- Helpers ----
function isFieldEnabled(f: Record<string, unknown>) { return f && Number(f.b_canuse) === 1; }
function applyDisabledFieldDefaults(prev: Record<string, unknown>[], next: Record<string, unknown>[]) {
const prevStates = new Map<string, boolean>(); prev.forEach((f) => { if (f.b_id) prevStates.set(String(f.b_id), isFieldEnabled(f)); });
const disabledIds = new Set<string>(); next.forEach((f) => { if (f.b_id && prevStates.get(String(f.b_id)) && !isFieldEnabled(f)) disabledIds.add(String(f.b_id)); });
if (!disabledIds.size) return;
for (const row of drafts.value.listConfig) if (disabledIds.has(String(row.b_field_id))) row.b_visible = 0;
for (const row of drafts.value.editConfig) if (disabledIds.has(String(row.b_field_id))) row.b_visible = 0;
for (const row of drafts.value.queryConfig) if (disabledIds.has(String(row.b_field_id))) row.b_canuse = 0;
}
function onFieldsUpdate(newFields: Record<string, unknown>[]) { applyDisabledFieldDefaults(drafts.value.fields, newFields); drafts.value.fields = newFields; }
function onAutoCodeUpdate(rows: Record<string, unknown>[]) { drafts.value.autoCode = rows; }
function validateAutoCode(): string {
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((f) => String(f.b_id) === String(rule.b_field_id));
if (!field || Number(field.b_canuse) !== 1) return "请选择已启用的业务编号字段";
const width = Number(rule.b_sequence_width);
if (!Number.isInteger(width) || width < 1 || width > 18) return "流水号位数必须是 1 到 18 之间的整数";
return "";
}
function onGroupsSaved(groups: Record<string, unknown>[], idMapping?: Map<string, string>) {
if (idMapping?.size) { remapForeignKeys(drafts.value.listConfig, ["b_group_id"], idMapping); remapForeignKeys(drafts.value.editConfig, ["b_group_id"], idMapping); }
originals.value.groups = cloneData(groups); drafts.value.groups = cloneData(groups);
}
// ---- Add Module ----
async function onAddModule(parentId: string | null, moduleType = "directory") {
if (!canManage("create")) return;
const res = await nextIdApi();
const newId = String(res.data);
const m: Record<string, unknown> = { 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, b_icon: null };
originals.value = { module: cloneData(m), fields: [], groups: [], listConfig: [], editConfig: [], queryConfig: [], autoCode: [], powers: [] };
drafts.value = cloneData(originals.value);
skipLoad.value = true; selectedModuleId.value = newId; loadedModuleId.value = newId; activeSection.value = "basic";
unsavedModuleIds.add(newId);
loadTree().catch(() => {});
}
function onTreeMenuClick(p: { key: string; node: TreeNode }) {
if (busy.value) return;
if (p.key.startsWith("add-")) { onAddModule(p.node.isFmsRoot ? null : p.node.key, p.key.replace("add-", "")); }
else if (p.key === "delete") { onDeleteNode(p.node); }
}
async function onDeleteNode(node: TreeNode) {
if (hasChildNode(String(node.key))) { toast.error("请先删除子节点"); return; }
const ok = await confirm({ title: "删除模块", description: `确定删除「${node.title}」?此操作不可恢复,且会删除其下所有子模块与配置。`, confirmText: "删除", danger: true });
if (!ok) return;
await onDeleteNodeById(String(node.key));
}
function hasChildNode(id: string) { const n = findNode(id); return !!(n?.children && (n.children as unknown[]).length); }
async function onDeleteNodeById(id: string) {
if (!canManage("delete") || hasChildNode(id)) return;
try {
const del = (t: string) => ({ table: t, key_field: "b_module_id", deletes: [{ b_module_id: id }] });
await saveObjectApi([del("b_user_module"), del("b_user_power"), del("s_module_field_list"), del("s_module_field_edit"), del("s_module_field_query"), del("s_module_auto_code"), del("s_module_power"), del("s_module_field"), del("s_module_field_group"), del("b_i18n"), { table: "s_module", key_field: "b_id", deletes: [{ b_id: id }] }]);
if (selectedModuleId.value === id) { selectedModuleId.value = null; drafts.value.module = null; }
await loadTree();
} catch (e: unknown) { toast.error("删除失败:" + ((e as Error).message || e)); }
}
// ---- Save All ----
async function saveAll() {
if (!canManage("update") || !selectedModule.value || busy.value) return;
if (!loadedModuleId.value || String(loadedModuleId.value) !== String(selectedModule.value.b_id)) return;
const moduleId = String(selectedModule.value.b_id);
configSaving.value = true;
try {
const autoCodeErr = validateAutoCode(); if (autoCodeErr) { toast.error(autoCodeErr); return; }
const md = drafts.value.module, mo = originals.value.module;
const code = String(md?.b_code || "").trim();
if (!code) { toast.error("请填写编码"); return; }
const codeErr = validateModuleCode(code); if (codeErr) { toast.error(`编码格式不正确:${codeErr}`); return; }
if (!String(md?.b_name || "").trim()) { toast.error("请填写名称"); return; }
if (md?.b_module_type === "page" && !String(md?.b_route || "").trim()) { toast.error("请填写页面路由"); return; }
if (md?.b_module_type === "data" && !String(md?.b_viewtable || "").trim()) { toast.error("请填写查询视图"); return; }
syncModuleItemI18nKeys(drafts.value.fields, "field", md?.b_code, "b_field");
syncModuleItemI18nKeys(drafts.value.powers, "power", md?.b_code, "b_code");
if (md) md.b_i18n = buildModuleI18nKey(md.b_module_type as string, md.b_code as string);
const saveReqs: Record<string, unknown>[] = [];
const isNew = unsavedModuleIds.has(moduleId);
if (md && (isNew || JSON.stringify(md) !== JSON.stringify(mo))) {
const mr: Record<string, unknown> = { table: "s_module", key_field: "b_id" };
if (isNew) mr.inserts = [md]; else mr.updates = [md];
saveReqs.push(mr);
}
const subTables: [string, keyof typeof drafts.value][] = [["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 cur = drafts.value[section] || [], prev = originals.value[section] || [];
const changes = diffRows(cur as never, prev as never);
if (table === "s_module_auto_code") { changes.updates = changes.updates.map((row: Record<string, unknown>) => { const { b_current_period: _, b_current_value: __, ...c } = row; return c; }) as never; }
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_id: string }) => ({ b_power_id: String(b_id) })) });
saveReqs.push(req as unknown as Record<string, unknown>);
}
}
if (i18nPanelRef.value?.reconcileManagedRows) i18nPanelRef.value.reconcileManagedRows(finalI18nItems.value);
if (i18nPanelRef.value?.buildSaveRequest) {
const r = await i18nPanelRef.value.buildSaveRequest();
if (r) saveReqs.push(r);
}
if (!saveReqs.length) { toast.error("没有需要保存的变更"); return; }
await saveObjectApi(saveReqs as never);
originals.value = cloneData(drafts.value);
if (i18nPanelRef.value) i18nPanelRef.value.markSaved();
unsavedModuleIds.delete(moduleId);
updateSavedTreeNode(md as Record<string, unknown>);
permissionStore.load().catch(() => {});
toast.success("保存成功");
} catch (e: unknown) { toast.error("保存失败:" + ((e as Error).message || e)); }
finally { configSaving.value = false; }
}
// ---- Sync Fields ----
function configuredFieldIds(section: keyof typeof drafts.value): Set<string> {
const ids = new Set<string>();
for (const row of (originals.value[section] as Record<string, unknown>[]) || []) if (row.b_field_id) ids.add(String(row.b_field_id));
for (const row of (drafts.value[section] as Record<string, unknown>[]) || []) if (row.b_field_id) ids.add(String(row.b_field_id));
return ids;
}
function buildMissingDefaultConfigs(module: Record<string, unknown>, fields: Record<string, unknown>[]) {
const mid = String(module.b_id), lIds = configuredFieldIds("listConfig"), eIds = configuredFieldIds("editConfig"), qIds = configuredFieldIds("queryConfig");
const g = { listConfig: [] as Record<string, unknown>[], editConfig: [] as Record<string, unknown>[], queryConfig: [] as Record<string, unknown>[] };
let i = 0;
for (const f of fields) { if (f.b_canuse !== 1) continue; i++; const fid = String(f.b_id), o = i * 10;
if (!lIds.has(fid)) g.listConfig.push({ b_id: createTempId(), b_module_id: mid, b_field_id: fid, b_group_id: null, b_title: null, b_i18n: f.b_i18n || null, b_visible: 1, b_xh: o, b_width: 100, b_sortable: 0 });
if (!eIds.has(fid)) g.editConfig.push({ b_id: createTempId(), b_module_id: mid, b_field_id: fid, b_required: 0, b_readonly: 0, b_disabled: 0, b_visible: 1, b_group_id: null, b_xh: o });
if (!qIds.has(fid)) g.queryConfig.push({ b_id: createTempId(), b_module_id: mid, b_field_id: fid, b_component: "input", b_operator: "=", b_default_value: null, b_xh: o, b_canuse: 1 });
}
return g;
}
async function syncFields() {
const m = selectedModule.value; if (!m) return;
const view = String(m.b_viewtable || "").trim(); if (!view || busy.value) return;
const mid = String(m.b_id);
configSyncing.value = true;
try {
const res = await describeApi(view);
if (!selectedModule.value || String(selectedModule.value.b_id) !== mid) return;
const cols = (res.data || []) as { name: string }[];
const colSet = new Set(cols.map((c) => c.name.toLowerCase()));
const ef = drafts.value.fields || [];
const efMap = new Map<string, Record<string, unknown>>();
for (const f of ef) if (f.b_field) efMap.set(String(f.b_field).toLowerCase(), f);
const merged: Record<string, unknown>[] = cols.map((c) => {
const exist = efMap.get(c.name.toLowerCase());
return exist ? { ...exist, b_canuse: 1 } : { b_id: createTempId(), b_module_id: String(m.b_id), b_field: c.name, b_name: c.name, b_i18n: null, b_type: "input", b_default_value: null, b_options: null, b_canuse: 1 };
});
const removed = new Set<string>();
for (const f of ef) { if (f.b_field && !colSet.has(String(f.b_field).toLowerCase())) removed.add(String(f.b_id)); }
if (removed.size) {
for (const s of ["listConfig", "editConfig", "queryConfig"] as const) drafts.value[s] = (drafts.value[s] || []).filter((r) => !removed.has(String(r.b_field_id)));
if (i18nPanelRef.value?.removeByManagedIds) i18nPanelRef.value.removeByManagedIds(new Set([...removed].map((id) => `field:${id}`)));
}
merged.forEach((f) => { f.b_i18n = buildModuleItemI18nKey("field", m.b_code, f.b_field); });
const changes = diffRows(merged as never, ef as never);
const freq = tableChange("s_module_field", changes);
await allocateTemporaryIds([freq.inserts || [], freq.updates || [], freq.deletes || []]);
applyDisabledFieldDefaults(ef, merged);
const gen = buildMissingDefaultConfigs(m, merged);
for (const s of ["listConfig", "editConfig", "queryConfig"] as const) { if (gen[s].length) drafts.value[s] = [...(drafts.value[s] || []), ...cloneData(gen[s])]; }
drafts.value.fields = cloneData(merged);
toast.success("同步成功");
} catch (e: unknown) { toast.error("字段同步失败:" + ((e as Error).message || e)); }
finally { configSyncing.value = false; }
}
// ---- Init ----
void permissionStore.load().then(loadTree).catch((e: unknown) => toast.error("模块树加载失败:" + ((e as Error).message || e)));
</script>
<template>
<div class="flex h-full gap-4 p-6">
<div class="flex h-full w-full min-w-0 min-h-0 bg-background">
<!-- 左侧树 -->
<div class="w-72 shrink-0 rounded-xl border bg-card p-4">
<h2 class="mb-3 text-sm font-semibold text-foreground">模块树</h2>
<FmsTree
v-model:expanded-keys="expandedKeys"
v-model:selected-keys="selectedKeys"
:tree-data="treeData"
:context-menu="buildContextMenu"
@select="onSelect"
@menu-click="onMenuClick"
>
<template #title="{ node }">
<span class="text-sm">{{ node.title }}</span>
</template>
</FmsTree>
</div>
<aside class="flex shrink-0 flex-col" :style="{ width: leftPanelWidth + 'px' }">
<div class="py-2">
<div class="relative">
<SearchIcon :size="14" class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input v-model="treeSearch" class="h-8 pl-8 text-[13px]" placeholder="搜索模块…" />
</div>
</div>
<div class="flex flex-1 min-h-0 flex-col overflow-hidden pb-2">
<div v-if="treeLoading" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">加载中…</div>
<div v-else class="flex-1 min-h-0 overflow-auto px-1">
<FmsTree
:tree-data="filteredTreeData as TreeNode[]"
v-model:expanded-keys="expandedTreeKeys"
:selected-keys="selectedModuleId ? [selectedModuleId] : []"
:context-menu="busy ? null : buildTreeContextMenu"
@select="onTreeSelect"
@menu-click="onTreeMenuClick"
/>
</div>
</div>
</aside>
<!-- 右侧详情 -->
<div class="flex-1 rounded-xl border bg-card p-4">
<h2 class="mb-3 text-sm font-semibold text-foreground">节点详情</h2>
<div v-if="selectedNode" class="space-y-2 text-sm text-muted-foreground">
<div>
<span class="font-medium text-foreground">Key: </span>
<code class="rounded bg-muted px-1.5 py-0.5 text-xs">{{ selectedNode.key }}</code>
<!-- 拖拽手柄 -->
<div class="w-1 shrink-0 cursor-col-resize bg-transparent transition-colors hover:bg-border/60" @mousedown="onResizeStart" />
<!-- 右侧 -->
<main class="relative flex flex-1 min-w-0 flex-col overflow-hidden">
<FmsLoadingOverlay :loading="configLoading" />
<template v-if="selectedModule">
<!-- Header -->
<div class="flex shrink-0 items-center justify-between bg-background px-5 py-2.5">
<div class="flex items-center gap-2 min-w-0">
<span class="truncate text-[14px] font-semibold">{{ String(selectedModule.b_name || selectedModule.b_code) }}</span>
<span class="inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wider text-muted-foreground bg-muted">{{ moduleTypeLabel }}</span>
</div>
<div class="flex items-center gap-3 shrink-0">
<label class="flex cursor-pointer items-center gap-1.5 text-[13px] text-muted-foreground">
<Switch :checked="Number(drafts.module?.b_canuse) === 1" :disabled="busy" @update:checked="(v: boolean) => { if (drafts.module) drafts.module.b_canuse = v ? 1 : 0; }" />启用
</label>
<template v-if="['directory', 'page'].includes(String(selectedModule.b_module_type))">
<label class="flex cursor-pointer items-center gap-1.5 text-[13px] text-muted-foreground">
<Switch :checked="Number(drafts.module?.b_canmenu) === 1" :disabled="busy" @update:checked="(v: boolean) => { if (drafts.module) drafts.module.b_canmenu = v ? 1 : 0; }" />菜单
</label>
</template>
<Button v-if="canManage('update')" size="sm" class="h-7 text-[12px]" :disabled="configLoading || configSyncing" @click="saveAll">保存</Button>
</div>
</div>
<div>
<span class="font-medium text-foreground">Title: </span>
{{ selectedNode.title }}
</div>
<div>
<span class="font-medium text-foreground">子节点数: </span>
{{ selectedNode.children?.length ?? 0 }}
<!-- Tabs -->
<div class="flex flex-1 min-h-0 flex-col overflow-hidden">
<div class="shrink-0 bg-background px-4">
<div class="flex h-9 gap-1 border-b border-border">
<button
v-for="t in tabs"
:key="t.key"
type="button"
class="flex h-9 -mb-px items-center gap-1.5 border-b-2 px-3 text-[13px] transition-colors"
:class="activeSection === t.key
? 'border-primary font-medium text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'"
@click="activeSection = t.key"
>
<component :is="t.icon" :size="15" />
{{ t.label }}
</button>
</div>
</div>
<!-- Tab Content -->
<div class="flex-1 min-h-0 overflow-hidden p-5">
<div v-show="activeSection === 'basic'" class="h-full min-h-0">
<ModuleBasicPanel
:module-id="String(selectedModule.b_id ?? '')"
:data="drafts.module"
:parent-name="parentModuleName"
:loading="busy"
@update="(v) => drafts.module = v"
/>
</div>
<div v-show="isDataModule && activeSection === 'fields'" class="h-full min-h-0">
<ModuleFieldsPanel :module-id="String(selectedModule.b_id ?? '')" :module-code="String(selectedModule.b_code ?? '')" :fields="drafts.fields" :loading="busy" @update="(v) => onFieldsUpdate(v as any)" @sync-fields="syncFields" />
</div>
<div v-show="isDataModule && activeSection === 'list'" class="h-full min-h-0">
<ModuleListConfigPanel :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)" />
</div>
<div v-show="isDataModule && activeSection === 'edit'" class="h-full min-h-0">
<ModuleEditConfigPanel :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)" />
</div>
<div v-show="isDataModule && activeSection === 'query'" class="h-full min-h-0">
<ModuleQueryConfigPanel :module-id="String(selectedModule.b_id ?? '')" :query-config="drafts.queryConfig" :fields="drafts.fields" :loading="busy" @update="(v) => onUpdate('queryConfig', v)" />
</div>
<div v-show="isDataModule && activeSection === 'auto-code'" class="h-full min-h-0">
<ModuleAutoCodePanel :module-id="String(selectedModule.b_id ?? '')" :auto-code="drafts.autoCode" :fields="drafts.fields" :loading="busy" @update="onAutoCodeUpdate" />
</div>
<div v-show="isPageModule && activeSection === 'powers'" class="h-full min-h-0">
<ModulePowersPanel :module-id="String(selectedModule.b_id ?? '')" :module-code="String(selectedModule.b_code ?? '')" :powers="drafts.powers" :loading="busy" @update="(v) => onUpdate('powers', v)" />
</div>
<div v-show="!isDirectoryModule && activeSection === 'i18n'" class="h-full min-h-0">
<ModuleI18nPanel :ref="setI18nPanelRef" :module-id="String(selectedModule.b_id ?? '')" :i18n-items="i18nItems" :loading="busy" />
</div>
</div>
</div>
</template>
<div v-else class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
请从左侧选择一个模块,或右键新建
</div>
<div v-else class="flex h-40 items-center justify-center text-sm text-muted-foreground">
请在左侧树中选择一个节点
</div>
</div>
</main>
</div>
</template>