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

+187 -2
View File
@@ -1,21 +1,200 @@
<script setup>
import { computed, nextTick, ref, useSlots } from "vue";
import { computed, defineComponent, h, nextTick, ref, useSlots } from "vue";
import { useRafFn, useResizeObserver } from "@vueuse/core";
import { Checkbox, Input, InputNumber, Select } from "antdv-next";
import { StkTable } from "stk-table-vue";
defineOptions({ inheritAttrs: false });
defineProps({
const props = defineProps({
virtual: { type: Boolean, default: true },
virtualX: { type: Boolean, default: true },
colResizable: { type: Boolean, default: true },
bordered: { type: [Boolean, String], default: true },
noDataFull: { type: Boolean, default: true },
columns: { type: Array, default: () => [] },
dataSource: { type: Array, default: () => [] },
rowKey: { type: [String, Function], default: "id" },
rowSelection: { type: Object, default: null },
});
const emit = defineEmits(["update:columns"]);
function getRowKey(row) {
return typeof props.rowKey === "function" ? props.rowKey(row) : row?.[props.rowKey];
}
const selectedRowKeySet = computed(
() => new Set(props.rowSelection?.selectedRowKeys || []),
);
function changeSelection(row, checked) {
const key = getRowKey(row);
const nextKeys = new Set(selectedRowKeySet.value);
if (checked) nextKeys.add(key);
else nextKeys.delete(key);
const keys = Array.from(nextKeys);
const rows = props.dataSource.filter((item) => nextKeys.has(getRowKey(item)));
props.rowSelection?.onChange?.(keys, rows);
}
function changeAllSelection(checked) {
const visibleKeys = props.dataSource.map(getRowKey);
const nextKeys = new Set(selectedRowKeySet.value);
visibleKeys.forEach((key) => {
if (checked) nextKeys.add(key);
else nextKeys.delete(key);
});
const keys = Array.from(nextKeys);
const rows = props.dataSource.filter((item) => nextKeys.has(getRowKey(item)));
props.rowSelection?.onChange?.(keys, rows);
}
const SelectionCell = defineComponent({
props: ["row"],
setup(cellProps) {
return () =>
h(Checkbox, {
checked: selectedRowKeySet.value.has(getRowKey(cellProps.row)),
"onUpdate:checked": (checked) => changeSelection(cellProps.row, checked),
onClick: (event) => event.stopPropagation(),
});
},
});
const SelectionHeaderCell = defineComponent({
setup() {
return () => {
const visibleKeys = props.dataSource.map(getRowKey);
const selectedCount = visibleKeys.filter((key) => selectedRowKeySet.value.has(key)).length;
return h(Checkbox, {
checked: visibleKeys.length > 0 && selectedCount === visibleKeys.length,
indeterminate: selectedCount > 0 && selectedCount < visibleKeys.length,
"onUpdate:checked": changeAllSelection,
onClick: (event) => event.stopPropagation(),
});
};
},
});
const EditableCell = defineComponent({
props: ["row", "col", "cellValue"],
setup(cellProps) {
function editorOptions() {
return typeof cellProps.col.editor === "string"
? { type: cellProps.col.editor }
: cellProps.col.editor || {};
}
function updateValue(value) {
cellProps.row[cellProps.col.dataIndex] = value;
editorOptions().onChange?.(value, cellProps.row, cellProps.col);
}
return () => {
const editor = editorOptions();
const disabled =
typeof editor.disabled === "function" ? editor.disabled(cellProps.row) : editor.disabled;
const commonProps = {
size: "small",
disabled,
style: { width: "100%" },
};
if (editor.type === "number") {
return h(InputNumber, {
...commonProps,
value: cellProps.row[cellProps.col.dataIndex],
min: editor.min,
max: editor.max,
precision: editor.precision,
"onUpdate:value": updateValue,
});
}
if (editor.type === "select") {
return h(Select, {
...commonProps,
value: cellProps.row[cellProps.col.dataIndex],
options: editor.options || [],
"onUpdate:value": updateValue,
});
}
if (editor.type === "checkbox") {
const checkedValue = editor.checkedValue ?? 1;
const uncheckedValue = editor.uncheckedValue ?? 0;
return h(Checkbox, {
disabled,
checked: cellProps.row[cellProps.col.dataIndex] === checkedValue,
"onUpdate:checked": (checked) => updateValue(checked ? checkedValue : uncheckedValue),
onClick: (event) => event.stopPropagation(),
});
}
return h(Input, {
...commonProps,
value: cellProps.row[cellProps.col.dataIndex],
allowClear: editor.allowClear ?? true,
maxlength: editor.maxlength,
placeholder:
typeof editor.placeholder === "function"
? editor.placeholder(cellProps.row)
: editor.placeholder,
status: typeof editor.status === "function" ? editor.status(cellProps.row) : editor.status,
"onUpdate:value": updateValue,
});
};
},
});
// StkTable 的编辑单元格和多选列统一在封装层生成,业务页面只声明 editor / rowSelection。
const tableColumnsModel = computed({
get: () => {
const columns = props.columns.map((column) =>
column.editor && !column.customCell
? { ...column, customCell: EditableCell, __fmsEditor: true }
: column,
);
if (!props.rowSelection) return columns;
return [
{
dataIndex: "__fmsSelection",
key: "__fmsSelection",
title: "",
width: 44,
fixed: "left",
align: "center",
headerAlign: "center",
customCell: SelectionCell,
customHeaderCell: SelectionHeaderCell,
__fmsSelection: true,
},
...columns,
];
},
set: (value) =>
emit(
"update:columns",
value
.filter((column) => !column.__fmsSelection)
.map((column) => {
if (!column.__fmsEditor) return column;
const rest = { ...column };
delete rest.customCell;
delete rest.__fmsEditor;
return rest;
}),
),
});
const slots = useSlots();
const tableRef = ref(null);
// 表格样式
const customVars = ref({
"--header-row-height": "40px",
"--row-height": "34px",
"--footer-row-height": "36px",
});
const { resume: refreshLayout } = useRafFn(
async () => {
await nextTick();
@@ -49,10 +228,14 @@ const forwardSlots = computed(() => {
ref="tableRef"
class="fms-table"
v-bind="$attrs"
:style="customVars"
:auto-resize="false"
:virtual="virtual"
:virtual-x="virtualX"
:col-resizable="colResizable"
v-model:columns="tableColumnsModel"
:data-source="dataSource"
:row-key="rowKey"
:bordered="bordered"
:no-data-full="noDataFull"
>
@@ -68,6 +251,8 @@ const forwardSlots = computed(() => {
</StkTable>
</template>
<style scoped>
.fms-table {
width: 100%;
@@ -66,7 +66,7 @@ export function useLookupOptions(props) {
}
const { start: startSearchTimer, stop: stopSearchTimer } = useTimeoutFn(
() => load(query.value),
(searchText) => load(searchText),
() => props.searchDelay,
{ immediate: false },
);
@@ -76,8 +76,11 @@ export function useLookupOptions(props) {
function search(value) {
stopResetTimer();
query.value = String(value ?? "");
startSearchTimer();
const searchText = String(value ?? "");
query.value = searchText;
requestId += 1;
loading.value = false;
startSearchTimer(searchText);
}
function open() {
@@ -142,6 +142,7 @@ const fallbackMenuItems = [
{ key: "/system/mappingConfig", label: "映射配置" },
{ key: "/system/autoId", label: "自动编码" },
{ key: "/system/modules", label: "模块管理" },
{ key: "/system/i18n", label: "多语言管理" },
],
},
{
+1 -41
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from "vue-router";
import NProgress from "nprogress";
import { message } from "antdv-next";
import "nprogress/nprogress.css";
import routes from "./routes";
import { useAuthStore } from "@/stores/auth";
import { usePermissionStore } from "@/stores/permissions";
@@ -13,47 +14,6 @@ function getRedirectPath(value) {
: "/dashboard";
}
const routes = [
{
path: "/",
component: () => import("@/layouts/DefaultLayout.vue"),
meta: { requiresAuth: true },
children: [
{
path: "",
redirect: "/dashboard",
},
{
path: "dashboard",
name: "dashboard",
component: () => import("@/views/DashboardView.vue"),
meta: { title: "工作台", menuKey: "/dashboard" },
},
{
path: "system/modules",
name: "system-modules",
component: () => import("@/views/module-management/ModuleManagementView.vue"),
meta: {
title: "模块管理",
section: "系统管理",
menuKey: "/system/modules",
moduleCode: "M_MODULE",
},
},
],
},
{
path: "/login",
name: "login",
component: () => import("@/views/LoginView.vue"),
meta: { title: "登录" },
},
{
path: "/:pathMatch(.*)*",
redirect: "/dashboard",
},
];
const router = createRouter({
history: createWebHistory(),
routes,
+53
View File
@@ -0,0 +1,53 @@
const routes = [
{
path: "/",
component: () => import("@/layouts/DefaultLayout.vue"),
meta: { requiresAuth: true },
children: [
{
path: "",
redirect: "/dashboard",
},
{
path: "dashboard",
name: "dashboard",
component: () => import("@/views/dashboard/index.vue"),
meta: { title: "工作台", menuKey: "/dashboard" },
},
{
path: "system/modules",
name: "system-modules",
component: () => import("@/views/module-management/index.vue"),
meta: {
title: "模块管理",
section: "系统管理",
menuKey: "/system/modules",
moduleCode: "m_module",
},
},
{
path: "system/i18n",
name: "system-i18n",
component: () => import("@/views/i18n-management/index.vue"),
meta: {
title: "多语言管理",
section: "系统管理",
menuKey: "/system/i18n",
moduleCode: "m_i18n",
},
},
],
},
{
path: "/login",
name: "login",
component: () => import("@/views/login/index.vue"),
meta: { title: "登录" },
},
{
path: "/:pathMatch(.*)*",
redirect: "/dashboard",
},
];
export default routes;
+29 -6
View File
@@ -11,26 +11,46 @@ export const usePermissionStore = defineStore("permissions", () => {
const moduleSet = computed(() => new Set(moduleCodes.value));
const powerSet = computed(() => new Set(powerCodes.value));
const isG3soft = computed(() => {
const authStore = useAuthStore();
return String(authStore.user?.account || "").trim().toLowerCase() === "g3soft";
});
async function load() {
const authStore = useAuthStore();
const userId = String(authStore.user?.id || "");
if (isG3soft.value) {
loadedUserId.value = `g3soft:${userId}`;
moduleCodes.value = [];
powerCodes.value = [];
return;
}
if (!/^\d+$/.test(userId)) throw new Error("登录用户 ID 无效,请重新登录");
if (loadedUserId.value === userId) return;
if (loadingPromise.value) return loadingPromise.value;
loadingPromise.value = Promise.all([
loadDataApi("b_module", "b_canuse = 1", "b_id ASC"),
loadDataApi("b_user_module", `b_user_id = ${userId}`, "b_id ASC"),
loadDataApi("b_user_power", `b_user_id = ${userId}`, "b_id ASC"),
loadDataApi("b_module_power", "b_canuse = 1", "b_id ASC"),
])
.then(([userModules, userPowers, powers]) => {
.then(([modules, userModules, userPowers, powers]) => {
const moduleCodeById = new Map(
modules.data.map((module) => [String(module.b_id), module.b_code]),
);
const powerById = new Map(powers.data.map((power) => [String(power.b_id), power]));
moduleCodes.value = userModules.data.map((item) => item.b_module_id);
moduleCodes.value = userModules.data
.map((item) => moduleCodeById.get(String(item.b_module_id)))
.filter(Boolean);
powerCodes.value = userPowers.data
.map((item) => powerById.get(String(item.b_power_id)))
.filter(Boolean)
.map((power) => `${power.b_module_id}.${power.b_code}`);
.map((power) => {
const moduleCode = moduleCodeById.get(String(power.b_module_id));
return moduleCode ? `${moduleCode}.${power.b_code}` : "";
})
.filter(Boolean);
loadedUserId.value = userId;
})
.finally(() => {
@@ -40,11 +60,14 @@ export const usePermissionStore = defineStore("permissions", () => {
}
function canModule(moduleCode) {
return !moduleCode || moduleSet.value.has(moduleCode);
return isG3soft.value || !moduleCode || moduleSet.value.has(moduleCode);
}
function canPower(moduleCode, powerCode) {
return canModule(moduleCode) && powerSet.value.has(`${moduleCode}.${powerCode}`);
return (
isG3soft.value ||
(canModule(moduleCode) && powerSet.value.has(`${moduleCode}.${powerCode}`))
);
}
function clear() {
@@ -53,5 +76,5 @@ export const usePermissionStore = defineStore("permissions", () => {
powerCodes.value = [];
}
return { load, canModule, canPower, clear, moduleCodes, powerCodes };
return { load, canModule, canPower, clear, moduleCodes, powerCodes, isG3soft };
});
+10 -12
View File
@@ -1,3 +1,6 @@
/* ============================================================
* 基础重置 / Base Reset
* ============================================================ */
:root {
color: #1f2329;
background: #f5f7fa;
@@ -39,6 +42,9 @@ a {
text-decoration: none;
}
/* ============================================================
* 图标 / Icons
* ============================================================ */
.lucide {
width: 16px;
height: 16px;
@@ -47,6 +53,9 @@ a {
vertical-align: -0.125em;
}
/* ============================================================
* 主题变量 / Theme Tokens
* ============================================================ */
.theme-root {
--fms-bg: #f5f7fa;
--fms-surface: #ffffff;
@@ -59,15 +68,4 @@ a {
color: var(--fms-text);
background: var(--fms-bg);
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
}
+26
View File
@@ -0,0 +1,26 @@
export const I18N_LOCALES = [
{ value: "zh-CN", label: "简体中文", shortLabel: "简体" },
{ value: "en-US", label: "English", shortLabel: "English" },
{ value: "zh-TW", label: "繁體中文", shortLabel: "繁體" },
];
export const I18N_LOCALE_CODES = I18N_LOCALES.map((item) => item.value);
export function getI18nNamespace(key) {
return String(key || "").split(".", 1)[0] || "common";
}
export function isValidI18nKey(key) {
return /^[A-Za-z][A-Za-z0-9_.:-]{1,149}$/.test(String(key || ""));
}
export function groupI18nRows(rows) {
const groups = {};
rows.forEach((item) => {
const key = item.b_key;
groups[key] ||= { key, values: {}, records: {} };
groups[key].values[item.b_locale] = item.b_value;
groups[key].records[item.b_locale] = item;
});
return groups;
}
@@ -0,0 +1,533 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { App } from "antdv-next";
import { Languages, Plus, RefreshCw, RotateCcw, Save, Trash2 } from "@lucide/vue";
import FmsTable from "@/components/FmsTable.vue";
import { loadDataApi, nextIdApi, saveObjectApi } from "@/services/api";
import { useAuthStore } from "@/stores/auth";
import { usePermissionStore } from "@/stores/permissions";
import {
I18N_LOCALES,
I18N_LOCALE_CODES,
getI18nNamespace,
groupI18nRows,
isValidI18nKey,
} from "@/utils/i18n";
const { message } = App.useApp();
const route = useRoute();
const authStore = useAuthStore();
const permissionStore = usePermissionStore();
const loading = ref(false);
const saving = ref(false);
const original = ref("");
const groups = ref({});
const selectedRowKeys = ref([]);
const keyModuleCodes = ref(new Map());
const data = reactive({ modules: [], fields: [], powers: [], translations: [] });
const filters = reactive({
keyword: "",
namespace: undefined,
moduleCode: undefined,
locale: undefined,
status: "all",
});
const createDialog = reactive({ open: false, key: "", values: {} });
const deleteDialogOpen = ref(false);
const canCreate = computed(() => permissionStore.canPower("m_i18n", "create"));
const canUpdate = computed(() => permissionStore.canPower("m_i18n", "update"));
const canDelete = computed(() => permissionStore.canPower("m_i18n", "delete"));
const dirty = computed(() => serializeGroups(groups.value) !== original.value);
const moduleOptions = computed(() =>
data.modules.map((item) => ({
value: item.b_code,
label: item.b_name + " (" + item.b_code + ")",
})),
);
const allRows = computed(() =>
Object.values(groups.value)
.map((group) => {
const missingLocales = I18N_LOCALE_CODES.filter((locale) => !group.values[locale]?.trim());
return {
...group,
...Object.fromEntries(
I18N_LOCALE_CODES.map((locale) => [locale, group.values[locale] || ""]),
),
namespace: getI18nNamespace(group.key),
moduleCodes: Array.from(keyModuleCodes.value.get(group.key) || inferModuleCodes(group.key)),
moduleCodesText:
Array.from(keyModuleCodes.value.get(group.key) || inferModuleCodes(group.key)).join(
"、",
) || "通用 / 未绑定",
missingLocales,
statusText: missingLocales.length ? `缺 ${missingLocales.length}` : "完整",
};
})
.sort((left, right) => left.key.localeCompare(right.key)),
);
const namespaceOptions = computed(() =>
Array.from(new Set(allRows.value.map((item) => item.namespace)))
.sort()
.map((value) => ({ value, label: value })),
);
const filteredRows = computed(() => {
const keyword = filters.keyword.trim().toLowerCase();
return allRows.value.filter((row) => {
if (keyword) {
const searchText = [row.key, ...Object.values(row.values)].join(" ").toLowerCase();
if (!searchText.includes(keyword)) return false;
}
if (filters.namespace && row.namespace !== filters.namespace) return false;
if (filters.moduleCode && !row.moduleCodes.includes(filters.moduleCode)) return false;
const focusedLocales = filters.locale ? [filters.locale] : I18N_LOCALE_CODES;
const missingCount = focusedLocales.filter((locale) => !row.values[locale]?.trim()).length;
if (filters.status === "missing" && missingCount === 0) return false;
if (filters.status === "complete" && missingCount > 0) return false;
return true;
});
});
const visibleLocales = computed(() =>
filters.locale ? I18N_LOCALES.filter((item) => item.value === filters.locale) : I18N_LOCALES,
);
const columns = computed(() => [
{ title: "多语言键", dataIndex: "key", key: "key", width: 280, fixed: "left" },
{ title: "命名空间", dataIndex: "namespace", key: "namespace", width: 110 },
{ title: "关联模块", dataIndex: "moduleCodesText", key: "moduleCodes", width: 190 },
...visibleLocales.value.map((locale) => ({
title: locale.label,
dataIndex: locale.value,
key: locale.value,
locale: locale.value,
width: 250,
editor: {
maxlength: 500,
disabled: (row) => !canEditRow(row),
placeholder: `填写 ${locale.value} 文案`,
status: (row) => (row[locale.value]?.trim() ? undefined : "warning"),
onChange: (value, row) => {
row.values[locale.value] = value;
},
},
})),
{ title: "状态", dataIndex: "statusText", key: "status", width: 100, fixed: "right" },
]);
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
function serializeGroups(value) {
return JSON.stringify(
Object.values(value)
.map((item) => ({
key: item.key,
values: Object.fromEntries(
I18N_LOCALE_CODES.map((locale) => [locale, item.values[locale] || ""]),
),
}))
.sort((left, right) => left.key.localeCompare(right.key)),
);
}
function inferModuleCodes(key) {
const parts = String(key).split(".");
return new Set(data.modules.map((item) => item.b_code).filter((code) => parts.includes(code)));
}
function rebuildModuleReferences() {
const moduleById = new Map(data.modules.map((item) => [String(item.b_id), item.b_code]));
const result = new Map();
const bind = (key, moduleCode) => {
if (!key || !moduleCode) return;
if (!result.has(key)) result.set(key, new Set());
result.get(key).add(moduleCode);
};
data.modules.forEach((item) => bind(item.b_i18n, item.b_code));
data.fields.forEach((item) => bind(item.b_i18n, moduleById.get(String(item.b_module_id))));
data.powers.forEach((item) => bind(item.b_i18n, moduleById.get(String(item.b_module_id))));
keyModuleCodes.value = result;
}
function resetDraft() {
const nextGroups = groupI18nRows(data.translations);
[...data.modules, ...data.fields, ...data.powers].forEach((item) => {
if (item.b_i18n) {
nextGroups[item.b_i18n] ||= { key: item.b_i18n, values: {}, records: {} };
}
});
groups.value = nextGroups;
original.value = serializeGroups(groups.value);
selectedRowKeys.value = [];
}
async function loadAll() {
loading.value = true;
try {
const [modules, fields, powers, translations] = 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"),
]);
Object.assign(data, {
modules: modules.data,
fields: fields.data,
powers: powers.data,
translations: translations.data,
});
rebuildModuleReferences();
resetDraft();
if (typeof route.query.module === "string") filters.moduleCode = route.query.module;
} catch (error) {
message.error(error.message || "多语言资源加载失败");
} finally {
loading.value = false;
}
}
function openCreateDialog() {
const modulePrefix = filters.moduleCode ? "." + filters.moduleCode + "." : ".";
createDialog.key = (filters.namespace || "common") + modulePrefix;
createDialog.values = Object.fromEntries(I18N_LOCALE_CODES.map((locale) => [locale, ""]));
createDialog.open = true;
}
function addKey() {
const key = createDialog.key.trim();
if (!isValidI18nKey(key)) {
message.warning("多语言键需以字母开头,只能包含字母、数字、点、冒号、短横线和下划线");
return;
}
if (groups.value[key]) {
message.warning("多语言键已经存在");
return;
}
if (!I18N_LOCALE_CODES.some((locale) => createDialog.values[locale]?.trim())) {
message.warning("请至少填写一种语言的文案");
return;
}
groups.value = {
...groups.value,
[key]: { key, values: { ...createDialog.values }, records: {} },
};
createDialog.open = false;
}
function requestDeleteSelected() {
if (!selectedRowKeys.value.length) return;
deleteDialogOpen.value = true;
}
function deleteSelected() {
const selected = new Set(selectedRowKeys.value);
groups.value = Object.fromEntries(
Object.entries(groups.value).filter(([key]) => !selected.has(key)),
);
selectedRowKeys.value = [];
deleteDialogOpen.value = false;
}
function canEditRow(record) {
return Object.keys(record.records || {}).length ? canUpdate.value : canCreate.value;
}
async function saveTranslations() {
if (!dirty.value) return;
saving.value = true;
try {
const oldMap = new Map(
data.translations.map((item) => [item.b_key + "\u0000" + item.b_locale, item]),
);
const desiredMap = new Map();
Object.values(groups.value).forEach((group) => {
I18N_LOCALE_CODES.forEach((locale) => {
const value = group.values[locale]?.trim();
if (value) {
desiredMap.set(group.key + "\u0000" + locale, { key: group.key, locale, value });
}
});
});
const inserts = [];
const updates = [];
const deletes = [];
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
for (const [compoundKey, item] of desiredMap) {
const old = oldMap.get(compoundKey);
if (!old) {
const id = String((await nextIdApi()).data);
inserts.push({
b_id: id,
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,
});
} else if (old.b_value !== item.value) {
updates.push({
...old,
b_value: item.value,
b_updateuser_id: String(authStore.user.id),
b_updatedatetime: now,
});
}
}
for (const [compoundKey, old] of oldMap) {
const keyWasDeleted = !groups.value[old.b_key];
const managedLocale = I18N_LOCALE_CODES.includes(old.b_locale);
if (!desiredMap.has(compoundKey) && (keyWasDeleted || managedLocale)) {
deletes.push({ b_id: old.b_id });
}
}
if (!inserts.length && !updates.length && !deletes.length) {
resetDraft();
message.info("绑定中的空 Key 会继续保留,以便补充缺失翻译");
return;
}
await saveObjectApi([{ table: "b_i18n", key_field: "b_id", inserts, updates, deletes }]);
message.success("多语言资源已保存");
await loadAll();
} catch (error) {
message.error(error.message || "多语言资源保存失败");
} finally {
saving.value = false;
}
}
function resetFilters() {
Object.assign(filters, {
keyword: "",
namespace: undefined,
moduleCode: undefined,
locale: undefined,
status: "all",
});
}
watch(
() => route.query.module,
(value) => {
if (typeof value === "string") filters.moduleCode = value;
},
);
onMounted(async () => {
await permissionStore.load();
await loadAll();
});
</script>
<template>
<section class="i18n-management">
<header class="page-header">
<div>
<div class="page-title"><Languages />多语言资源中心</div>
<p>统一维护菜单、模块、权限、字段、提示、按钮与校验文案。</p>
</div>
<div class="header-actions">
<a-button :disabled="!dirty" @click="resetDraft"><RotateCcw />撤销修改</a-button>
<a-button :loading="loading" @click="loadAll"><RefreshCw />刷新</a-button>
<a-button type="primary" :loading="saving" :disabled="!dirty" @click="saveTranslations">
<Save />保存
</a-button>
</div>
</header>
<div class="filter-bar">
<a-input-search v-model:value="filters.keyword" allow-clear placeholder="搜索 key 或文案" />
<a-select
v-model:value="filters.namespace"
allow-clear
placeholder="命名空间"
:options="namespaceOptions"
/>
<a-select
v-model:value="filters.moduleCode"
show-search
allow-clear
placeholder="关联模块"
:options="moduleOptions"
/>
<a-select
v-model:value="filters.locale"
allow-clear
placeholder="聚焦语言"
:options="I18N_LOCALES"
/>
<a-select
v-model:value="filters.status"
:options="[
{ value: 'all', label: '全部状态' },
{ value: 'missing', label: '存在缺失' },
{ value: 'complete', label: '翻译完整' },
]"
/>
<a-button @click="resetFilters">重置筛选</a-button>
</div>
<div class="table-toolbar">
<div class="toolbar-actions">
<a-button type="primary" :disabled="!canCreate" @click="openCreateDialog">
<Plus />新增 Key
</a-button>
<a-button
danger
:disabled="!canDelete || !selectedRowKeys.length"
@click="requestDeleteSelected"
>
<Trash2 />批量删除
</a-button>
</div>
<span>共 {{ filteredRows.length }} 个 Key,已选择 {{ selectedRowKeys.length }} 个</span>
</div>
<div class="table-wrap">
<a-spin :spinning="loading">
<FmsTable
row-key="key"
:col-resizable="false"
:columns="columns"
:data-source="filteredRows"
:row-selection="rowSelection"
/>
</a-spin>
</div>
<a-modal
v-model:open="createDialog.open"
title="新增多语言 Key"
ok-text="添加"
cancel-text="取消"
@ok="addKey"
>
<a-form layout="vertical">
<a-form-item
label="多语言键"
required
extra="建议使用 namespace.module_code.name,例如 field.m_biz.customer.placeholder"
>
<a-input v-model:value="createDialog.key" :maxlength="150" />
</a-form-item>
<a-form-item v-for="locale in I18N_LOCALES" :key="locale.value" :label="locale.label">
<a-input v-model:value="createDialog.values[locale.value]" :maxlength="500" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="deleteDialogOpen"
title="删除多语言 Key"
ok-text="删除"
cancel-text="取消"
ok-type="danger"
@ok="deleteSelected"
>
<p>
确定删除选中的 {{ selectedRowKeys.length }} 个 Key 及其全部语言文本吗?已绑定这些 Key
的业务对象将显示回退文案。
</p>
</a-modal>
</section>
</template>
<style scoped>
.i18n-management {
display: flex;
min-height: 100%;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid var(--fms-border);
border-radius: 6px;
background: var(--fms-surface);
}
.page-header,
.filter-bar,
.table-toolbar,
.header-actions,
.toolbar-actions,
.page-title {
display: flex;
align-items: center;
}
.page-header,
.table-toolbar {
justify-content: space-between;
gap: 12px;
}
.page-title {
gap: 8px;
color: var(--fms-text);
font-size: 20px;
font-weight: 650;
}
.page-header p,
.table-toolbar,
.muted {
color: var(--fms-text-secondary);
}
.page-header p {
margin-top: 4px;
font-size: 13px;
}
.header-actions,
.toolbar-actions,
.filter-bar {
gap: 8px;
}
.filter-bar {
flex-wrap: wrap;
padding: 10px;
border-radius: 6px;
background: var(--fms-bg);
}
.filter-bar :deep(.ant-input-search) {
width: 260px;
}
.filter-bar :deep(.ant-select) {
width: 170px;
}
.table-wrap {
min-width: 0;
min-height: 420px;
flex: 1 1 auto;
}
.table-wrap :deep(.ant-spin-nested-loading),
.table-wrap :deep(.ant-spin-container),
.table-wrap :deep(.ant-spin) {
width: 100%;
height: 100%;
}
.table-wrap :deep(.stk-table .ant-input) {
min-width: 0;
}
</style>
@@ -1,11 +1,13 @@
<script setup>
import ModuleTranslationEditor from "./ModuleTranslationEditor.vue";
const props = defineProps({
directoryParentOptions: { type: Array, default: () => [] },
isNew: Boolean,
parentOptions: { type: Array, default: () => [] },
});
const emit = defineEmits(["update-module-id"]);
const emit = defineEmits(["update-module-code"]);
const module = defineModel("module", { type: Object, default: null });
const translations = defineModel("translations", { type: Object, default: () => ({}) });
function updateRoute(value) {
module.value.b_route = value || null;
@@ -18,7 +20,7 @@ function updateRoute(value) {
<a-form v-if="module.b_type === 'directory'" layout="vertical" class="directory-form">
<div class="directory-grid">
<a-form-item label="模块编码" required>
<a-input :value="module.b_id" disabled />
<a-input :value="module.b_code" @update:value="emit('update-module-code', $event)" />
</a-form-item>
<a-form-item label="模块名称" required>
<a-input v-model:value="module.b_name" />
@@ -33,7 +35,14 @@ function updateRoute(value) {
/>
</a-form-item>
<a-form-item label="多语言键">
<a-input v-model:value="module.b_i18n" />
<div class="i18n-editor-row">
<a-input v-model:value="module.b_i18n" />
<ModuleTranslationEditor
v-model:translations="translations"
:i18n-key="module.b_i18n"
:disabled="!module.b_i18n?.trim()"
/>
</div>
</a-form-item>
<a-form-item label="显示顺序">
<a-input-number v-model:value="module.b_xh" :min="0" />
@@ -44,11 +53,7 @@ function updateRoute(value) {
<a-form v-else layout="vertical" class="basic-form">
<div class="form-grid">
<a-form-item label="模块编码" required>
<a-input
:value="module.b_id"
:disabled="!isNew"
@update:value="emit('update-module-id', $event)"
/>
<a-input :value="module.b_code" @update:value="emit('update-module-code', $event)" />
</a-form-item>
<a-form-item label="模块名称" required>
<a-input v-model:value="module.b_name" />
@@ -70,7 +75,14 @@ function updateRoute(value) {
/>
</a-form-item>
<a-form-item label="多语言键">
<a-input v-model:value="module.b_i18n" />
<div class="i18n-editor-row">
<a-input v-model:value="module.b_i18n" />
<ModuleTranslationEditor
v-model:translations="translations"
:i18n-key="module.b_i18n"
:disabled="!module.b_i18n?.trim()"
/>
</div>
</a-form-item>
<a-form-item label="查询表或视图">
<a-input v-model:value="module.b_viewtable" />
@@ -81,12 +93,9 @@ function updateRoute(value) {
<a-form-item label="主键字段">
<a-input v-model:value="module.b_keyfield" />
</a-form-item>
<a-form-item label="排序字段">
<a-form-item label="排序条件">
<a-input v-model:value="module.b_orderfield" />
</a-form-item>
<a-form-item label="排序方向">
<a-segmented v-model:value="module.b_ordertype" :options="['ASC', 'DESC']" />
</a-form-item>
<a-form-item label="显示顺序">
<a-input-number v-model:value="module.b_xh" :min="0" />
</a-form-item>
@@ -100,7 +109,7 @@ function updateRoute(value) {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding-top: 10px;
padding-top: 10px 0;
}
.directory-form {
@@ -124,6 +133,17 @@ function updateRoute(value) {
gap: 0 10px;
}
.i18n-editor-row {
display: flex;
align-items: center;
gap: 8px;
}
.i18n-editor-row :deep(.ant-input) {
min-width: 0;
flex: 1 1 auto;
}
:deep(.ant-form-item) {
margin-bottom: 8px;
}
@@ -1,29 +1,165 @@
<script setup>
import { computed, ref, watch } from "vue";
import { App } from "antdv-next";
import { Plus, Trash2 } from "@lucide/vue";
import FmsTable from "@/components/FmsTable.vue";
import { nextIdApi } from "@/services/api";
const props = defineProps({
moduleId: { type: [String, Number], default: "" },
moduleCode: { type: String, default: "" },
});
const fields = defineModel("fields", { type: Array, default: () => [] });
const { message } = App.useApp();
const adding = ref(false);
const selectedRowKeys = ref([]);
const columns = [
{ dataIndex: "b_field", title: "字段", width: 150 },
{ dataIndex: "b_name", title: "名称", width: 160 },
{ dataIndex: "b_type", title: "类型", width: 120 },
{ dataIndex: "b_canlist", title: "列表", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_canform", title: "表单", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_must", title: "必填", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_canquery", title: "查询", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_readonly", title: "只读", width: 64, align: "center", headerAlign: "center" },
{ dataIndex: "b_width", title: "宽度", width: 90 },
{ dataIndex: "b_xh", title: "顺序", width: 80 },
];
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
watch(
() => fields.value.map((item) => String(item.b_id)),
(keys) => {
const available = new Set(keys);
selectedRowKeys.value = selectedRowKeys.value.filter((key) => available.has(String(key)));
},
);
function nextFieldCode() {
const existing = new Set(fields.value.map((item) => item.b_field));
let index = 1;
while (existing.has(`b_new_${index}`)) index += 1;
return `b_new_${index}`;
}
async function onAdd() {
if (!props.moduleId || adding.value) return;
adding.value = true;
try {
const code = nextFieldCode();
const id = String((await nextIdApi()).data);
fields.value = [
...fields.value,
{
b_id: id,
b_module_id: props.moduleId,
b_field: code,
b_name: "新字段",
b_i18n: props.moduleCode ? `field.${props.moduleCode}.${code}` : null,
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: (fields.value.length + 1) * 10,
b_canuse: 1,
},
];
} catch (error) {
message.error(error.message || "字段创建失败");
} finally {
adding.value = false;
}
}
function onBatchDelete() {
const selected = new Set(selectedRowKeys.value.map(String));
fields.value = fields.value.filter((item) => !selected.has(String(item.b_id)));
selectedRowKeys.value = [];
}
const columns = ref([
{ dataIndex: "b_field", title: "字段", width: 150, editor: { maxlength: 50 } },
{ dataIndex: "b_name", title: "名称", width: 150, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "多语言键", width: 230, editor: { maxlength: 150 } },
{
dataIndex: "b_type",
title: "类型",
width: 120,
editor: {
type: "select",
options: ["string", "text", "number", "select", "date", "boolean"].map((value) => ({
value,
label: value,
})),
},
},
{
dataIndex: "b_canlist",
title: "列表",
width: 64,
align: "center",
headerAlign: "center",
editor: "checkbox",
},
{
dataIndex: "b_canform",
title: "表单",
width: 64,
align: "center",
headerAlign: "center",
editor: {
type: "checkbox",
onChange: (value, row) => {
if (value === 0) row.b_must = 0;
},
},
},
{
dataIndex: "b_must",
title: "必填",
width: 64,
align: "center",
headerAlign: "center",
editor: { type: "checkbox", disabled: (row) => row.b_canform !== 1 },
},
{
dataIndex: "b_canquery",
title: "查询",
width: 64,
align: "center",
headerAlign: "center",
editor: "checkbox",
},
{
dataIndex: "b_readonly",
title: "只读",
width: 64,
align: "center",
headerAlign: "center",
editor: "checkbox",
},
{ dataIndex: "b_width", title: "宽度", width: 90, editor: { type: "number", min: 1 } },
{ dataIndex: "b_xh", title: "顺序", width: 80, editor: { type: "number", min: 0 } },
]);
</script>
<template>
<section class="table-pane">
<div class="toolbar">
<a-button type="primary" :loading="adding" @click="onAdd">
<template #icon><Plus /></template>
新增
</a-button>
<a-button danger :disabled="!selectedRowKeys.length" @click="onBatchDelete">
<template #icon><Trash2 /></template>
批量删除
</a-button>
</div>
<FmsTable
class="sub-table"
row-key="b_id"
:columns="columns"
v-model:columns="columns"
:data-source="fields"
:row-selection="rowSelection"
/>
</section>
</template>
@@ -37,6 +173,13 @@ const columns = [
overflow: hidden;
}
.toolbar {
display: flex;
gap: 8px;
flex: none;
padding: 10px 0;
}
.sub-table {
flex: 1 1 auto;
min-height: 0;
@@ -1,24 +1,107 @@
<script setup>
import { computed, ref, watch } from "vue";
import { App } from "antdv-next";
import { Plus, Trash2 } from "@lucide/vue";
import FmsTable from "@/components/FmsTable.vue";
import { nextIdApi } from "@/services/api";
const props = defineProps({
moduleId: { type: [String, Number], default: "" },
moduleCode: { type: String, default: "" },
});
const powers = defineModel("powers", { type: Array, default: () => [] });
const { message } = App.useApp();
const adding = ref(false);
const selectedRowKeys = ref([]);
const columns = [
{ dataIndex: "b_code", title: "权限代码", width: 180 },
{ dataIndex: "b_name", title: "名称", width: 200 },
{ dataIndex: "b_i18n", title: "多语言键", width: 200 },
{ dataIndex: "b_canuse", title: "启用", width: 74, align: "center", headerAlign: "center" },
{ dataIndex: "b_xh", title: "顺序", width: 90 },
];
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
watch(
() => powers.value.map((item) => String(item.b_id)),
(keys) => {
const available = new Set(keys);
selectedRowKeys.value = selectedRowKeys.value.filter((key) => available.has(String(key)));
},
);
function nextPowerCode() {
const existing = new Set(powers.value.map((item) => item.b_code));
let index = 1;
while (existing.has(`new_${index}`)) index += 1;
return `new_${index}`;
}
async function onAdd() {
if (!props.moduleId || adding.value) return;
adding.value = true;
try {
const code = nextPowerCode();
const id = String((await nextIdApi()).data);
powers.value = [
...powers.value,
{
b_id: id,
b_module_id: props.moduleId,
b_code: code,
b_name: "新权限",
b_i18n: props.moduleCode ? `power.${props.moduleCode}.${code}` : null,
b_canuse: 1,
b_xh: (powers.value.length + 1) * 10,
},
];
} catch (error) {
message.error(error.message || "权限创建失败");
} finally {
adding.value = false;
}
}
function onBatchDelete() {
const selected = new Set(selectedRowKeys.value.map(String));
powers.value = powers.value.filter((item) => !selected.has(String(item.b_id)));
selectedRowKeys.value = [];
}
const columns = ref([
{ dataIndex: "b_code", title: "权限代码", width: 180, editor: { maxlength: 50 } },
{ dataIndex: "b_name", title: "名称", width: 180, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "多语言键", width: 240, editor: { maxlength: 150 } },
{
dataIndex: "b_canuse",
title: "启用",
width: 74,
align: "center",
headerAlign: "center",
editor: "checkbox",
},
{ dataIndex: "b_xh", title: "顺序", width: 90, editor: { type: "number", min: 0 } },
]);
</script>
<template>
<section class="table-pane">
<div class="toolbar">
<a-button type="primary" :loading="adding" @click="onAdd">
<template #icon><Plus /></template>
新增
</a-button>
<a-button danger :disabled="!selectedRowKeys.length" @click="onBatchDelete">
<template #icon><Trash2 /></template>
批量删除
</a-button>
</div>
<FmsTable
class="sub-table"
row-key="b_id"
:columns="columns"
v-model:columns="columns"
:data-source="powers"
:row-selection="rowSelection"
/>
</section>
</template>
@@ -32,6 +115,13 @@ const columns = [
overflow: hidden;
}
.toolbar {
display: flex;
gap: 8px;
flex: none;
padding: 10px 0;
}
.sub-table {
flex: 1 1 auto;
min-height: 0;
@@ -0,0 +1,59 @@
<script setup>
import { reactive } from "vue";
import { App } from "antdv-next";
import { Languages } from "@lucide/vue";
import { I18N_LOCALES, isValidI18nKey } from "@/utils/i18n";
const props = defineProps({
i18nKey: { type: String, default: "" },
disabled: { type: Boolean, default: false },
});
const translations = defineModel("translations", { type: Object, default: () => ({}) });
const { message } = App.useApp();
const dialog = reactive({ open: false, key: "", values: {} });
function openEditor() {
const key = props.i18nKey.trim();
if (!isValidI18nKey(key)) {
message.warning("请先填写正确的多语言键");
return;
}
dialog.key = key;
dialog.values = Object.fromEntries(
I18N_LOCALES.map((locale) => [locale.value, translations.value[key]?.[locale.value] || ""]),
);
dialog.open = true;
}
function saveTranslations() {
translations.value = {
...translations.value,
[dialog.key]: { ...dialog.values },
};
dialog.open = false;
}
</script>
<template>
<a-button :disabled="disabled" @click="openEditor">
<template #icon><Languages /></template>
多语言
</a-button>
<a-modal
v-model:open="dialog.open"
title="更新多语言"
ok-text="更新"
cancel-text="取消"
@ok="saveTranslations"
>
<a-form layout="vertical">
<a-form-item label="多语言键">
<a-input :value="dialog.key" disabled />
</a-form-item>
<a-form-item v-for="locale in I18N_LOCALES" :key="locale.value" :label="locale.label">
<a-input v-model:value="dialog.values[locale.value]" :maxlength="500" />
</a-form-item>
</a-form>
</a-modal>
</template>
@@ -1,57 +0,0 @@
<script setup>
import { computed } from "vue";
import FmsTable from "@/components/FmsTable.vue";
const LOCALES = [
{ value: "zh-CN", label: "简体中文" },
{ value: "en-US", label: "English" },
{ value: "zh-TW", label: "繁體中文" },
];
const props = defineProps({
keys: { type: Array, default: () => [] },
});
const translations = defineModel("translations", { type: Object, default: () => ({}) });
const rows = computed(() =>
props.keys.map((key) => {
const entry = translations.value[key] || {};
const row = { key };
LOCALES.forEach((locale) => {
row[locale.value] = entry[locale.value];
});
return row;
}),
);
const columns = [
{ dataIndex: "key", title: "多语言键", width: 280, fixed: "left" },
...LOCALES.map((locale) => ({ dataIndex: locale.value, title: locale.label, width: 220 })),
];
</script>
<template>
<section class="translation-pane">
<FmsTable
class="sub-table"
row-key="key"
:columns="columns"
:data-source="rows"
/>
</section>
</template>
<style scoped>
.translation-pane {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.sub-table {
flex: 1 1 auto;
min-height: 0;
}
</style>
@@ -5,6 +5,7 @@ import FmsTree from "@/components/FmsTree.vue";
const props = defineProps({
modules: { type: Array, required: true },
protectedKeys: { type: Array, default: () => [] },
selectedKey: { type: String, default: "" },
loading: Boolean,
canCreate: Boolean,
@@ -16,15 +17,16 @@ const contextNode = ref(null);
const treeData = computed(() => {
const keyword = searchText.value.trim().toLowerCase();
const codeById = new Map(props.modules.map((item) => [item.b_id, item.b_code]));
const visible = keyword
? new Set(
props.modules
.filter(
(item) =>
String(item.b_id).toLowerCase().includes(keyword) ||
String(item.b_code).toLowerCase().includes(keyword) ||
String(item.b_name).toLowerCase().includes(keyword),
)
.flatMap((item) => [item.b_id, item.b_parent_id]),
.flatMap((item) => [item.b_code, codeById.get(item.b_parent_id)].filter(Boolean)),
)
: null;
const children = new Map();
@@ -35,11 +37,11 @@ const treeData = computed(() => {
});
const build = (parent) =>
(children.get(parent) || [])
.filter((item) => !visible || visible.has(item.b_id))
.filter((item) => !visible || visible.has(item.b_code))
.map((item) => {
const nodeChildren = build(item.b_id);
return {
key: item.b_id,
key: item.b_code,
title: item.b_name,
children: nodeChildren,
isLeaf: nodeChildren.length === 0,
@@ -69,7 +71,7 @@ const contextMenu = computed(() => {
key: "delete",
label: "删除",
danger: true,
disabled: !node || !props.canDelete || ["M_SYSTEM", "M_MODULE"].includes(node.key),
disabled: !node || !props.canDelete || props.protectedKeys.includes(String(node.key)),
},
],
onClick: ({ key }) => {
@@ -1,18 +1,18 @@
<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 { 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 ModuleTranslationsPanel from "./components/ModuleTranslationsPanel.vue";
import ModuleTreePanel from "./components/ModuleTreePanel.vue";
const PROTECTED_MODULE_CODES = ["m_module", "m_i18n", "m_biz"];
const { message } = App.useApp();
const LOCALES = ["zh-CN", "en-US", "zh-TW"];
const authStore = useAuthStore();
const permissionStore = usePermissionStore();
const loading = ref(false);
@@ -23,6 +23,7 @@ const isNew = ref(false);
const deleteConfirmOpen = ref(false);
const pendingDeleteKey = ref("");
const original = ref("");
const originalTranslationKeys = ref([]);
const data = reactive({
modules: [],
fields: [],
@@ -33,28 +34,27 @@ const data = reactive({
});
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 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 + ")" })),
.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_id + ")" })),
.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 translationKeys = computed(() => rowKeys());
const pendingDeleteModule = computed(() =>
data.modules.find((item) => item.b_id === pendingDeleteKey.value),
data.modules.find((item) => item.b_code === pendingDeleteKey.value),
);
function clone(value) {
@@ -86,19 +86,23 @@ function clearDraft() {
draft.powers = [];
draft.translations = {};
original.value = "";
originalTranslationKeys.value = [];
activeTab.value = "basic";
}
function selectModule(code) {
const module = data.modules.find((item) => item.b_id === code);
function selectModule(moduleCode) {
const module = data.modules.find((item) => item.b_code === moduleCode);
if (!module) return;
selectedKey.value = code;
selectedKey.value = moduleCode;
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));
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
@@ -108,6 +112,7 @@ function selectModule(code) {
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";
}
@@ -131,7 +136,7 @@ async function loadAll(preferredKey = selectedKey.value) {
userModules: userModules.data,
userPowers: userPowers.data,
});
const target = data.modules.some((item) => item.b_id === preferredKey) ? preferredKey : "";
const target = data.modules.some((item) => item.b_code === preferredKey) ? preferredKey : "";
if (target) {
selectModule(target);
@@ -146,75 +151,63 @@ async function loadAll(preferredKey = selectedKey.value) {
}
function nextModuleCode(prefix) {
const existing = new Set(data.modules.map((item) => item.b_id));
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_id === 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;
}
function addModule(kind, contextKey) {
async function addModule(kind, contextKey) {
const isDirectory = kind === "directory";
const id = nextModuleCode(isDirectory ? "M_DIR_NEW_" : "M_PAGE_NEW_");
const id = String((await nextIdApi()).data);
const code = nextModuleCode(isDirectory ? "m_dir_new_" : "m_page_new_");
const parentId = parentForNew(contextKey);
const route = isDirectory ? null : "/" + id.toLowerCase().replace(/^m_/, "").replaceAll("_", "-");
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." + id,
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",
b_ordertype: isDirectory ? null : "DESC",
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." + id]: { "zh-CN": draft.module.b_name },
["module." + code]: { "zh-CN": draft.module.b_name },
};
selectedKey.value = id;
selectedKey.value = code;
isNew.value = true;
original.value = "";
activeTab.value = "basic";
}
function handleAdd(kind, contextKey) {
addModule(kind === "module" ? "directory" : "page", contextKey);
async function handleAdd(kind, contextKey) {
try {
await addModule(kind === "module" ? "directory" : "page", contextKey);
} catch (error) {
message.error(error.message || "模块创建失败");
}
}
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;
});
function updateModuleCode(value) {
draft.module.b_code = String(value || "").toLowerCase();
}
function normalizeModuleType() {
@@ -225,9 +218,9 @@ function normalizeModuleType() {
function validate() {
const module = draft.module;
if (!/^[A-Z][A-Z0-9_]{1,49}$/.test(module.b_id)) return "模块编码格式不正确";
if (!/^[a-z][a-z0-9_]{1,49}$/.test(module.b_code)) return "模块编码格式不正确";
if (!module.b_name?.trim()) return "模块名称不能为空";
if (isNew.value && data.modules.some((item) => item.b_id === module.b_id)) {
if (data.modules.some((item) => item.b_code === module.b_code && item.b_id !== module.b_id)) {
return "模块编码已经存在";
}
if (
@@ -238,15 +231,20 @@ function validate() {
}
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);
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 "字段编码不能重复";
@@ -281,14 +279,14 @@ function diffRows(current, previous) {
}
async function translationChanges(previousKeys) {
const currentKeys = new Set(rowKeys());
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 LOCALES) {
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);
@@ -299,6 +297,9 @@ async function translationChanges(previousKeys) {
}
}
}
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) {
@@ -340,7 +341,7 @@ async function saveModule() {
try {
const oldModule = isNew.value
? null
: data.modules.find((item) => item.b_id === selectedKey.value);
: data.modules.find((item) => item.b_code === selectedKey.value);
const oldFields = oldModule
? data.fields.filter((item) => item.b_module_id === oldModule.b_id)
: [];
@@ -349,6 +350,7 @@ async function saveModule() {
: [];
const previousKeys = new Set(
[
...originalTranslationKeys.value,
oldModule?.b_i18n,
...oldFields.map((item) => item.b_i18n),
...oldPowers.map((item) => item.b_i18n),
@@ -391,7 +393,7 @@ async function saveModule() {
),
);
const savedKey = draft.module.b_id;
const savedKey = draft.module.b_code;
message.success("模块配置已保存");
await loadAll(savedKey);
} catch (error) {
@@ -401,38 +403,31 @@ async function saveModule() {
}
}
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)) {
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 = moduleId;
pendingDeleteKey.value = moduleCode;
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)) {
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 === moduleId);
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 === moduleId);
const keys = new Set(
[
module.b_i18n,
...fields.map((item) => item.b_i18n),
...powers.map((item) => item.b_i18n),
].filter(Boolean),
);
const fields = data.fields.filter((item) => item.b_module_id === module.b_id);
const requests = [
{
@@ -450,7 +445,7 @@ async function deleteModule(moduleId = draft.module?.b_id) {
inserts: [],
updates: [],
deletes: data.userModules
.filter((item) => item.b_module_id === moduleId)
.filter((item) => item.b_module_id === module.b_id)
.map((item) => ({ b_id: item.b_id })),
},
{
@@ -467,21 +462,12 @@ async function deleteModule(moduleId = draft.module?.b_id) {
updates: [],
deletes: fields.map((item) => ({ b_id: item.b_id })),
},
{
table: "b_i18n",
key_field: "b_id",
inserts: [],
updates: [],
deletes: data.translations
.filter((item) => keys.has(item.b_key))
.map((item) => ({ b_id: item.b_id })),
},
{
table: "b_module",
key_field: "b_id",
inserts: [],
updates: [],
deletes: [{ b_id: moduleId }],
deletes: [{ b_id: module.b_id }],
},
];
await saveObjectApi(
@@ -493,7 +479,7 @@ async function deleteModule(moduleId = draft.module?.b_id) {
message.success("模块已删除");
deleteConfirmOpen.value = false;
pendingDeleteKey.value = "";
await loadAll(selectedKey.value === moduleId ? "" : selectedKey.value);
await loadAll(selectedKey.value === moduleCode ? "" : selectedKey.value);
} catch (error) {
message.error(error.message || "模块删除失败");
} finally {
@@ -523,6 +509,7 @@ onMounted(async () => {
<div class="module-management">
<ModuleTreePanel
:modules="data.modules"
:protected-keys="PROTECTED_MODULE_CODES"
:selected-key="selectedKey"
:loading="loading"
:can-create="canCreate"
@@ -540,7 +527,7 @@ onMounted(async () => {
<div class="title-block">
<div class="title-line">
<h1>{{ draft.module.b_name }}</h1>
<a-tag>{{ draft.module.b_id }}</a-tag>
<a-tag>{{ draft.module.b_code }}</a-tag>
<a-tag color="blue">{{ moduleKindLabel }}</a-tag>
</div>
</div>
@@ -572,14 +559,14 @@ onMounted(async () => {
<a-popconfirm
title="确定删除当前模块吗?"
:disabled="
isNew || !canDelete || ['M_SYSTEM', 'M_MODULE'].includes(draft.module.b_id)
isNew || !canDelete || PROTECTED_MODULE_CODES.includes(draft.module.b_code)
"
@confirm="deleteModule()"
>
<a-button
danger
:disabled="
isNew || !canDelete || ['M_SYSTEM', 'M_MODULE'].includes(draft.module.b_id)
isNew || !canDelete || PROTECTED_MODULE_CODES.includes(draft.module.b_code)
"
>
<Trash2 :size="15" />删除
@@ -596,51 +583,41 @@ onMounted(async () => {
</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-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"
:is-new="isNew"
:parent-options="parentOptions"
@update-module-id="updateModuleId"
@update-module-code="updateModuleCode"
/>
</a-tab-pane>
<a-tab-pane key="fields">
<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" />
<ModuleFieldDefinitionPanel
v-model:fields="draft.fields"
:module-id="draft.module.b_id"
:module-code="draft.module.b_code"
/>
</a-tab-pane>
<a-tab-pane key="powers">
<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" />
</a-tab-pane>
<a-tab-pane key="i18n">
<template #tab>
<span class="tab-label"><Languages :size="15" />多语言</span>
</template>
<ModuleTranslationsPanel
v-model:translations="draft.translations"
:keys="translationKeys"
<ModulePowerPanel
v-model:powers="draft.powers"
:module-id="draft.module.b_id"
:module-code="draft.module.b_code"
/>
</a-tab-pane>
</a-tabs>
@@ -664,7 +641,8 @@ onMounted(async () => {
>
<p>
确定删除
{{ pendingDeleteModule?.b_name || pendingDeleteKey }} 吗?关联字段、权限和多语言将一并删除。
{{ pendingDeleteModule?.b_name || pendingDeleteKey }}
吗?关联字段和权限将一并删除;共享多语言资源会保留在多语言中心。
</p>
</a-modal>
</div>