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

534 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, onMounted, reactive, ref, 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>