20260721223033
This commit is contained in:
1 parent
bab8e2972f
commit
e9f099f044
26 files changed
+1687
-349
No files matched your search
@@ -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: "多语言管理" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
File renamed without changes.
@@ -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>
|
||||
File renamed without changes.
@@ -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;
|
||||
}
|
||||
|
||||
+156
-13
@@ -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 }) => {
|
||||
|
||||
+96
-118
@@ -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>
|
||||
Reference in new issue
Block a user