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

706 lines
19 KiB
Vue

<script setup>
import { computed, h, ref, watch } from "vue";
import { message } from "antdv-next";
import FmsTree from "@/components/FmsTree.vue";
import { loadDataApi, saveObjectApi, nextIdApi, describeApi } from "@/services/api";
import { cloneData, diffRows, allocateTemporaryIds, tableChange, hasChanges } from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
import { Plus } from "@lucide/vue";
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 ModulePowersPanel from "./components/ModulePowersPanel.vue";
import ModuleI18nPanel from "./components/ModuleI18nPanel.vue";
// ---- 状态 ----
const treeData = ref([]);
const treeLoading = ref(false);
const selectedModuleId = ref(null);
const skipLoad = ref(false);
const activeTab = ref("basic");
// 原始数据(加载时保存,用于 diff)
const originals = ref({
module: null,
fields: [],
groups: [],
listConfig: [],
editConfig: [],
queryConfig: [],
powers: [],
});
// 编辑草稿
const drafts = ref({
module: null,
fields: [],
groups: [],
listConfig: [],
editConfig: [],
queryConfig: [],
powers: [],
});
const configLoading = ref(false);
const configSaving = ref(false);
// 当前选中模块对象(从 drafts.module 获取)
const selectedModule = computed(() => drafts.value.module);
// 可见 Tab
const visibleTabs = computed(() => {
const type = selectedModule.value?.b_module_type;
if (!type) return [];
const tabs = [{ key: "basic", tab: "基本信息", icon: "Info" }];
if (type === "data") {
tabs.push(
{ key: "fields", tab: "字段定义", icon: "Table" },
{ key: "list", tab: "列表配置", icon: "List" },
{ key: "edit", tab: "编辑配置", icon: "Edit" },
{ key: "query", tab: "查询配置", icon: "Search" },
);
}
tabs.push(
{ key: "powers", tab: "权限配置", icon: "Lock" },
{ key: "i18n", tab: "多语言", icon: "Translation" },
);
return tabs;
});
// ---- 模块树 ----
function buildTree(modules) {
const map = new Map();
const roots = [];
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 ? String(m.b_parent_id) : null;
map.set(m.key, m);
});
modules.forEach((m) => {
if (m.b_parent_id && map.has(m.b_parent_id)) {
const parent = map.get(m.b_parent_id);
if (!parent.children) parent.children = [];
parent.children.push(m);
} else {
roots.push(m);
}
});
return roots;
}
async function loadTree() {
treeLoading.value = true;
try {
const res = await loadDataApi("b_module", "b_canuse = 1", "b_xh ASC, b_id ASC");
treeData.value = buildTree(res.data || []);
} finally {
treeLoading.value = false;
}
}
// ---- 模块配置加载 ----
async function loadModuleConfig(moduleId) {
configLoading.value = true;
try {
const id = String(moduleId);
const [moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, powersRes] =
await Promise.all([
loadDataApi("b_module", `b_id = ${id}`),
loadDataApi("b_module_field", `b_module_id = ${id}`, "b_id ASC"),
loadDataApi("b_module_field_group", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("b_module_field_list", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("b_module_field_edit", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("b_module_field_query", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("b_module_power", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
]);
const original = {
module: moduleRes.data?.[0] || null,
fields: fieldsRes.data || [],
groups: groupsRes.data || [],
listConfig: listRes.data || [],
editConfig: editRes.data || [],
queryConfig: queryRes.data || [],
powers: powersRes.data || [],
};
originals.value = original;
drafts.value = cloneData(original);
activeTab.value = "basic";
} finally {
configLoading.value = false;
}
}
// ---- 树节点选择 ----
function onTreeSelect(keys) {
if (keys.length > 0) {
selectedModuleId.value = String(keys[0]);
}
}
watch(selectedModuleId, (id) => {
if (!id) return;
if (skipLoad.value) {
skipLoad.value = false;
return;
}
loadModuleConfig(id);
});
// ---- 子面板数据变更 ----
function onUpdate(section, newData) {
drafts.value[section] = newData;
}
function onSetField(field, value) {
if (drafts.value.module) {
drafts.value.module[field] = value;
}
}
// 分组管理已自行保存到后端,同步更新 originals 和 drafts
function onGroupsSaved(newGroups) {
originals.value.groups = cloneData(newGroups);
drafts.value.groups = cloneData(newGroups);
}
// ---- 新增模块 ----
async function onAddModule(parentId, moduleType = "directory") {
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: [],
powers: [],
};
drafts.value = cloneData(originals.value);
skipLoad.value = true;
selectedModuleId.value = newId;
activeTab.value = "basic";
// 刷新树
await loadTree();
}
const addModuleMenu = {
items: [
{ key: "directory", label: "目录" },
{ key: "page", label: "页面" },
{ key: "data", label: "数据" },
],
onClick: ({ key }) => onAddModule(null, key),
};
// ---- 删除模块 ----
async function onDeleteModule() {
const moduleId = selectedModuleId.value;
if (!moduleId) return;
try {
await saveObjectApi([{ table: "b_module", key_field: "b_id", deletes: [{ b_id: moduleId }] }]);
message.success("模块已删除");
selectedModuleId.value = null;
drafts.value.module = null;
await loadTree();
} catch (e) {
message.error("删除失败:" + (e.message || e));
}
}
// ---- 统一保存 ----
async function saveAll() {
if (!selectedModule.value) return;
configSaving.value = true;
try {
const saveReqs = [];
// b_module 主表
const moduleData = drafts.value.module;
const moduleOriginal = originals.value.module;
if (moduleData && JSON.stringify(moduleData) !== JSON.stringify(moduleOriginal)) {
const isNew = !moduleOriginal?.b_code;
saveReqs.push({
table: "b_module",
key_field: "b_id",
[isNew ? "inserts" : "updates"]: [moduleData],
});
}
// 子表:fields, listConfig, editConfig, queryConfig, powers
const subTables = [
["b_module_field", "fields"],
["b_module_field_list", "listConfig"],
["b_module_field_edit", "editConfig"],
["b_module_field_query", "queryConfig"],
["b_module_power", "powers"],
];
for (const [table, section] of subTables) {
const current = drafts.value[section] || [];
const previous = originals.value[section] || [];
const changes = diffRows(current, previous);
const req = tableChange(table, changes);
if (hasChanges(req)) {
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
saveReqs.push(req);
}
}
if (saveReqs.length === 0) {
message.info("没有需要保存的变更");
return;
}
await saveObjectApi(saveReqs);
// 更新 originals
originals.value = cloneData(drafts.value);
message.success("保存成功");
await loadTree();
} catch (e) {
message.error("保存失败:" + (e.message || e));
} finally {
configSaving.value = false;
}
}
// ---- 字段同步 ----
function typeFromSqlType(sqlType) {
if (!sqlType) return "input";
const t = sqlType.toLowerCase();
if (
t.includes("char") ||
t.includes("text") ||
t.includes("nchar") ||
t.includes("ntext") ||
t.includes("xml")
) {
if (t.includes("text") || t.includes("ntext") || t.includes("xml")) return "textarea";
return "input";
}
if (t.includes("int") || t.includes("float") || t.includes("double") || t.includes("real"))
return "number";
if (t.includes("decimal") || t.includes("numeric") || t.includes("money")) {
if (t.includes("money")) return "money";
return "number";
}
if (t.includes("date") || t.includes("time")) {
if (t.includes("date") && (t.includes("time") || t.includes("smalldatetime")))
return "datetime";
return "date";
}
if (t.includes("bit")) return "switch";
return "input";
}
async function syncFields() {
const module = selectedModule.value;
if (!module?.b_viewtable) {
message.warning("当前模块未配置查询视图");
return;
}
try {
configSaving.value = true;
const res = await describeApi(module.b_viewtable);
const columns = res.data || [];
const existingFields = originals.value.fields || [];
// 构建视图列名集合
const viewColumnSet = new Set(columns.map((c) => c.name.toLowerCase()));
// 已有字段映射(按字段名)
const existingMap = new Map(
existingFields.map((f) => [f.b_field?.toLowerCase(), f]),
);
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: typeFromSqlType(col.type),
b_default_value: null,
b_options: null,
b_canuse: 1,
});
}
}
// 视图中不存在的已有字段标记为不可用
for (const existing of existingFields) {
if (!viewColumnSet.has(existing.b_field?.toLowerCase())) {
mergedFields.push({ ...existing, b_canuse: 0 });
}
}
// 保存到 b_module_field
const changes = diffRows(mergedFields, existingFields);
const req = tableChange("b_module_field", changes);
if (hasChanges(req)) {
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
await saveObjectApi([req]);
message.success(`字段同步完成:${columns.length} 列,新增 ${req.inserts?.length || 0},停用 ${req.deletes?.length || 0}`);
} else {
message.info("字段已是最新,无需同步");
}
// 重新加载
const fieldsRes = await loadDataApi(
"b_module_field",
`b_module_id = ${module.b_id}`,
"b_id ASC",
);
originals.value.fields = fieldsRes.data || [];
drafts.value.fields = cloneData(originals.value.fields);
} catch (e) {
message.error("字段同步失败:" + (e.message || e));
} finally {
configSaving.value = false;
}
}
// ---- 初始化 ----
loadTree();
</script>
<template>
<div class="mm-page">
<!-- 左侧模块树 -->
<aside class="mm-sidebar">
<div class="mm-sidebar-header">
<span class="mm-sidebar-title">模块列表</span>
<a-dropdown :menu="addModuleMenu" :trigger="['click']">
<a-button><Plus :size="14" />新增</a-button>
</a-dropdown>
</div>
<div v-if="treeData.length === 0" class="mm-tree-empty">
<a-empty description="暂无模块" />
</div>
<FmsTree
v-else
class="mm-tree"
:tree-data="treeData"
:selected-keys="selectedModuleId ? [selectedModuleId] : []"
:field-names="{ title: 'title', key: 'key', children: 'children' }"
@select="onTreeSelect"
>
<template #title="{ key, title }">
<span class="mm-tree-node">
<span>{{ title }}</span>
<span class="mm-tree-node-actions">
<a-button type="text" @click.stop="onAddModule(key)">+</a-button>
</span>
</span>
</template>
</FmsTree>
</aside>
<!-- 右侧详情 -->
<main class="mm-main">
<template v-if="selectedModule">
<!-- 页面头部 -->
<div class="mm-header">
<a-space>
<a-button type="primary" :loading="configSaving" @click="saveAll">保存</a-button>
<a-popconfirm title="确定删除该模块?" @confirm="onDeleteModule">
<a-button danger>删除模块</a-button>
</a-popconfirm>
</a-space>
<div class="mm-header-center">
<a-space size="middle">
<span>启用</span>
<a-switch
:checked="drafts.module?.b_canuse === 1"
@update:checked="(v) => onSetField('b_canuse', v ? 1 : 0)"
/>
<a-divider type="vertical" />
<span>显示菜单</span>
<a-switch
:checked="drafts.module?.b_canmenu === 1"
:disabled="drafts.module?.b_module_type === 'data'"
@update:checked="(v) => onSetField('b_canmenu', v ? 1 : 0)"
/>
</a-space>
</div>
<span class="mm-header-title">{{ selectedModule.b_name || selectedModule.b_code }}</span>
<span class="mm-header-code">{{ selectedModule.b_code || '(未设置编码)' }}</span>
<span class="mm-header-type">
<a-tag>{{ selectedModule.b_module_type === 'directory' ? '目录' : selectedModule.b_module_type === 'page' ? '页面' : '数据' }}</a-tag>
</span>
</div>
<!-- Tab 面板 -->
<div class="mm-body">
<a-tabs v-model:active-key="activeTab" class="mm-tabs">
<a-tab-pane
v-for="tab in visibleTabs"
:key="tab.key"
:tab="tab.tab"
>
<template v-if="configLoading">
<div class="mm-loading"><a-spin /></div>
</template>
<template v-else>
<ModuleBasicPanel
v-if="tab.key === 'basic'"
:module-id="String(selectedModule.b_id)"
:data="drafts.module"
:loading="configSaving"
@update="(v) => onUpdate('module', v)"
/>
<ModuleFieldsPanel
v-else-if="tab.key === 'fields'"
:module-id="String(selectedModule.b_id)"
:fields="drafts.fields"
:view-table="selectedModule.b_viewtable"
:loading="configSaving"
@update="(v) => onUpdate('fields', v)"
@sync="syncFields"
/>
<ModuleListConfigPanel
v-else-if="tab.key === 'list'"
:module-id="String(selectedModule.b_id)"
:list-config="drafts.listConfig"
:fields="drafts.fields"
:groups="drafts.groups"
:loading="configSaving"
@update="(v) => onUpdate('listConfig', v)"
@update-groups="onGroupsSaved"
/>
<ModuleEditConfigPanel
v-else-if="tab.key === 'edit'"
:module-id="String(selectedModule.b_id)"
:edit-config="drafts.editConfig"
:fields="drafts.fields"
:groups="drafts.groups"
:loading="configSaving"
@update="(v) => onUpdate('editConfig', v)"
@update-groups="onGroupsSaved"
/>
<ModuleQueryConfigPanel
v-else-if="tab.key === 'query'"
:module-id="String(selectedModule.b_id)"
:query-config="drafts.queryConfig"
:fields="drafts.fields"
:loading="configSaving"
@update="(v) => onUpdate('queryConfig', v)"
/>
<ModulePowersPanel
v-else-if="tab.key === 'powers'"
:module-id="String(selectedModule.b_id)"
:powers="drafts.powers"
:loading="configSaving"
@update="(v) => onUpdate('powers', v)"
/>
<ModuleI18nPanel
v-else-if="tab.key === 'i18n'"
:module-id="String(selectedModule.b_id)"
:loading="configSaving"
/>
</template>
</a-tab-pane>
</a-tabs>
</div>
</template>
<!-- 未选中模块 -->
<div v-else class="mm-empty">
<a-result status="info" title="请选择模块" sub-title="从左侧树选择一个模块查看详情" />
</div>
</main>
</div>
</template>
<style scoped lang="scss">
.mm-page {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
background: var(--fms-bg);
}
.mm-sidebar {
display: flex;
flex-direction: column;
flex: none;
width: 260px;
border-right: 1px solid var(--fms-border);
background: var(--fms-surface);
}
.mm-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
flex: none;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--fms-border);
}
.mm-sidebar-title {
font-size: 14px;
font-weight: 600;
color: var(--fms-text);
}
.mm-tree {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 0;
}
.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;
}
.mm-tree-node-actions {
display: none;
flex: none;
}
.mm-tree-node:hover .mm-tree-node-actions {
display: flex;
}
.mm-main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
background: var(--fms-surface);
}
.mm-header {
display: flex;
align-items: center;
flex: none;
gap: 12px;
padding: 12px 20px;
background: var(--fms-surface);
border-bottom: 1px solid var(--fms-border);
}
.mm-header-center {
flex: 1;
display: flex;
justify-content: center;
}
.mm-header-title {
font-size: 16px;
font-weight: 600;
color: var(--fms-text);
}
.mm-header-code {
font-size: 13px;
color: var(--fms-text-secondary);
font-family: monospace;
}
.mm-header-type {
flex: none;
}
.mm-body {
flex: 1;
min-height: 0;
overflow: hidden;
}
.mm-tabs {
height: 100%;
display: flex;
flex-direction: column;
:deep(.ant-tabs-nav) {
margin-bottom: 0;
padding: 0 20px;
background: var(--fms-surface);
}
:deep(.ant-tabs-content-holder) {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.ant-tabs-content) {
flex: 1;
min-height: 0;
height: 100%;
overflow: hidden;
}
:deep(.ant-tabs-tabpane) {
height: 100%;
}
}
.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>