20260724232121
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user