20260724173425

This commit is contained in:
oneao committed 2026-07-24 17:34:26 +08:00
1 parent 4f40ad9f11
commit b33c743236
27 files changed
+1310 -437

No files matched your search

@@ -70,7 +70,7 @@ function makeCellEditor(dataIndex, edit) {
if (row.id !== editingKey.value) {
if (edit.type === "select") {
const opt = (edit.options || []).find((o) => o.value === cellValue);
return h("span", { class: "cell-display" }, opt ? opt.label : cellValue ?? "");
return h("span", { class: "cell-display" }, opt ? opt.label : (cellValue ?? ""));
}
return h("span", { class: "cell-display" }, cellValue ?? "");
}
+233 -66
View File
@@ -1,100 +1,267 @@
<script setup>
import { ref } from "vue";
import FmsMemoInput from "@/components/FmsMemoInput.vue";
import { h, ref } from "vue";
import { message } from "antdv-next";
import { Input, InputNumber, Select } from "antdv-next";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import FpsMonitor from "@/components/FpsMonitor.vue";
const memo = ref("");
const COLUMN_COUNT = 20;
const ROW_COUNT = 4000;
const STATUS_OPTIONS = [
{ label: "启用", value: 1 },
{ label: "禁用", value: 0 },
];
function randInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function randText(len) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let s = "";
for (let i = 0; i < len; i++) s += chars[randInt(0, chars.length - 1)];
return s;
}
function generateValue(c, i) {
switch (c % 5) {
case 0:
return randText(randInt(4, 10));
case 1:
return randInt(0, 99999);
case 2:
return STATUS_OPTIONS[randInt(0, 1)].value;
case 3:
return `名称-${i}-${c}`;
default:
return `${randInt(1000, 9999)}-${randText(4)}`;
}
}
function buildData() {
const rows = [];
for (let i = 0; i < ROW_COUNT; i++) {
const row = { id: `R${i}` };
for (let c = 1; c <= COLUMN_COUNT; c++) row[`col${c}`] = generateValue(c, i);
rows.push(row);
}
return rows;
}
const dataSource = ref(buildData());
// 当前编辑行 key;进入编辑时留存快照,取消可还原
const editingKey = ref(null);
const editSnapshot = ref(null);
function editConfigFor(c) {
switch (c % 5) {
case 1:
return { type: "number" };
case 2:
return { type: "select", options: STATUS_OPTIONS };
default:
return { type: "text", maxlength: 64 };
}
}
// 单元格渲染:编辑行显示控件,其他行显示纯文本
function makeCellEditor(dataIndex, edit) {
return (props) => {
const { row, cellValue } = props;
if (row.id !== editingKey.value) {
if (edit.type === "select") {
const opt = (edit.options || []).find((o) => o.value === cellValue);
return h("span", { class: "cell-display" }, opt ? opt.label : (cellValue ?? ""));
}
return h("span", { class: "cell-display" }, cellValue ?? "");
}
const update = (v) => {
row[dataIndex] = v;
};
const common = {
size: "small",
style: { width: "100%" },
onClick: (e) => e.stopPropagation(),
};
if (edit.type === "number") {
return h(InputNumber, {
...common,
value: cellValue,
min: edit.min,
max: edit.max,
precision: edit.precision,
"onUpdate:value": update,
});
}
if (edit.type === "select") {
return h(Select, {
...common,
value: cellValue,
options: edit.options || [],
"onUpdate:value": update,
});
}
return h(Input, {
...common,
value: cellValue,
maxlength: edit.maxlength,
"onUpdate:value": update,
});
};
}
function buildColumns() {
const cols = [
{
dataIndex: "id",
title: "编码",
width: 120,
fixed: "left",
customCell: makeCellEditor("id", { type: "text", maxlength: 32 }),
},
];
for (let c = 1; c <= COLUMN_COUNT; c++) {
const edit = editConfigFor(c);
cols.push({
dataIndex: `col${c}`,
title: `列 ${c}`,
width: 140,
sorter: edit.type === "number" ? true : undefined,
customCell: makeCellEditor(`col${c}`, edit),
});
}
return cols;
}
const columns = ref(buildColumns());
function onRowClick(ev, row) {
if (row.id === editingKey.value) return;
editingKey.value = row.id;
editSnapshot.value = JSON.parse(JSON.stringify(row));
}
function saveEdit() {
if (editingKey.value == null) return;
editingKey.value = null;
editSnapshot.value = null;
message.success("已保存当前行修改");
}
function cancelEdit() {
if (editingKey.value != null && editSnapshot.value) {
const idx = dataSource.value.findIndex((r) => r.id === editingKey.value);
if (idx >= 0) dataSource.value[idx] = editSnapshot.value;
}
editingKey.value = null;
editSnapshot.value = null;
message.info("已取消编辑");
}
function regenerate() {
editingKey.value = null;
editSnapshot.value = null;
dataSource.value = buildData();
message.success("已重新生成随机数据");
}
function rowClassName(row) {
return row.id === editingKey.value ? "fms-row-editing" : "";
}
</script>
<template>
<main class="memo-demo">
<header class="memo-demo-header">
<h1>Memo 输入框</h1>
<main class="table-demo">
<header class="table-demo__toolbar">
<div class="table-demo__title">
<strong>大数据表格 Demo</strong>
<span class="table-demo__meta">
共 {{ dataSource.length }} 行 · {{ columns.length }} 列 ·
<template v-if="editingKey">正在编辑:{{ editingKey }}</template>
<template v-else>点击任意行进入编辑</template>
</span>
</div>
<div class="table-demo__actions">
<a-button :disabled="editingKey == null" type="primary" @click="saveEdit">保存</a-button>
<a-button :disabled="editingKey == null" @click="cancelEdit">取消</a-button>
<a-button @click="regenerate">重新生成</a-button>
</div>
</header>
<section class="memo-demo-content" aria-label="FmsMemoInput 测试">
<label for="memo-test">备注</label>
<FmsMemoInput
id="memo-test"
v-model="memo"
placeholder="点击输入备注"
<section class="table-demo__body">
<FpsMonitor />
<FmsTable
v-model:columns="columns"
:data-source="dataSource"
row-key="id"
:row-class-name="rowClassName"
@row-click="onRowClick"
/>
<div class="memo-value">
<span>当前值</span>
<pre>{{ memo || "--" }}</pre>
</div>
</section>
</main>
</template>
<style scoped>
.memo-demo {
.table-demo {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: auto;
min-height: 0;
color: var(--fms-text);
background: var(--fms-surface);
}
.memo-demo-header {
padding: 16px 24px;
.table-demo__toolbar {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--fms-border);
}
.memo-demo-header h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.memo-demo-content {
display: grid;
width: min(680px, 100%);
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
gap: 16px;
padding: 32px 24px;
}
.memo-demo-content > label {
font-size: 13px;
font-weight: 500;
}
.memo-value {
display: grid;
.table-demo__title {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
grid-column: 2;
gap: 6px;
}
.memo-value > span {
.table-demo__title strong {
font-size: 15px;
font-weight: 600;
}
.table-demo__meta {
color: var(--fms-text-secondary);
font-size: 12px;
}
.memo-value pre {
min-height: 72px;
margin: 0;
overflow-wrap: anywhere;
padding: 10px 12px;
border: 1px solid var(--fms-border);
border-radius: 4px;
background: var(--fms-bg);
font-family: inherit;
font-size: 13px;
line-height: 20px;
white-space: pre-wrap;
.table-demo__actions {
display: flex;
flex: none;
gap: 8px;
}
@media (width <= 640px) {
.memo-demo-content {
grid-template-columns: minmax(0, 1fr);
}
.table-demo__body {
position: relative;
flex: 1;
min-height: 0;
padding: 12px 16px 16px;
}
.memo-value {
grid-column: 1;
}
:deep(.fms-row-editing) {
background: rgba(22, 119, 255, 0.08) !important;
}
:deep(.cell-display) {
display: block;
min-height: 22px;
padding: 0 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+1 -7
View File
@@ -148,13 +148,7 @@ async function submit() {
<template #prefix><LockKeyhole /></template>
</a-input-password>
</a-form-item>
<a-button
type="primary"
html-type="submit"
size="large"
block
:loading="loading"
>
<a-button type="primary" html-type="submit" size="large" block :loading="loading">
<template #icon><LogIn /></template>
登录
</a-button>
@@ -3,7 +3,13 @@ import { computed, ref, watch } from "vue";
import { message } from "antdv-next";
import FmsTree from "@/components/FmsTree.vue";
import { saveObjectApi } from "@/services/api";
import { cloneData, diffRows, allocateTemporaryIds, tableChange, hasChanges } from "@/utils/dataChanges";
import {
cloneData,
diffRows,
allocateTemporaryIds,
tableChange,
hasChanges,
} from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
const props = defineProps({
@@ -49,14 +55,15 @@ function buildTreeData(items) {
return roots.map((r) => ({
key: r.key,
title: r.b_title || `分组-${r.b_id}`,
children: r.children?.map((c) => ({
key: c.key,
title: c.b_title || `分组-${c.b_id}`,
children: c.children?.map((gc) => ({
key: gc.key,
title: gc.b_title || `分组-${gc.b_id}`,
})),
})) || [],
children:
r.children?.map((c) => ({
key: c.key,
title: c.b_title || `分组-${c.b_id}`,
children: c.children?.map((gc) => ({
key: gc.key,
title: gc.b_title || `分组-${gc.b_id}`,
})),
})) || [],
}));
}
@@ -122,13 +129,7 @@ function onClose() {
</script>
<template>
<a-modal
:open="visible"
title="分组管理"
width="650px"
@cancel="onClose"
@ok="saveAllGroups"
>
<a-modal :open="visible" title="分组管理" width="650px" @cancel="onClose" @ok="saveAllGroups">
<div class="group-manager">
<div class="group-sidebar">
<div class="group-sidebar-header">
@@ -157,28 +158,25 @@ function onClose() {
<div class="group-detail" v-if="editForm">
<a-form layout="vertical">
<a-form-item label="分组标题" required>
<a-input
:value="editForm.b_title"
@update:value="(v) => editForm.b_title = v"
/>
<a-input :value="editForm.b_title" @update:value="(v) => (editForm.b_title = v)" />
</a-form-item>
<a-form-item label="多语言键">
<a-input
:value="editForm.b_i18n"
@update:value="(v) => editForm.b_i18n = v || null"
@update:value="(v) => (editForm.b_i18n = v || null)"
/>
</a-form-item>
<a-form-item label="排序号">
<a-input-number
:value="editForm.b_xh"
:min="0"
@update:value="(v) => editForm.b_xh = v"
@update:value="(v) => (editForm.b_xh = v)"
/>
</a-form-item>
<a-form-item label="启用">
<a-switch
:checked="editForm.b_canuse === 1"
@update:checked="(v) => editForm.b_canuse = v ? 1 : 0"
@update:checked="(v) => (editForm.b_canuse = v ? 1 : 0)"
/>
</a-form-item>
<a-form-item>
@@ -0,0 +1,121 @@
<script setup>
import { ref } from "vue";
import { message } from "antdv-next";
import { loadDataApi, saveObjectApi } from "@/services/api";
// 快速设置多语言弹窗:按 key 加载 b_i18n 各语言值,保存后 emit("saved", key)。
// 调用方通过 ref 调用 open(key, defaultValue),defaultValue 用于默认语言的初始值。
const emit = defineEmits(["saved"]);
const visible = ref(false);
const saving = ref(false);
const i18nKey = ref("");
const entries = ref([]);
const langTypes = ref([]);
async function loadLangTypes() {
try {
const res = await loadDataApi("b_i18n_type", "b_canuse = 1", "b_xh ASC, b_id ASC");
langTypes.value = res.data || [];
} catch (e) {
message.error("加载语言类型失败:" + (e.message || e));
}
}
async function open(key, defaultValue = "") {
if (!key) {
message.warning("多语言键为空");
return;
}
i18nKey.value = key;
const escaped = key.replace(/'/g, "''");
try {
const [i18nRes] = await Promise.all([
loadDataApi("b_i18n", `b_key = '${escaped}'`, "b_locale ASC"),
loadLangTypes(),
]);
const rows = i18nRes.data || [];
const byLocale = Object.fromEntries(rows.map((r) => [r.b_locale, r]));
entries.value = langTypes.value.map((t) => {
const ex = byLocale[t.b_code];
const isDefault = t.b_default === 1 || t.b_code === "zh-CN";
return {
b_id: ex ? ex.b_id : null,
b_locale: t.b_code,
b_name: t.b_name,
b_value: ex ? ex.b_value : isDefault ? defaultValue : "",
};
});
} catch {
entries.value = [];
}
visible.value = true;
}
async function save() {
if (!i18nKey.value) {
message.warning("请填写多语言键");
return;
}
saving.value = true;
try {
const req = { table: "b_i18n", key_field: "b_id", inserts: [], updates: [] };
for (const e of entries.value) {
const value = e.b_value ?? "";
if (!e.b_id && !value) continue;
const row = { b_key: i18nKey.value, b_locale: e.b_locale, b_value: value };
if (e.b_id) req.updates.push({ ...row, b_id: e.b_id });
else req.inserts.push(row);
}
if (req.inserts.length || req.updates.length) {
await saveObjectApi([req]);
}
message.success("多语言已保存");
visible.value = false;
emit("saved", i18nKey.value);
} catch (err) {
message.error("保存失败:" + (err?.message || err));
} finally {
saving.value = false;
}
}
defineExpose({ open });
</script>
<template>
<a-modal v-model:open="visible" title="快速设置多语言" :confirm-loading="saving" @ok="save">
<a-form layout="vertical">
<a-form-item label="多语言键">
<a-input v-model:value="i18nKey" disabled />
</a-form-item>
<a-form-item label="多语言值">
<div v-for="(item, idx) in entries" :key="idx" class="i18n-entry-row">
<span class="i18n-locale-label">{{ item.b_name }} ({{ item.b_locale }})</span>
<a-input v-model:value="item.b_value" class="i18n-value" />
</div>
</a-form-item>
</a-form>
</a-modal>
</template>
<style scoped lang="scss">
.i18n-entry-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.i18n-locale-label {
flex: none;
width: 140px;
color: #646a73;
font-size: 13px;
}
.i18n-value {
flex: 1;
}
}
</style>
@@ -1,8 +1,7 @@
<script setup>
import { computed, ref, watch } from "vue";
import { message } from "antdv-next";
import { Languages } from "@lucide/vue";
import { loadDataApi, saveObjectApi } from "@/services/api";
import I18nQuickSetModal from "./I18nQuickSetModal.vue";
const props = defineProps({
moduleId: { type: String, required: true },
@@ -46,74 +45,15 @@ function onCodeInput(v) {
}
}
const i18nModalVisible = ref(false);
const i18nSaving = ref(false);
const i18nKey = ref("");
const i18nEntries = ref([]);
const langTypes = ref([]);
const i18nModalRef = ref(null);
async function loadLangTypes() {
try {
const res = await loadDataApi("b_i18n_type", "b_canuse = 1", "b_xh ASC, b_id ASC");
langTypes.value = res.data || [];
} catch (e) {
message.error("加载语言类型失败:" + (e.message || e));
}
}
async function openI18nModal() {
function openI18nModal() {
const key = formData.value?.b_i18n || `module.${formData.value?.b_code || ""}`;
i18nKey.value = key;
const escaped = key.replace(/'/g, "''");
try {
const [i18nRes] = await Promise.all([
loadDataApi("b_i18n", `b_key = '${escaped}'`, "b_locale ASC"),
loadLangTypes(),
]);
const rows = i18nRes.data || [];
const byLocale = Object.fromEntries(rows.map((r) => [r.b_locale, r]));
i18nEntries.value = langTypes.value.map((t) => {
const ex = byLocale[t.b_code];
const isDefault = t.b_default === 1 || t.b_code === "zh-CN";
return {
b_id: ex ? ex.b_id : null,
b_locale: t.b_code,
b_name: t.b_name,
b_value: ex ? ex.b_value : isDefault ? formData.value?.b_name || "" : "",
};
});
} catch {
i18nEntries.value = [];
}
i18nModalVisible.value = true;
i18nModalRef.value?.open(key, formData.value?.b_name || "");
}
async function saveI18n() {
if (!i18nKey.value) {
message.warning("请填写多语言键");
return;
}
i18nSaving.value = true;
try {
setField("b_i18n", i18nKey.value);
const req = { table: "b_i18n", key_field: "b_id", inserts: [], updates: [] };
for (const e of i18nEntries.value) {
const value = e.b_value ?? "";
if (!e.b_id && !value) continue;
const row = { b_key: i18nKey.value, b_locale: e.b_locale, b_value: value };
if (e.b_id) req.updates.push({ ...row, b_id: e.b_id });
else req.inserts.push(row);
}
if (req.inserts.length || req.updates.length) {
await saveObjectApi([req]);
}
message.success("多语言已保存");
i18nModalVisible.value = false;
} catch (err) {
message.error("保存失败:" + (err?.message || err));
} finally {
i18nSaving.value = false;
}
function onI18nSaved(key) {
setField("b_i18n", key);
}
</script>
@@ -149,7 +89,12 @@ async function saveI18n() {
{ label: '页面', value: 'page' },
{ label: '数据', value: 'data' },
]"
@update:value="(v) => { formData.b_module_type = v; emitUpdate(); }"
@update:value="
(v) => {
formData.b_module_type = v;
emitUpdate();
}
"
/>
</a-form-item>
</a-col>
@@ -164,11 +109,7 @@ async function saveI18n() {
<a-col :span="8">
<a-form-item label="多语言键">
<div class="i18n-key-row">
<a-input
:value="formData.b_i18n"
readonly
placeholder="填写模块编码后自动生成"
/>
<a-input :value="formData.b_i18n" readonly placeholder="填写模块编码后自动生成" />
<a-button
class="i18n-key-btn"
type="text"
@@ -245,24 +186,7 @@ async function saveI18n() {
</template>
</a-form>
<a-modal
v-model:open="i18nModalVisible"
title="快速设置多语言"
:confirm-loading="i18nSaving"
@ok="saveI18n"
>
<a-form layout="vertical">
<a-form-item label="多语言键">
<a-input v-model:value="i18nKey" disabled />
</a-form-item>
<a-form-item label="多语言值">
<div v-for="(item, idx) in i18nEntries" :key="idx" class="i18n-entry-row">
<span class="i18n-locale-label">{{ item.b_name }} ({{ item.b_locale }})</span>
<a-input v-model:value="item.b_value" class="i18n-value" />
</div>
</a-form-item>
</a-form>
</a-modal>
<I18nQuickSetModal ref="i18nModalRef" @saved="onI18nSaved" />
</div>
</div>
</template>
@@ -298,22 +222,4 @@ async function saveI18n() {
.i18n-key-btn {
flex: none;
}
.i18n-entry-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.i18n-locale-label {
flex: none;
width: 140px;
color: #646a73;
font-size: 13px;
}
.i18n-value {
flex: 1;
}
}
</style>
@@ -27,7 +27,10 @@ watch(
);
function emitUpdate() {
emit("update", localEdit.value.map((r) => ({ ...r })));
emit(
"update",
localEdit.value.map((r) => ({ ...r })),
);
}
const availableFields = computed(() =>
@@ -101,7 +104,14 @@ const columns = [
title: "操作",
width: 60,
align: "center",
actions: [{ type: "danger", label: "删除", confirm: { title: "确定删除该记录?", okText: "删除" }, onClick: (row) => deleteRow(row) }],
actions: [
{
type: "danger",
label: "删除",
confirm: { title: "确定删除该记录?", okText: "删除" },
onClick: (row) => deleteRow(row),
},
],
},
];
</script>
@@ -34,7 +34,10 @@ watch(
);
function emitUpdate() {
emit("update", localFields.value.map((r) => ({ ...r })));
emit(
"update",
localFields.value.map((r) => ({ ...r })),
);
}
const columns = [
@@ -66,11 +69,7 @@ const columns = [
<div class="fields-panel">
<div class="panel-toolbar">
<a-space>
<a-button
v-if="viewTable"
type="primary"
@click="emit('sync')"
>
<a-button v-if="viewTable" type="primary" @click="emit('sync')">
同步字段 ({{ viewTable }})
</a-button>
</a-space>
@@ -2,8 +2,14 @@
import { computed, h, nextTick, ref, watch } from "vue";
import { message, Select } from "antdv-next";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { loadDataApi, saveObjectApi } from "@/services/api";
import { cloneData, diffRows, allocateTemporaryIds, tableChange, hasChanges } from "@/utils/dataChanges";
import { loadDataApi } from "@/services/api";
import {
cloneData,
diffRows,
allocateTemporaryIds,
tableChange,
hasChanges,
} from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
const props = defineProps({
@@ -14,7 +20,6 @@ const props = defineProps({
const i18nData = ref([]);
const originals = ref([]);
const isLoading = ref(false);
const isSaving = ref(false);
const searchKey = ref("");
const langOptions = ref([]);
const activeKey = ref(null);
@@ -53,12 +58,16 @@ watch(() => props.moduleId, loadI18n, { immediate: true });
async function addRow() {
const def = langOptions.value[0]?.value || "zh-CN";
const newId = createTempId();
i18nData.value.push({
b_id: newId,
b_key: "",
b_locale: def,
b_value: "",
});
// 用新引用替换(而非 push),否则 displayedData 这个 computed 因返回同一数组引用而不会触发重渲染
i18nData.value = [
...i18nData.value,
{
b_id: newId,
b_key: "",
b_locale: def,
b_value: "",
},
];
activeKey.value = String(newId);
await nextTick();
@@ -69,51 +78,42 @@ function deleteRow(record) {
i18nData.value = i18nData.value.filter((r) => r.b_id !== record.b_id);
}
async function save() {
// 由父级统一保存调用:返回 b_i18n 的变更请求(无变更返回 null),保存成功后父级调用 markSaved 同步基线
async function buildSaveRequest() {
const changes = diffRows(i18nData.value, originals.value);
const req = tableChange("b_i18n", changes);
if (!hasChanges(req)) {
message.info("没有需要保存的变更");
return;
}
isSaving.value = true;
try {
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
await saveObjectApi([req]);
originals.value = cloneData(i18nData.value);
message.success("保存成功");
} catch (e) {
message.error("保存失败:" + (e.message || e));
} finally {
isSaving.value = false;
}
if (!hasChanges(req)) return null;
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
return req;
}
function markSaved() {
originals.value = cloneData(i18nData.value);
}
defineExpose({ buildSaveRequest, markSaved });
const displayedData = computed(() => {
if (!searchKey.value) return i18nData.value;
const kw = searchKey.value.toLowerCase();
return i18nData.value.filter(
(r) =>
(r.b_key || "").toLowerCase().includes(kw) ||
(r.b_value || "").toLowerCase().includes(kw),
(r.b_key || "").toLowerCase().includes(kw) || (r.b_value || "").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;
},
return h(Select, {
value: row.b_locale,
size: "small",
style: { width: "100%" },
options: langOptions.value,
onChange: (val) => {
row.b_locale = val;
},
);
});
};
}
@@ -126,7 +126,14 @@ const columns = [
title: "操作",
width: 60,
align: "center",
actions: [{ type: "danger", label: "删除", confirm: { title: "确定删除该记录?", okText: "删除" }, onClick: (row) => deleteRow(row) }],
actions: [
{
type: "danger",
label: "删除",
confirm: { title: "确定删除该记录?", okText: "删除" },
onClick: (row) => deleteRow(row),
},
],
},
];
</script>
@@ -142,11 +149,17 @@ const columns = [
allow-clear
/>
<a-button type="primary" @click="addRow">新增翻译</a-button>
<a-button :loading="isSaving" @click="save">保存</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable ref="tableRef" :columns="columns" :data-source="displayedData" row-key="b_id" :active-row-key="activeKey" @row-click="(_, row) => (activeKey = String(row.b_id))" />
<FmsTable
ref="tableRef"
:columns="columns"
:data-source="displayedData"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row.b_id))"
/>
</div>
</div>
</template>
@@ -27,7 +27,10 @@ watch(
);
function emitUpdate() {
emit("update", localList.value.map((r) => ({ ...r })));
emit(
"update",
localList.value.map((r) => ({ ...r })),
);
}
const availableFields = computed(() =>
@@ -106,7 +109,14 @@ const columns = [
title: "操作",
width: 60,
align: "center",
actions: [{ type: "danger", label: "删除", confirm: { title: "确定删除该记录?", okText: "删除" }, onClick: (row) => deleteRow(row) }],
actions: [
{
type: "danger",
label: "删除",
confirm: { title: "确定删除该记录?", okText: "删除" },
onClick: (row) => deleteRow(row),
},
],
},
];
</script>
@@ -1,7 +1,11 @@
<script setup>
import { nextTick, ref, watch } from "vue";
import { 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";
import FpsMonitor from "@/components/FpsMonitor.vue";
import { createTempId } from "@/utils/tempId";
import I18nQuickSetModal from "./I18nQuickSetModal.vue";
const props = defineProps({
moduleId: { type: String, required: true },
@@ -23,16 +27,17 @@ watch(
localPowers.value = (val || []).map((r) => ({
...r,
b_i18n:
r.b_code && props.moduleCode
? `power.${props.moduleCode}.${r.b_code}`
: (r.b_i18n ?? null),
r.b_code && props.moduleCode ? `power.${props.moduleCode}.${r.b_code}` : (r.b_i18n ?? null),
}));
},
{ immediate: true },
);
function emitUpdate() {
emit("update", localPowers.value.map((r) => ({ ...r })));
emit(
"update",
localPowers.value.map((r) => ({ ...r })),
);
}
async function addRow() {
@@ -58,25 +63,125 @@ function deleteRow(record) {
emitUpdate();
}
// ── 快速设置多语言 ──
const i18nModalRef = ref(null);
function openI18n(row) {
const key =
row.b_code && props.moduleCode ? `power.${props.moduleCode}.${row.b_code}` : row.b_i18n;
if (!key) {
message.warning("请先填写操作编码");
return;
}
i18nModalRef.value?.open(key, row.b_name || "");
}
// 多语言键单元格:值 + 右侧翻译 icon 按钮(原生 button,避免 antdv Button 组件开销)
const I18nKeyCell = ({ row, cellValue }) =>
h(
"span",
{
style: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "4px",
width: "100%",
},
},
[
h(
"span",
{ style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
cellValue ?? "",
),
h(
"button",
{
type: "button",
title: "设置多语言",
style: {
flex: "none",
border: "none",
background: "transparent",
cursor: "pointer",
padding: "2px",
display: "inline-flex",
alignItems: "center",
},
onClick: (e) => {
e.stopPropagation();
openI18n(row);
},
},
[h(Languages, { size: 14 })],
),
],
);
// 启用状态单元格:原生 checkbox + 自定义 CSS 模拟 antdv-next 外观,不创建组件实例
const CanUseCell = ({ row, cellValue }) => {
const checked = cellValue === 1;
return h(
"label",
{
class: "pw-checkbox",
onClick: (e) => e.stopPropagation(),
},
[
h("input", {
type: "checkbox",
checked: checked,
onChange: (e) => {
e.stopPropagation();
row.b_canuse = e.target.checked ? 1 : 0;
emitUpdate();
},
}),
h("span", {
class: ["pw-checkbox-box", checked ? "checked" : null].filter(Boolean).join(" "),
}),
],
);
};
// 操作列单元格:原生 button + Modal.confirm,避免每行一个 Popconfirm 实例的开销
const ActionsCell = ({ row }) =>
h(
"button",
{
type: "button",
title: "删除",
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_code", title: "操作编码", width: 160, editor: { onChange: () => emitUpdate() } },
{ dataIndex: "b_name", title: "操作名称", width: 160, editor: { onChange: () => emitUpdate() } },
{ dataIndex: "b_i18n", title: "多语言键", width: 160 },
{ dataIndex: "b_i18n", title: "多语言键", width: 160, customCell: I18nKeyCell },
{ dataIndex: "b_xh", title: "排序", width: 80, editor: { onChange: () => emitUpdate() } },
{
dataIndex: "b_canuse",
title: "启用",
width: 70,
align: "center",
editor: { type: "checkbox", onChange: () => emitUpdate() },
},
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
actions: [{ type: "danger", label: "删除", confirm: { title: "确定删除该记录?", okText: "删除" }, onClick: (row) => deleteRow(row) }],
},
{ dataIndex: "b_canuse", title: "启用", width: 70, align: "center", customCell: CanUseCell },
{ dataIndex: "actions", title: "操作", width: 60, align: "center", customCell: ActionsCell },
];
</script>
@@ -88,8 +193,17 @@ const columns = [
</a-space>
</div>
<div class="panel-body">
<FmsTable ref="tableRef" :columns="columns" :data-source="localPowers" row-key="b_id" :active-row-key="activeKey" @row-click="(_, row) => (activeKey = String(row.b_id))" />
<FpsMonitor />
<FmsTable
ref="tableRef"
:columns="columns"
:data-source="localPowers"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row.b_id))"
/>
</div>
<I18nQuickSetModal ref="i18nModalRef" />
</div>
</template>
@@ -121,3 +235,54 @@ const columns = [
width: 100%;
}
</style>
<!-- 非 scoped 全局样式:customCell 通过 h() 渲染的元素不带 data-v 属性,
scoped 的 :deep() 可能不生效,用全局 style 确保 checkbox 样式注入。
基于 antdv-next Checkbox 源码(checkbox/style/index.js)精确模拟外观。 -->
<style lang="scss">
.pw-checkbox {
display: inline-flex;
align-items: center;
cursor: pointer;
line-height: 1;
margin: 0;
input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
margin: 0;
}
.pw-checkbox-box {
display: block;
position: relative;
width: 16px;
height: 16px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
transition: all 0.2s;
box-sizing: border-box;
flex: none;
&.checked {
background: #1677ff;
border-color: #1677ff;
&::after {
content: "";
position: absolute;
left: 5px;
top: 2px;
width: 4px;
height: 8px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
}
}
</style>
@@ -38,7 +38,10 @@ watch(
);
function emitUpdate() {
emit("update", localQuery.value.map((r) => ({ ...r })));
emit(
"update",
localQuery.value.map((r) => ({ ...r })),
);
}
const availableFields = computed(() =>
@@ -111,7 +114,14 @@ const columns = [
title: "操作",
width: 60,
align: "center",
actions: [{ type: "danger", label: "删除", confirm: { title: "确定删除该记录?", okText: "删除" }, onClick: (row) => deleteRow(row) }],
actions: [
{
type: "danger",
label: "删除",
confirm: { title: "确定删除该记录?", okText: "删除" },
onClick: (row) => deleteRow(row),
},
],
},
];
</script>
@@ -3,7 +3,13 @@ import { computed, h, ref, watch } from "vue";
import { message, Modal } from "antdv-next";
import FmsTree from "@/components/FmsTree.vue";
import { loadDataApi, saveObjectApi, nextIdApi, describeApi } from "@/services/api";
import { cloneData, diffRows, allocateTemporaryIds, tableChange, hasChanges } from "@/utils/dataChanges";
import {
cloneData,
diffRows,
allocateTemporaryIds,
tableChange,
hasChanges,
} from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
import { Plus } from "@lucide/vue";
import ModuleBasicPanel from "./components/ModuleBasicPanel.vue";
@@ -65,6 +71,7 @@ const drafts = ref({
const configLoading = ref(false);
const configSaving = ref(false);
const i18nPanelRef = ref(null);
// 当前选中模块对象(从 drafts.module 获取)
const selectedModule = computed(() => drafts.value.module);
@@ -245,9 +252,7 @@ async function onAddModule(parentId, moduleType = "directory") {
}
const addModuleMenu = {
items: [
{ key: "directory", label: "目录" },
],
items: [{ key: "directory", label: "目录" }],
onClick: ({ key }) => onAddModule(null, key),
};
@@ -265,6 +270,13 @@ function onContextMenuClick({ key }) {
if (key.startsWith("add-")) {
onAddModule(node.key, key.replace("add-", ""));
} else if (key === "delete") {
if (hasChildNode(node.key)) {
Modal.warning({
title: "无法删除",
content: "该节点下还有子节点,请先删除子节点后再删除当前节点。",
});
return;
}
Modal.confirm({
title: `确定删除「${node.title}」?`,
content: "此操作不可恢复",
@@ -276,7 +288,31 @@ function onContextMenuClick({ key }) {
}
}
function findNode(key) {
const walk = (nodes) => {
for (const n of nodes || []) {
if (String(n.b_id) === String(key)) return n;
const child = walk(n.children);
if (child) return child;
}
return null;
};
return walk(treeData.value);
}
function hasChildNode(moduleId) {
const node = findNode(moduleId);
return !!node?.children?.length;
}
async function onDeleteNodeById(moduleId) {
if (hasChildNode(moduleId)) {
Modal.warning({
title: "无法删除",
content: "该节点下还有子节点,请先删除子节点后再删除当前节点。",
});
return;
}
try {
await saveObjectApi([{ table: "b_module", key_field: "b_id", deletes: [{ b_id: moduleId }] }]);
message.success("模块已删除");
@@ -329,6 +365,12 @@ async function saveAll() {
}
}
// 多语言:由各子面板收集变更,统一在此保存
if (i18nPanelRef.value) {
const i18nReq = await i18nPanelRef.value.buildSaveRequest();
if (i18nReq) saveReqs.push(i18nReq);
}
if (saveReqs.length === 0) {
message.info("没有需要保存的变更");
return;
@@ -338,6 +380,7 @@ async function saveAll() {
// 更新 originals
originals.value = cloneData(drafts.value);
i18nPanelRef.value?.markSaved();
message.success("保存成功");
await loadTree();
} catch (e) {
@@ -393,9 +436,7 @@ async function syncFields() {
const viewColumnSet = new Set(columns.map((c) => c.name.toLowerCase()));
// 已有字段映射(按字段名)
const existingMap = new Map(
existingFields.map((f) => [f.b_field?.toLowerCase(), f]),
);
const existingMap = new Map(existingFields.map((f) => [f.b_field?.toLowerCase(), f]));
const mergedFields = [];
@@ -434,7 +475,9 @@ async function syncFields() {
if (hasChanges(req)) {
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
await saveObjectApi([req]);
message.success(`字段同步完成:${columns.length} 列,新增 ${req.inserts?.length || 0},停用 ${req.deletes?.length || 0}`);
message.success(
`字段同步完成:${columns.length} 列,新增 ${req.inserts?.length || 0},停用 ${req.deletes?.length || 0}`,
);
} else {
message.info("字段已是最新,无需同步");
}
@@ -471,12 +514,10 @@ loadTree();
<div v-if="treeData.length === 0" class="mm-tree-empty">
<a-empty description="暂无模块" />
</div>
<a-dropdown
v-else
:trigger="['contextmenu']"
:menu="contextMenuItems"
>
<div style="flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;">
<a-dropdown v-else :trigger="['contextmenu']" :menu="contextMenuItems">
<div
style="flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column"
>
<FmsTree
class="mm-tree"
:tree-data="treeData"
@@ -502,9 +543,6 @@ loadTree();
<div class="mm-header">
<a-space>
<a-button type="primary" :loading="configSaving" @click="saveAll">保存</a-button>
<a-popconfirm title="确定删除该模块?" @confirm="onDeleteNodeById(selectedModuleId)">
<a-button danger>删除模块</a-button>
</a-popconfirm>
</a-space>
<div class="mm-header-center">
<a-space size="middle">
@@ -523,20 +561,22 @@ loadTree();
</a-space>
</div>
<span class="mm-header-title">{{ selectedModule.b_name || selectedModule.b_code }}</span>
<span class="mm-header-code">{{ selectedModule.b_code || '(未设置编码)' }}</span>
<span class="mm-header-code">{{ selectedModule.b_code || "(未设置编码)" }}</span>
<span class="mm-header-type">
<a-tag>{{ selectedModule.b_module_type === 'directory' ? '目录' : selectedModule.b_module_type === 'page' ? '页面' : '数据' }}</a-tag>
<a-tag>{{
selectedModule.b_module_type === "directory"
? "目录"
: selectedModule.b_module_type === "page"
? "页面"
: "数据"
}}</a-tag>
</span>
</div>
<!-- Tab 面板 -->
<div class="mm-body">
<a-tabs v-model:active-key="activeTab" class="mm-tabs">
<a-tab-pane
v-for="tab in visibleTabs"
:key="tab.key"
:tab="tab.tab"
>
<a-tab-pane v-for="tab in visibleTabs" :key="tab.key" :tab="tab.tab">
<template v-if="configLoading">
<div class="mm-loading"><a-spin /></div>
</template>
@@ -596,6 +636,7 @@ loadTree();
/>
<ModuleI18nPanel
v-else-if="tab.key === 'i18n'"
ref="i18nPanelRef"
:module-id="String(selectedModule.b_id)"
:loading="configSaving"
/>
@@ -64,7 +64,14 @@ function createActionsCell() {
children.push(
h(
Button,
{ type: "link", size: "small", onClick: (e) => { e.stopPropagation(); openEdit(row); } },
{
type: "link",
size: "small",
onClick: (e) => {
e.stopPropagation();
openEdit(row);
},
},
"编辑",
),
);
@@ -97,10 +104,28 @@ function createActionsCell() {
const columns = [
{ dataIndex: "b_code", title: "简称 / 编码", width: 150 },
{ dataIndex: "b_name", title: "名称", width: 160 },
{ dataIndex: "b_default", title: "默认", width: 80, align: "center", customCell: createDefaultCell() },
{ dataIndex: "b_canuse", title: "是否启用", width: 100, align: "center", customCell: createCanuseCell() },
{
dataIndex: "b_default",
title: "默认",
width: 80,
align: "center",
customCell: createDefaultCell(),
},
{
dataIndex: "b_canuse",
title: "是否启用",
width: 100,
align: "center",
customCell: createCanuseCell(),
},
{ dataIndex: "b_xh", title: "排序", width: 80, align: "center" },
{ dataIndex: "actions", title: "操作", width: 130, align: "center", customCell: createActionsCell() },
{
dataIndex: "actions",
title: "操作",
width: 130,
align: "center",
customCell: createActionsCell(),
},
];
/* ---------- 行内开关即时保存 ---------- */
@@ -157,10 +182,10 @@ function openEdit(row) {
modalMode.value = "edit";
editingId.value = row.b_id;
editingDefault.value = row.b_default === 1;
form.value = {
b_code: row.b_code,
b_name: row.b_name,
b_xh: row.b_xh ?? 0,
form.value = {
b_code: row.b_code,
b_name: row.b_name,
b_xh: row.b_xh ?? 0,
defaultOn: row.b_default === 1,
canuseOn: row.b_canuse === 1,
};
@@ -240,10 +265,7 @@ async function saveForm() {
>
<a-form layout="vertical">
<a-form-item label="语言编码(简称)" required>
<a-input
v-model:value="form.b_code"
:disabled="editingDefault"
/>
<a-input v-model:value="form.b_code" :disabled="editingDefault" />
</a-form-item>
<a-form-item label="语言名称" required>
<a-input v-model:value="form.b_name" />