20260721223033

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

No files matched your search

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