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

821 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, RotateCcw, Save, ShieldCheck, Trash2 } from "@lucide/vue";
import { loadDataApi, nextIdApi, saveObjectApi } from "@/services/api";
import { useAuthStore } from "@/stores/auth";
import { usePermissionStore } from "@/stores/permissions";
import { I18N_LOCALE_CODES } from "@/utils/i18n";
import ModuleBasicInfoPanel from "./components/ModuleBasicInfoPanel.vue";
import ModuleFieldDefinitionPanel from "./components/ModuleFieldDefinitionPanel.vue";
import ModulePowerPanel from "./components/ModulePowerPanel.vue";
import ModuleTreePanel from "./components/ModuleTreePanel.vue";
const PROTECTED_MODULE_CODES = ["m_module", "m_i18n", "m_biz"];
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 deleteConfirmOpen = ref(false);
const pendingDeleteKey = ref("");
const original = ref("");
const originalTranslationKeys = 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_code + ")" })),
);
const directoryParentOptions = computed(() =>
data.modules
.filter((item) => item.b_type === "directory")
.map((item) => ({ value: item.b_id, label: item.b_name + " (" + item.b_code + ")" })),
);
const moduleKindLabel = computed(() => {
if (draft.module?.b_type === "directory") return "模块";
return draft.module?.b_route?.trim() ? "页面模块" : "数据模块";
});
const pendingDeleteModule = computed(() =>
data.modules.find((item) => item.b_code === 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),
].filter(Boolean);
}
function clearDraft() {
selectedKey.value = "";
isNew.value = false;
draft.module = null;
draft.fields = [];
draft.powers = [];
draft.translations = {};
original.value = "";
originalTranslationKeys.value = [];
activeTab.value = "basic";
}
function selectModule(moduleCode) {
const module = data.modules.find((item) => item.b_code === moduleCode);
if (!module) return;
selectedKey.value = moduleCode;
isNew.value = false;
draft.module = clone(module);
draft.fields = clone(data.fields.filter((item) => item.b_module_id === module.b_id));
draft.powers = clone(data.powers.filter((item) => item.b_module_id === module.b_id));
const keys = new Set(rowKeys());
data.translations.forEach((item) => {
if (String(item.b_key).split(".").includes(module.b_code)) keys.add(item.b_key);
});
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;
});
originalTranslationKeys.value = Array.from(keys);
original.value = serializeDraft();
if (module.b_type === "directory") activeTab.value = "basic";
}
async function loadAll(preferredKey = selectedKey.value) {
loading.value = true;
try {
const [modules, fields, powers, translations, userModules, userPowers] = await Promise.all([
loadDataApi("b_module", null, "b_xh ASC"),
loadDataApi("b_module_field", null, "b_xh ASC"),
loadDataApi("b_module_power", null, "b_xh ASC"),
loadDataApi("b_i18n", null, "b_key ASC"),
loadDataApi("b_user_module", null, "b_id ASC"),
loadDataApi("b_user_power", null, "b_id ASC"),
]);
Object.assign(data, {
modules: modules.data,
fields: fields.data,
powers: powers.data,
translations: translations.data,
userModules: userModules.data,
userPowers: userPowers.data,
});
const target = data.modules.some((item) => item.b_code === 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_code));
let index = 1;
while (existing.has(prefix + index)) index += 1;
return prefix + index;
}
function parentForNew(contextKey) {
const context = data.modules.find((item) => item.b_code === contextKey);
if (!context) return null;
return context.b_type === "directory" ? context.b_id : context.b_parent_id || null;
}
async function addModule(kind, contextKey) {
const isDirectory = kind === "directory";
const id = String((await nextIdApi()).data);
const code = nextModuleCode(isDirectory ? "m_dir_new_" : "m_page_new_");
const parentId = parentForNew(contextKey);
const route = isDirectory ? null : "/" + code.replace(/^m_/, "").replaceAll("_", "-");
draft.module = {
b_id: id,
b_code: code,
b_parent_id: parentId,
b_name: isDirectory ? "新模块" : "新页面",
b_i18n: "module." + code,
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 DESC",
b_canmenu: isDirectory || route ? 1 : 0,
b_canuse: 1,
b_xh: 100,
};
draft.fields = [];
draft.powers = [];
originalTranslationKeys.value = [];
draft.translations = {
["module." + code]: { "zh-CN": draft.module.b_name },
};
selectedKey.value = code;
isNew.value = true;
original.value = "";
activeTab.value = "basic";
}
async function handleAdd(kind, contextKey) {
try {
await addModule(kind === "module" ? "directory" : "page", contextKey);
} catch (error) {
message.error(error.message || "模块创建失败");
}
}
function updateModuleCode(value) {
draft.module.b_code = String(value || "").toLowerCase();
}
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_code)) return "模块编码格式不正确";
if (!module.b_name?.trim()) return "模块名称不能为空";
if (data.modules.some((item) => item.b_code === module.b_code && 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].filter(Boolean);
if (identifiers.some((item) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(item))) {
return "表名和字段名只能包含字母、数字和下划线";
}
const orderItemPattern =
/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*(?:\s+(?:ASC|DESC))?$/i;
if (
module.b_orderfield &&
String(module.b_orderfield)
.split(",")
.some((item) => !orderItemPattern.test(item.trim()))
) {
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(), ...Object.keys(draft.translations)]);
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 I18N_LOCALE_CODES) {
const value = draft.translations[key]?.[locale]?.trim();
if (!value) continue;
const old = oldRows.find((item) => item.b_key === key && item.b_locale === locale);
if (old) {
desired.push({ ...old, b_value: value });
} else {
missing.push({ key, locale, value });
}
}
}
oldRows
.filter((item) => !I18N_LOCALE_CODES.includes(item.b_locale))
.forEach((item) => desired.push(item));
const ids = [];
for (let index = 0; index < missing.length; index += 1) {
const response = await nextIdApi();
ids.push(String(response.data));
}
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_code === 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(
[
...originalTranslationKeys.value,
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({
table: "b_user_power",
key_field: "b_id",
inserts: [],
updates: [],
deletes: assignedPowerDeletes,
});
}
const i18nChanges = await translationChanges(previousKeys);
requests.push(
{
table: "b_module",
key_field: "b_id",
inserts: isNew.value ? [draft.module] : [],
updates: isNew.value ? [] : [draft.module],
deletes: [],
},
{ table: "b_module_field", key_field: "b_id", ...fieldChanges },
{ table: "b_module_power", key_field: "b_id", ...powerChanges },
{ table: "b_i18n", key_field: "b_id", ...i18nChanges },
);
await saveObjectApi(
requests.filter(
(request) => request.inserts.length || request.updates.length || request.deletes.length,
),
);
const savedKey = draft.module.b_code;
message.success("模块配置已保存");
await loadAll(savedKey);
} catch (error) {
message.error(error.message || "模块保存失败");
} finally {
saving.value = false;
}
}
function requestDeleteModule(moduleCode) {
const module = data.modules.find((item) => item.b_code === moduleCode);
if (!module || PROTECTED_MODULE_CODES.includes(moduleCode)) return;
if (data.modules.some((item) => item.b_parent_id === module.b_id)) {
message.warning("请先删除下级模块");
return;
}
pendingDeleteKey.value = moduleCode;
deleteConfirmOpen.value = true;
}
async function deleteModule(moduleCode = draft.module?.b_code) {
const module = data.modules.find((item) => item.b_code === moduleCode);
if (!module || PROTECTED_MODULE_CODES.includes(moduleCode)) return;
if (data.modules.some((item) => item.b_parent_id === module.b_id)) {
message.warning("请先删除下级模块");
return;
}
saving.value = true;
try {
const powers = data.powers.filter((item) => item.b_module_id === module.b_id);
const powerIds = new Set(powers.map((item) => String(item.b_id)));
const fields = data.fields.filter((item) => item.b_module_id === module.b_id);
const requests = [
{
table: "b_user_power",
key_field: "b_id",
inserts: [],
updates: [],
deletes: data.userPowers
.filter((item) => powerIds.has(String(item.b_power_id)))
.map((item) => ({ b_id: item.b_id })),
},
{
table: "b_user_module",
key_field: "b_id",
inserts: [],
updates: [],
deletes: data.userModules
.filter((item) => item.b_module_id === module.b_id)
.map((item) => ({ b_id: item.b_id })),
},
{
table: "b_module_power",
key_field: "b_id",
inserts: [],
updates: [],
deletes: powers.map((item) => ({ b_id: item.b_id })),
},
{
table: "b_module_field",
key_field: "b_id",
inserts: [],
updates: [],
deletes: fields.map((item) => ({ b_id: item.b_id })),
},
{
table: "b_module",
key_field: "b_id",
inserts: [],
updates: [],
deletes: [{ b_id: module.b_id }],
},
];
await saveObjectApi(
requests.filter(
(request) => request.inserts.length || request.updates.length || request.deletes.length,
),
);
message.success("模块已删除");
deleteConfirmOpen.value = false;
pendingDeleteKey.value = "";
await loadAll(selectedKey.value === moduleCode ? "" : 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"
:protected-keys="PROTECTED_MODULE_CODES"
: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_code }}</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 || PROTECTED_MODULE_CODES.includes(draft.module.b_code)
"
@confirm="deleteModule()"
>
<a-button
danger
:disabled="
isNew || !canDelete || PROTECTED_MODULE_CODES.includes(draft.module.b_code)
"
>
<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>
<a-tabs 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"
v-model:translations="draft.translations"
:directory-parent-options="directoryParentOptions"
:parent-options="parentOptions"
@update-module-code="updateModuleCode"
/>
</a-tab-pane>
<a-tab-pane v-if="draft.module.b_type !== 'directory'" 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"
:module-id="draft.module.b_id"
:module-code="draft.module.b_code"
/>
</a-tab-pane>
<a-tab-pane v-if="draft.module.b_type !== 'directory'" 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"
:module-id="draft.module.b_id"
:module-code="draft.module.b_code"
/>
</a-tab-pane>
</a-tabs>
</template>
<a-empty
v-else
class="workspace-empty"
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;
}
.workspace-empty {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-direction: column;
margin: 0;
}
: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>