20260724232121

This commit is contained in:
oneao committed 2026-07-24 23:21:21 +08:00
1 parent b33c743236
commit 1b86e0043a
10 files changed
+996 -230

No files matched your search

@@ -1,7 +1,8 @@
<script setup>
import { computed, h, nextTick, ref, watch } from "vue";
import { message, Select } from "antdv-next";
import { message, Modal } from "antdv-next";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import FpsMonitor from "@/components/FpsMonitor.vue";
import { loadDataApi } from "@/services/api";
import {
cloneData,
@@ -17,11 +18,15 @@ const props = defineProps({
loading: { type: Boolean, default: false },
});
const i18nData = ref([]);
const originals = ref([]);
// 启用的语言类型(b_i18n_type 中 b_canuse=1),每种语言作为表格的一列。
const langOptions = ref([]);
// 扁平原始数据(b_id, b_key, b_locale, b_value),仅用于保存时差量对比。
const originalsFlat = ref([]);
// 透视后的行数据:一行一个 key,每种语言的值挂在对应 locale 字段上。
// 形如 { _id, b_key, "zh-CN": "…", "en-US": "…", _localeIds: { "zh-CN": b_id, … } }
const pivotRows = ref([]);
const isLoading = ref(false);
const searchKey = ref("");
const langOptions = ref([]);
const activeKey = ref(null);
const tableRef = ref(null);
@@ -37,6 +42,25 @@ async function loadLangTypes() {
}
}
// 将扁平记录按 key 聚合成一行(每种语言一列)。
function buildPivot(flat) {
const byKey = new Map();
for (const r of flat) {
if (!byKey.has(r.b_key)) byKey.set(r.b_key, []);
byKey.get(r.b_key).push(r);
}
const rows = [];
for (const [key, recs] of byKey) {
const row = { _id: createTempId(), b_key: key, _localeIds: {} };
for (const r of recs) {
row[r.b_locale] = r.b_value;
row._localeIds[r.b_locale] = r.b_id;
}
rows.push(row);
}
return rows;
}
async function loadI18n() {
isLoading.value = true;
try {
@@ -44,8 +68,14 @@ async function loadI18n() {
loadDataApi("b_i18n", null, "b_key ASC, b_locale ASC"),
loadLangTypes(),
]);
originals.value = i18nRes.data || [];
i18nData.value = cloneData(originals.value);
const flat = (i18nRes.data || []).map((r) => ({
b_id: r.b_id,
b_key: r.b_key,
b_locale: r.b_locale,
b_value: r.b_value,
}));
originalsFlat.value = cloneData(flat);
pivotRows.value = buildPivot(flat);
} catch (e) {
message.error("加载多语言失败:" + (e.message || e));
} finally {
@@ -56,18 +86,9 @@ async function loadI18n() {
watch(() => props.moduleId, loadI18n, { immediate: true });
async function addRow() {
const def = langOptions.value[0]?.value || "zh-CN";
const newId = createTempId();
// 用新引用替换(而非 push),否则 displayedData 这个 computed 因返回同一数组引用而不会触发重渲染
i18nData.value = [
...i18nData.value,
{
b_id: newId,
b_key: "",
b_locale: def,
b_value: "",
},
];
pivotRows.value = [...pivotRows.value, { _id: newId, b_key: "", _localeIds: {} }];
activeKey.value = String(newId);
await nextTick();
@@ -75,12 +96,66 @@ async function addRow() {
}
function deleteRow(record) {
i18nData.value = i18nData.value.filter((r) => r.b_id !== record.b_id);
pivotRows.value = pivotRows.value.filter((r) => r._id !== record._id);
// 同步清理选择状态,避免选中项被删后 keys 残留
const k = String(record._id);
if (selectedRowKeys.value.includes(k)) {
selectedRowKeys.value = selectedRowKeys.value.filter((x) => x !== k);
}
}
// 行多选:selectedRowKeys 由 FmsTable 的选择列(createSelectionCellRenderers 提供)
// 通过 onChange 回写;FmsTable 在传了 rowSelection 时自动在最前列插入复选框列。
const selectedRowKeys = ref([]);
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
// 批量删除选中行(整条 key,含其全部语言)
function batchDelete() {
const keys = selectedRowKeys.value;
if (!keys.length) return;
Modal.confirm({
title: `确定删除选中的 ${keys.length} 条记录?`,
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: () => {
const delSet = new Set(keys);
pivotRows.value = pivotRows.value.filter((r) => !delSet.has(String(r._id)));
selectedRowKeys.value = [];
if (activeKey.value && delSet.has(activeKey.value)) activeKey.value = "";
},
});
}
// 将透视行平铺回 (key, locale) 扁平记录;空值不生成记录(原存在则视为删除)。
function flatten(rows) {
const flat = [];
for (const row of rows) {
for (const opt of langOptions.value) {
const locale = opt.value;
const val = row[locale];
if (val == null || val === "") continue;
let id = row._localeIds[locale];
if (id == null) {
// 新语言值:分配稳定的临时 ID,供保存时映射为雪花 ID
id = createTempId();
row._localeIds[locale] = id;
}
flat.push({ b_id: id, b_key: row.b_key, b_locale: locale, b_value: val });
}
}
return flat;
}
// 由父级统一保存调用:返回 b_i18n 的变更请求(无变更返回 null),保存成功后父级调用 markSaved 同步基线
async function buildSaveRequest() {
const changes = diffRows(i18nData.value, originals.value);
const flat = flatten(pivotRows.value);
const changes = diffRows(flat, originalsFlat.value);
const req = tableChange("b_i18n", changes);
if (!hasChanges(req)) return null;
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
@@ -88,54 +163,68 @@ async function buildSaveRequest() {
}
function markSaved() {
originals.value = cloneData(i18nData.value);
originalsFlat.value = cloneData(flatten(pivotRows.value));
}
defineExpose({ buildSaveRequest, markSaved });
const displayedData = computed(() => {
if (!searchKey.value) return i18nData.value;
if (!searchKey.value) return pivotRows.value;
const kw = searchKey.value.toLowerCase();
return i18nData.value.filter(
(r) =>
(r.b_key || "").toLowerCase().includes(kw) || (r.b_value || "").toLowerCase().includes(kw),
);
return pivotRows.value.filter((r) => (r.b_key || "").toLowerCase().includes(kw));
});
function createLocaleCell() {
return (props) => {
const row = props.row;
return h(Select, {
value: row.b_locale,
size: "small",
style: { width: "100%" },
options: langOptions.value,
onChange: (val) => {
row.b_locale = val;
// 操作列单元格:普通文字「删除」按钮(原生 button,避免每行一个组件实例开销)
const ActionsCell = ({ row }) =>
h(
"button",
{
type: "button",
title: "删除",
onMousedown: (e) => e.stopPropagation(),
style: {
border: "none",
background: "transparent",
cursor: "pointer",
padding: "2px 4px",
color: "#ff4d4f",
fontSize: "12px",
lineHeight: "20px",
},
});
};
}
onClick: (e) => {
e.stopPropagation();
Modal.confirm({
title: "确定删除该记录(含其全部语言)?",
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: () => deleteRow(row),
});
},
},
"删除",
);
const columns = [
{ dataIndex: "b_key", title: "键", width: 200 },
{ dataIndex: "b_locale", title: "语言", width: 160, customCell: createLocaleCell() },
{ dataIndex: "b_value", title: "翻译文本", width: 300 },
{
// 动态列:键 + 每种语言一列 + 操作列
const columns = computed(() => {
const cols = [{ dataIndex: "b_key", title: "键", width: 240, editor: { type: "input" } }];
for (const opt of langOptions.value) {
cols.push({
dataIndex: opt.value,
title: opt.label,
width: 180,
editor: { type: "input" },
});
}
cols.push({
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
actions: [
{
type: "danger",
label: "删除",
confirm: { title: "确定删除该记录?", okText: "删除" },
onClick: (row) => deleteRow(row),
},
],
},
];
customCell: ActionsCell,
});
return cols;
});
</script>
<template>
@@ -144,21 +233,26 @@ const columns = [
<a-space>
<a-input-search
v-model:value="searchKey"
placeholder="搜索键或值"
placeholder="搜索键"
style="width: 200px"
allow-clear
/>
<a-button type="primary" @click="addRow">新增翻译</a-button>
<a-button danger :disabled="!selectedRowKeys.length" @click="batchDelete">
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : "" }}
</a-button>
</a-space>
</div>
<div class="panel-body">
<FpsMonitor />
<FmsTable
ref="tableRef"
:columns="columns"
:data-source="displayedData"
row-key="b_id"
row-key="_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row.b_id))"
:row-selection="rowSelection"
@row-click="(_, row) => (activeKey = String(row._id))"
/>
</div>
</div>
@@ -1,5 +1,5 @@
<script setup>
import { h, nextTick, ref, watch } from "vue";
import { computed, h, nextTick, ref, watch } from "vue";
import { message, Modal } from "antdv-next";
import { Languages } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
@@ -60,9 +60,43 @@ async function addRow() {
function deleteRow(record) {
localPowers.value = localPowers.value.filter((r) => r.b_id !== record.b_id);
// 同步清理选择状态,避免选中项被删后 keys 残留
const k = String(record.b_id);
if (selectedRowKeys.value.includes(k)) {
selectedRowKeys.value = selectedRowKeys.value.filter((x) => x !== k);
}
emitUpdate();
}
// 行多选:selectedRowKeys 由 FmsTable 的选择列(createSelectionCellRenderers 提供)
// 通过 onChange 回写;FmsTable 在传了 rowSelection 时自动在最前列插入复选框列。
const selectedRowKeys = ref([]);
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
// 批量删除选中行
function batchDelete() {
const keys = selectedRowKeys.value;
if (!keys.length) return;
Modal.confirm({
title: `确定删除选中的 ${keys.length} 条记录?`,
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: () => {
const delSet = new Set(keys);
localPowers.value = localPowers.value.filter((r) => !delSet.has(r.b_id));
selectedRowKeys.value = [];
if (activeKey.value && delSet.has(activeKey.value)) activeKey.value = "";
emitUpdate();
},
});
}
// ── 快速设置多语言 ──
const i18nModalRef = ref(null);
@@ -145,13 +179,14 @@ const CanUseCell = ({ row, cellValue }) => {
);
};
// 操作列单元格:原生 button + Modal.confirm,避免每行一个 Popconfirm 实例的开销
// 操作列单元格:普通文字「删除」按钮(原生 button,避免每行一个组件实例开销)
const ActionsCell = ({ row }) =>
h(
"button",
{
type: "button",
title: "删除",
onMousedown: (e) => e.stopPropagation(),
style: {
border: "none",
background: "transparent",
@@ -190,6 +225,9 @@ const columns = [
<div class="panel-toolbar">
<a-space>
<a-button type="primary" @click="addRow">新增权限</a-button>
<a-button danger :disabled="!selectedRowKeys.length" @click="batchDelete">
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : "" }}
</a-button>
</a-space>
</div>
<div class="panel-body">
@@ -200,6 +238,7 @@ const columns = [
:data-source="localPowers"
row-key="b_id"
:active-row-key="activeKey"
:row-selection="rowSelection"
@row-click="(_, row) => (activeKey = String(row.b_id))"
/>
</div>
@@ -241,8 +280,10 @@ const columns = [
基于 antdv-next Checkbox 源码(checkbox/style/index.js)精确模拟外观。 -->
<style lang="scss">
.pw-checkbox {
display: inline-flex;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
cursor: pointer;
line-height: 1;
margin: 0;