534 lines
15 KiB
Vue
534 lines
15 KiB
Vue
<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>
|