20260811165111
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user