Files
workspace/code/fms/fms-vue/src/views/module-management/ModuleManagementView.vue
T
2026-07-20 21:58:10 +08:00

824 lines
24 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, onMounted, reactive, ref } from "vue";
import { App } from "antdv-next";
import { Database, FileText, Languages, RotateCcw, Save, ShieldCheck, Trash2 } from "@lucide/vue";
import { useAuthStore } from "../../stores/auth";
import { usePermissionStore } from "../../stores/permissions";
import {
LOCALES,
loadModuleConfiguration,
nextIds,
saveChanges,
tableRequest,
} from "../../services/moduleManagement";
import ModuleBasicInfoPanel from "./components/ModuleBasicInfoPanel.vue";
import ModuleFieldDefinitionPanel from "./components/ModuleFieldDefinitionPanel.vue";
import ModulePowerPanel from "./components/ModulePowerPanel.vue";
import ModuleTranslationsPanel from "./components/ModuleTranslationsPanel.vue";
import ModuleTreePanel from "./components/ModuleTreePanel.vue";
const { message } = App.useApp();
const authStore = useAuthStore();
const permissionStore = usePermissionStore();
const loading = ref(false);
const saving = ref(false);
const selectedKey = ref("");
const activeTab = ref("basic");
const isNew = ref(false);
const customI18nKeys = ref([]);
const deleteConfirmOpen = ref(false);
const pendingDeleteKey = ref("");
const original = ref("");
const data = reactive({
modules: [],
fields: [],
powers: [],
translations: [],
userModules: [],
userPowers: [],
});
const draft = reactive({ module: null, fields: [], powers: [], translations: {} });
const canCreate = computed(() => permissionStore.canPower("M_MODULE", "create"));
const canUpdate = computed(() => permissionStore.canPower("M_MODULE", "update"));
const canDelete = computed(() => permissionStore.canPower("M_MODULE", "delete"));
const canSave = computed(() => (isNew.value ? canCreate.value : canUpdate.value));
const dirty = computed(() => Boolean(draft.module) && serializeDraft() !== original.value);
const parentOptions = computed(() =>
data.modules
.filter((item) => item.b_id !== draft.module?.b_id && item.b_type !== "data")
.map((item) => ({ value: item.b_id, label: item.b_name + " (" + item.b_id + ")" })),
);
const directoryParentOptions = computed(() =>
data.modules
.filter((item) => item.b_type === "directory")
.map((item) => ({ value: item.b_id, label: item.b_name + " (" + item.b_id + ")" })),
);
const moduleKindLabel = computed(() => {
if (draft.module?.b_type === "directory") return "模块";
return draft.module?.b_route?.trim() ? "页面模块" : "数据模块";
});
const translationKeys = computed(() => rowKeys());
const pendingDeleteModule = computed(() =>
data.modules.find((item) => item.b_id === pendingDeleteKey.value),
);
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function serializeDraft() {
return JSON.stringify({
module: draft.module,
fields: draft.fields,
powers: draft.powers,
translations: draft.translations,
});
}
function rowKeys() {
return [
draft.module?.b_i18n,
...draft.fields.map((item) => item.b_i18n),
...draft.powers.map((item) => item.b_i18n),
...customI18nKeys.value,
].filter(Boolean);
}
function clearDraft() {
selectedKey.value = "";
isNew.value = false;
draft.module = null;
draft.fields = [];
draft.powers = [];
draft.translations = {};
customI18nKeys.value = [];
original.value = "";
activeTab.value = "basic";
}
function selectModule(code) {
const module = data.modules.find((item) => item.b_id === code);
if (!module) return;
selectedKey.value = code;
isNew.value = false;
draft.module = clone(module);
draft.fields = clone(data.fields.filter((item) => item.b_module_id === code));
draft.powers = clone(data.powers.filter((item) => item.b_module_id === code));
customI18nKeys.value = [];
const keys = new Set(rowKeys());
draft.translations = {};
data.translations
.filter((item) => keys.has(item.b_key))
.forEach((item) => {
draft.translations[item.b_key] ||= {};
draft.translations[item.b_key][item.b_locale] = item.b_value;
});
original.value = serializeDraft();
if (module.b_type === "directory") activeTab.value = "basic";
}
async function loadAll(preferredKey = selectedKey.value) {
loading.value = true;
try {
Object.assign(data, await loadModuleConfiguration());
const target = data.modules.some((item) => item.b_id === preferredKey) ? preferredKey : "";
if (target) {
selectModule(target);
} else {
clearDraft();
}
} catch (error) {
message.error(error.message || "模块配置加载失败");
} finally {
loading.value = false;
}
}
function nextModuleCode(prefix) {
const existing = new Set(data.modules.map((item) => item.b_id));
let index = 1;
while (existing.has(prefix + index)) index += 1;
return prefix + index;
}
function parentForNew(contextKey) {
const context = data.modules.find((item) => item.b_id === contextKey);
if (!context) return null;
return context.b_type === "directory" ? context.b_id : context.b_parent_id || null;
}
function addModule(kind, contextKey) {
const isDirectory = kind === "directory";
const id = nextModuleCode(isDirectory ? "M_DIR_NEW_" : "M_PAGE_NEW_");
const parentId = parentForNew(contextKey);
const route = isDirectory ? null : "/" + id.toLowerCase().replace(/^m_/, "").replaceAll("_", "-");
draft.module = {
b_id: id,
b_parent_id: parentId,
b_name: isDirectory ? "新模块" : "新页面",
b_i18n: "module." + id,
b_type: isDirectory ? "directory" : "page",
b_route: route,
b_viewtable: null,
b_savetable: null,
b_keyfield: isDirectory ? null : "b_id",
b_orderfield: isDirectory ? null : "b_id",
b_ordertype: isDirectory ? null : "DESC",
b_canmenu: isDirectory || route ? 1 : 0,
b_canuse: 1,
b_xh: 100,
};
draft.fields = [];
draft.powers = [];
draft.translations = {
["module." + id]: { "zh-CN": draft.module.b_name },
};
customI18nKeys.value = [];
selectedKey.value = id;
isNew.value = true;
original.value = "";
activeTab.value = "basic";
}
function handleAdd(kind, contextKey) {
addModule(kind === "module" ? "directory" : "page", contextKey);
}
function updateModuleId(value) {
if (!isNew.value) return;
const oldId = draft.module.b_id;
const oldKey = draft.module.b_i18n;
const newId = String(value || "").toUpperCase();
draft.module.b_id = newId;
if (oldKey === "module." + oldId) {
const newKey = "module." + newId;
draft.module.b_i18n = newKey;
draft.translations[newKey] = draft.translations[oldKey] || {};
delete draft.translations[oldKey];
}
selectedKey.value = newId;
draft.fields.forEach((item) => {
item.b_module_id = newId;
});
draft.powers.forEach((item) => {
item.b_module_id = newId;
});
}
async function addField() {
if (!draft.module) return;
const [id] = await nextIds(1);
const index = draft.fields.length + 1;
const fieldCode = "b_field" + index;
const i18nKey = "field." + draft.module.b_id + "." + fieldCode;
draft.fields.push({
b_id: id,
b_module_id: draft.module.b_id,
b_field: fieldCode,
b_name: "新字段",
b_i18n: i18nKey,
b_type: "string",
b_canlist: 1,
b_canform: 1,
b_must: 0,
b_canquery: 0,
b_readonly: 0,
b_width: 120,
b_value: null,
b_xh: index * 10,
b_canuse: 1,
});
draft.translations[i18nKey] = { "zh-CN": "新字段" };
}
async function addPower() {
if (!draft.module) return;
const [id] = await nextIds(1);
const index = draft.powers.length + 1;
const code = "action" + index;
const i18nKey = "power." + draft.module.b_id + "." + code;
draft.powers.push({
b_id: id,
b_module_id: draft.module.b_id,
b_code: code,
b_name: "新操作",
b_i18n: i18nKey,
b_xh: index * 10,
b_canuse: 1,
});
draft.translations[i18nKey] = { "zh-CN": "新操作" };
}
function normalizeModuleType() {
if (draft.module.b_type === "directory") return;
draft.module.b_type = draft.module.b_route?.trim() ? "page" : "data";
if (draft.module.b_type === "data") draft.module.b_canmenu = 0;
}
function validate() {
const module = draft.module;
if (!/^[A-Z][A-Z0-9_]{1,49}$/.test(module.b_id)) return "模块编码格式不正确";
if (!module.b_name?.trim()) return "模块名称不能为空";
if (isNew.value && data.modules.some((item) => item.b_id === module.b_id)) {
return "模块编码已经存在";
}
if (
module.b_type === "directory" &&
(module.b_route || module.b_viewtable || module.b_savetable)
) {
return "模块不能配置路由或数据表";
}
if (module.b_type === "data" && module.b_canmenu === 1) return "数据模块不能显示在菜单";
const identifiers = [
module.b_viewtable,
module.b_savetable,
module.b_keyfield,
module.b_orderfield,
].filter(Boolean);
if (identifiers.some((item) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(item))) {
return "表名和字段名只能包含字母、数字和下划线";
}
const fieldCodes = draft.fields.map((item) => item.b_field);
if (new Set(fieldCodes).size !== fieldCodes.length) return "字段编码不能重复";
if (fieldCodes.some((item) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(item))) {
return "字段编码格式不正确";
}
const powerCodes = draft.powers.map((item) => item.b_code);
if (new Set(powerCodes).size !== powerCodes.length) return "权限代码不能重复";
if (
draft.fields.some((item) => !item.b_name?.trim()) ||
draft.powers.some((item) => !item.b_name?.trim())
) {
return "字段名称和权限名称不能为空";
}
return "";
}
function diffRows(current, previous) {
const oldMap = new Map(previous.map((item) => [String(item.b_id), item]));
const currentMap = new Map(current.map((item) => [String(item.b_id), item]));
return {
inserts: current.filter((item) => !oldMap.has(String(item.b_id))),
updates: current.filter((item) => {
const old = oldMap.get(String(item.b_id));
return old && JSON.stringify(item) !== JSON.stringify(old);
}),
deletes: previous
.filter((item) => !currentMap.has(String(item.b_id)))
.map((item) => ({ b_id: item.b_id })),
};
}
async function translationChanges(previousKeys) {
const currentKeys = new Set(rowKeys());
const relevantKeys = new Set([...previousKeys, ...currentKeys]);
const oldRows = data.translations.filter((item) => relevantKeys.has(item.b_key));
const desired = [];
const missing = [];
for (const key of currentKeys) {
for (const locale of LOCALES) {
const value = draft.translations[key]?.[locale.value]?.trim();
if (!value) continue;
const old = oldRows.find((item) => item.b_key === key && item.b_locale === locale.value);
if (old) {
desired.push({ ...old, b_value: value });
} else {
missing.push({ key, locale: locale.value, value });
}
}
}
const ids = await nextIds(missing.length);
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
missing.forEach((item, index) => {
desired.push({
b_id: ids[index],
b_key: item.key,
b_locale: item.locale,
b_value: item.value,
b_inputuser_id: String(authStore.user.id),
b_inputdatetime: now,
b_updateuser_id: null,
b_updatedatetime: null,
});
});
const changes = diffRows(desired, oldRows);
changes.updates = changes.updates.map((item) => ({
...item,
b_updateuser_id: String(authStore.user.id),
b_updatedatetime: now,
}));
return changes;
}
async function saveModule() {
normalizeModuleType();
const error = validate();
if (error) {
message.warning(error);
return;
}
saving.value = true;
try {
const oldModule = isNew.value
? null
: data.modules.find((item) => item.b_id === selectedKey.value);
const oldFields = oldModule
? data.fields.filter((item) => item.b_module_id === oldModule.b_id)
: [];
const oldPowers = oldModule
? data.powers.filter((item) => item.b_module_id === oldModule.b_id)
: [];
const previousKeys = new Set(
[
oldModule?.b_i18n,
...oldFields.map((item) => item.b_i18n),
...oldPowers.map((item) => item.b_i18n),
].filter(Boolean),
);
const fieldChanges = diffRows(draft.fields, oldFields);
const powerChanges = diffRows(draft.powers, oldPowers);
const removedPowerIds = new Set(powerChanges.deletes.map((item) => String(item.b_id)));
const assignedPowerDeletes = data.userPowers
.filter((item) => removedPowerIds.has(String(item.b_power_id)))
.map((item) => ({ b_id: item.b_id }));
const requests = [];
if (assignedPowerDeletes.length) {
requests.push(tableRequest("b_user_power", { deletes: assignedPowerDeletes }));
}
requests.push(
tableRequest("b_module", {
inserts: isNew.value ? [draft.module] : [],
updates: isNew.value ? [] : [draft.module],
}),
tableRequest("b_module_field", fieldChanges),
tableRequest("b_module_power", powerChanges),
tableRequest("b_i18n", await translationChanges(previousKeys)),
);
await saveChanges(requests);
const savedKey = draft.module.b_id;
message.success("模块配置已保存");
await loadAll(savedKey);
} catch (error) {
message.error(error.message || "模块保存失败");
} finally {
saving.value = false;
}
}
function requestDeleteModule(moduleId) {
const module = data.modules.find((item) => item.b_id === moduleId);
if (!module || ["M_SYSTEM", "M_MODULE"].includes(moduleId)) return;
if (data.modules.some((item) => item.b_parent_id === moduleId)) {
message.warning("请先删除下级模块");
return;
}
pendingDeleteKey.value = moduleId;
deleteConfirmOpen.value = true;
}
async function deleteModule(moduleId = draft.module?.b_id) {
const module = data.modules.find((item) => item.b_id === moduleId);
if (!module || ["M_SYSTEM", "M_MODULE"].includes(moduleId)) return;
if (data.modules.some((item) => item.b_parent_id === moduleId)) {
message.warning("请先删除下级模块");
return;
}
saving.value = true;
try {
const powers = data.powers.filter((item) => item.b_module_id === moduleId);
const powerIds = new Set(powers.map((item) => String(item.b_id)));
const fields = data.fields.filter((item) => item.b_module_id === moduleId);
const keys = new Set(
[
module.b_i18n,
...fields.map((item) => item.b_i18n),
...powers.map((item) => item.b_i18n),
].filter(Boolean),
);
await saveChanges([
tableRequest("b_user_power", {
deletes: data.userPowers
.filter((item) => powerIds.has(String(item.b_power_id)))
.map((item) => ({ b_id: item.b_id })),
}),
tableRequest("b_user_module", {
deletes: data.userModules
.filter((item) => item.b_module_id === moduleId)
.map((item) => ({ b_id: item.b_id })),
}),
tableRequest("b_module_power", { deletes: powers.map((item) => ({ b_id: item.b_id })) }),
tableRequest("b_module_field", { deletes: fields.map((item) => ({ b_id: item.b_id })) }),
tableRequest("b_i18n", {
deletes: data.translations
.filter((item) => keys.has(item.b_key))
.map((item) => ({ b_id: item.b_id })),
}),
tableRequest("b_module", { deletes: [{ b_id: moduleId }] }),
]);
message.success("模块已删除");
deleteConfirmOpen.value = false;
pendingDeleteKey.value = "";
await loadAll(selectedKey.value === moduleId ? "" : selectedKey.value);
} catch (error) {
message.error(error.message || "模块删除失败");
} finally {
saving.value = false;
}
}
function resetDraft() {
if (isNew.value) {
clearDraft();
return;
}
selectModule(selectedKey.value);
}
function setBoolean(record, field, checked) {
record[field] = checked ? 1 : 0;
}
onMounted(async () => {
await permissionStore.load();
await loadAll();
});
</script>
<template>
<div class="module-management">
<ModuleTreePanel
:modules="data.modules"
:selected-key="selectedKey"
:loading="loading"
:can-create="canCreate"
:can-delete="canDelete"
@select="selectModule"
@add="handleAdd"
@delete="requestDeleteModule"
@refresh="loadAll()"
/>
<main class="module-workspace">
<a-spin :spinning="loading || saving">
<template v-if="draft.module">
<header class="workspace-header">
<div class="title-block">
<div class="title-line">
<h1>{{ draft.module.b_name }}</h1>
<a-tag>{{ draft.module.b_id }}</a-tag>
<a-tag color="blue">{{ moduleKindLabel }}</a-tag>
</div>
</div>
<div class="header-switches">
<div class="switch-item">
<span class="switch-label">菜单显示</span>
<a-switch
:checked="draft.module.b_canmenu === 1"
:disabled="draft.module.b_type !== 'directory' && !draft.module.b_route?.trim()"
@change="setBoolean(draft.module, 'b_canmenu', $event)"
/>
</div>
<div class="switch-item">
<span class="switch-label">
{{ draft.module.b_type === "directory" ? "启用模块" : "启用页面" }}
</span>
<a-switch
:checked="draft.module.b_canuse === 1"
@change="setBoolean(draft.module, 'b_canuse', $event)"
/>
</div>
</div>
<div class="header-actions">
<a-button :disabled="!dirty" @click="resetDraft">
<RotateCcw :size="15" />取消
</a-button>
<a-popconfirm
title="确定删除当前模块吗?"
:disabled="
isNew || !canDelete || ['M_SYSTEM', 'M_MODULE'].includes(draft.module.b_id)
"
@confirm="deleteModule()"
>
<a-button
danger
:disabled="
isNew || !canDelete || ['M_SYSTEM', 'M_MODULE'].includes(draft.module.b_id)
"
>
<Trash2 :size="15" />删除
</a-button>
</a-popconfirm>
<a-button
type="primary"
:loading="saving"
:disabled="!dirty || !canSave"
@click="saveModule"
>
<Save :size="15" />保存
</a-button>
</div>
</header>
<ModuleBasicInfoPanel
v-if="draft.module.b_type === 'directory'"
v-model:module="draft.module"
:directory-parent-options="directoryParentOptions"
:is-new="isNew"
:parent-options="parentOptions"
@update-module-id="updateModuleId"
/>
<a-tabs v-else v-model:active-key="activeTab" class="workspace-tabs">
<a-tab-pane key="basic">
<template #tab>
<span class="tab-label"><FileText :size="15" />基本信息</span>
</template>
<ModuleBasicInfoPanel
v-model:module="draft.module"
:directory-parent-options="directoryParentOptions"
:is-new="isNew"
:parent-options="parentOptions"
@update-module-id="updateModuleId"
/>
</a-tab-pane>
<a-tab-pane key="fields">
<template #tab>
<span class="tab-label">
<Database :size="15" />字段定义 <a-badge :count="draft.fields.length" />
</span>
</template>
<ModuleFieldDefinitionPanel
v-model:fields="draft.fields"
v-model:translations="draft.translations"
:can-save="canSave"
@add="addField"
/>
</a-tab-pane>
<a-tab-pane key="powers">
<template #tab>
<span class="tab-label">
<ShieldCheck :size="15" />操作权限 <a-badge :count="draft.powers.length" />
</span>
</template>
<ModulePowerPanel v-model:powers="draft.powers" :can-save="canSave" @add="addPower" />
</a-tab-pane>
<a-tab-pane key="i18n">
<template #tab>
<span class="tab-label"><Languages :size="15" />多语言</span>
</template>
<ModuleTranslationsPanel
v-model:custom-keys="customI18nKeys"
v-model:translations="draft.translations"
:can-save="canSave"
:keys="translationKeys"
/>
</a-tab-pane>
</a-tabs>
</template>
<a-empty v-else description="请选择模块,或右键树节点创建模块和页面" />
</a-spin>
</main>
<a-modal
v-model:open="deleteConfirmOpen"
title="删除模块"
ok-text="删除"
cancel-text="取消"
ok-type="danger"
:confirm-loading="saving"
@ok="deleteModule(pendingDeleteKey)"
>
<p>
确定删除
{{ pendingDeleteModule?.b_name || pendingDeleteKey }} 吗?关联字段、权限和多语言将一并删除。
</p>
</a-modal>
</div>
</template>
<style scoped>
.module-management {
box-sizing: border-box;
display: grid;
grid-template-columns: 250px minmax(0, 1fr);
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
}
.module-workspace {
display: flex;
height: 100%;
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: hidden;
padding: 0 10px 6px;
}
.workspace-header {
display: flex;
min-height: 56px;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 6px 0;
border-bottom: 1px solid #f0f0f0;
}
.header-switches,
.switch-item,
.title-line,
.header-actions,
.tab-label {
display: flex;
align-items: center;
}
.header-switches {
flex: 0 0 auto;
gap: 16px;
padding: 0 14px;
}
.switch-item {
gap: 8px;
}
.switch-label {
color: #4b5563;
font-size: 13px;
white-space: nowrap;
}
.title-line {
gap: 9px;
}
h1 {
margin: 0;
color: #111827;
font-size: 20px;
line-height: 1.3;
}
.header-actions {
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.tab-label {
gap: 6px;
}
.workspace-tabs {
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
}
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container) {
height: 100%;
min-height: 0;
}
:deep(.module-workspace > .ant-spin) {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
}
:deep(.module-workspace > .ant-spin > .ant-spin-container),
:deep(.module-workspace > .ant-spin-nested-loading > .ant-spin-container) {
display: flex;
height: 100%;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
}
:deep(.workspace-tabs > .ant-tabs-body-holder) {
min-height: 0;
flex: 1 1 auto;
}
:deep(.workspace-tabs > .ant-tabs-body-holder > .ant-tabs-body),
:deep(.workspace-tabs > .ant-tabs-body-holder > .ant-tabs-body > .ant-tabs-content),
:deep(.workspace-tabs .ant-tabs-tabpane) {
height: 100%;
min-height: 0;
}
:deep(.workspace-tabs .ant-tabs-tabpane) {
display: flex;
flex-direction: column;
overflow: hidden;
}
:deep(.workspace-tabs > .ant-tabs-nav) {
flex: 0 0 auto;
margin-bottom: 4px;
}
@media (max-width: 980px) {
.module-management {
grid-template-columns: 220px minmax(0, 1fr);
}
.workspace-header {
align-items: flex-start;
flex-direction: column;
padding-block: 8px;
}
}
@media (max-width: 720px) {
.module-management {
grid-template-rows: 180px minmax(0, 1fr);
grid-template-columns: 1fr;
}
.module-workspace {
padding: 0 8px 8px;
}
}
</style>