20260721223033

This commit is contained in:
oneao committed 2026-07-21 22:30:33 +08:00
1 parent bab8e2972f
commit e9f099f044
26 files changed
+1687 -349

No files matched your search

@@ -0,0 +1,820 @@
<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>