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

+1 -1
View File
@@ -41,7 +41,7 @@ pnpm fmt:check # check formatting
There is **no lint or test script** in the frontend.
### Build/test discipline (from `开发规范.md`)
Do **not** run build/test/start yourself after finishing a change. Only build, test, or start services when the user explicitly asks. When delivering, state that build/test were not executed.
Do **not** run build or start services yourself after finishing a change — only when the user explicitly asks. Running tests to verify changes is allowed (e.g. backend `./mvnw test`, frontend `pnpm fmt:check`). When delivering, state whether tests were run (and their results) and that no build was executed.
## Backend architecture
+1 -3
View File
@@ -1,7 +1,5 @@
<script setup>
const themeConfig = {
};
const themeConfig = {};
</script>
<template>
@@ -0,0 +1,184 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
defineOptions({ inheritAttrs: false });
const props = defineProps({
// FPS 计算采样间隔(毫秒)
sampleInterval: { type: Number, default: 500 },
// 历史柱状图保留的采样点数
historySize: { type: Number, default: 48 },
// 柱状图基准 FPS(满高对应值)
baseFps: { type: Number, default: 60 },
});
const currentFps = ref(0);
const history = ref([]);
let rafId = null;
let frames = 0;
let lastTime = 0;
let totalFrames = 0;
let totalTime = 0;
let minFps = Infinity;
function tick(now) {
frames++;
totalFrames++;
const elapsed = now - lastTime;
if (elapsed >= props.sampleInterval) {
const fps = Math.round((frames * 1000) / elapsed);
currentFps.value = fps;
if (fps < minFps) minFps = fps;
totalTime += elapsed;
const next =
history.value.length >= props.historySize ? history.value.slice(1) : history.value.slice();
next.push(fps);
history.value = next;
frames = 0;
lastTime = now;
}
rafId = requestAnimationFrame(tick);
}
onMounted(() => {
lastTime = performance.now();
rafId = requestAnimationFrame(tick);
});
onBeforeUnmount(() => {
if (rafId) cancelAnimationFrame(rafId);
});
const avgFps = computed(() => (totalTime > 0 ? Math.round((totalFrames * 1000) / totalTime) : 0));
const minFpsDisplay = computed(() => (minFps === Infinity ? "--" : minFps));
const fpsColor = computed(() => {
const f = currentFps.value;
if (f >= 50) return "#52c41a";
if (f >= 30) return "#faad14";
return "#ff4d4f";
});
function barStyle(f) {
const h = Math.max(8, Math.min(100, (f / props.baseFps) * 100));
const color = f >= 50 ? "#52c41a" : f >= 30 ? "#faad14" : "#ff4d4f";
return { height: `${h}%`, background: color };
}
function reset() {
history.value = [];
totalFrames = 0;
totalTime = 0;
minFps = Infinity;
currentFps.value = 0;
}
</script>
<template>
<div class="fps-monitor" v-bind="$attrs">
<div class="fps-monitor__head">
<span class="fps-monitor__label">FPS</span>
<span class="fps-monitor__now" :style="{ color: fpsColor }">{{ currentFps || "--" }}</span>
<button class="fps-monitor__reset" type="button" title="重置统计" @click="reset">↻</button>
</div>
<div class="fps-monitor__chart">
<div v-for="(f, i) in history" :key="i" class="fps-monitor__bar" :style="barStyle(f)"></div>
</div>
<div class="fps-monitor__foot">
<span
>avg <b>{{ avgFps || "--" }}</b></span
>
<span
>min <b>{{ minFpsDisplay }}</b></span
>
</div>
</div>
</template>
<style scoped>
.fps-monitor {
position: absolute;
top: 8px;
right: 8px;
z-index: 999;
display: flex;
flex-direction: column;
gap: 6px;
width: 108px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.4;
color: rgba(0, 0, 0, 0.85);
background: rgba(255, 255, 255, 0.92);
border: 1px solid #e8e8e8;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(4px);
user-select: none;
}
.fps-monitor__head {
display: flex;
align-items: baseline;
gap: 6px;
}
.fps-monitor__label {
font-weight: 600;
color: rgba(0, 0, 0, 0.55);
}
.fps-monitor__now {
flex: 1;
font-size: 18px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.fps-monitor__reset {
flex: none;
width: 18px;
height: 18px;
padding: 0;
font-size: 13px;
line-height: 1;
color: rgba(0, 0, 0, 0.45);
background: transparent;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s;
}
.fps-monitor__reset:hover {
color: rgba(0, 0, 0, 0.85);
background: rgba(0, 0, 0, 0.06);
}
.fps-monitor__chart {
display: flex;
align-items: flex-end;
gap: 1px;
height: 32px;
}
.fps-monitor__bar {
flex: 1;
min-width: 2px;
border-radius: 1px;
transition:
height 0.2s ease,
background 0.2s ease;
}
.fps-monitor__foot {
display: flex;
justify-content: space-between;
color: rgba(0, 0, 0, 0.55);
}
.fps-monitor__foot b {
color: rgba(0, 0, 0, 0.85);
font-variant-numeric: tabular-nums;
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { computed, nextTick, ref, useSlots } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots } from "vue";
import { useRafFn, useResizeObserver } from "@vueuse/core";
import { StkTable } from "stk-table-vue";
import { createActionsCellRenderer } from "./utils/createActionsCellRenderer";
@@ -30,15 +30,150 @@ function getRowKey(row) {
const selectedRowKeySet = computed(() => new Set(props.rowSelection?.selectedRowKeys || []));
// 追踪用户点击的单元格列,用于编辑态自动聚焦到对应列
// 追踪当前编辑列,用于 EditableCell 决定哪个单元格自动聚焦
const activeColKey = ref(null);
function onStkRowClick(ev, row, data) {
const td = ev.target.closest?.("td");
activeColKey.value = td?.dataset?.colKey || null;
// 更新键盘导航的起始位置
const rowIndex = props.dataSource.findIndex(
(r) => String(getRowKey(r)) === String(getRowKey(row)),
);
const colIndex = props.columns.findIndex((c) => c.dataIndex === activeColKey.value);
if (rowIndex >= 0 && colIndex >= 0) {
currentPos.value = { rowIndex, colIndex };
}
emit("row-click", ev, row, data);
}
// ==================== 自定义键盘导航 ====================
// StkTable 的 areaSelection keyboard 模式在按键重复(~30Hz)时内部做选区矩形计算、
// overlay DOM 操作和虚拟滚动重算——这些不经过我们的 RAF,是 FPS ~12 的根源。
// 这里关闭 areaSelection 内置键盘处理,自己接管方向键,只做行/列定位 + RAF 批量化更新。
const currentPos = ref({ rowIndex: -1, colIndex: -1 });
let navRafPending = false;
let navPendingState = null;
/** RAF 批量化提交一次导航(activeColKey + row-click emit),每帧至多一次。 */
function flushNav() {
navRafPending = false;
const p = navPendingState;
navPendingState = null;
if (!p) return;
if (activeColKey.value !== p.colKey) {
activeColKey.value = p.colKey;
}
const rowKey = String(getRowKey(p.row));
if (String(props.activeRowKey ?? "") !== rowKey) {
emit("row-click", null, p.row, p.rows);
}
}
function navigateTo(rowIndex, colIndex) {
const rows = props.dataSource;
const cols = props.columns;
if (rowIndex < 0 || rowIndex >= rows.length || colIndex < 0 || colIndex >= cols.length) return;
const row = rows[rowIndex];
const colKey = cols[colIndex].dataIndex;
currentPos.value = { rowIndex, colIndex };
navPendingState = { row, colKey, rows };
if (!navRafPending) {
navRafPending = true;
requestAnimationFrame(flushNav);
}
// 虚拟滚动跟随:目标行超出可视区时滚动
const table = tableRef.value;
if (!table) return;
const el = table.$el;
const scrollTop = el?.scrollTop ?? 0;
const containerHeight = el?.clientHeight ?? 600;
const visibleTop = scrollTop + headerRowHeight;
const visibleBottom = scrollTop + containerHeight;
const targetTop = rowIndex * rowHeight;
if (targetTop < visibleTop) {
table.scrollTo(Math.max(0, targetTop - headerRowHeight), null);
} else if (targetTop + rowHeight > visibleBottom) {
table.scrollTo(targetTop + rowHeight - containerHeight + headerRowHeight, null);
}
}
function onTableKeydown(event) {
const { key, shiftKey, ctrlKey } = event;
if (ctrlKey || ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Tab"].indexOf(key) < 0)
return;
if (key === "Tab" && shiftKey) return;
// 输入框/文本框内按键留给浏览器处理光标移动,不拦截为表格导航
const tag = event.target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || event.target?.isContentEditable)
return;
const rows = props.dataSource;
const cols = props.columns;
if (rows.length === 0 || cols.length === 0) return;
let { rowIndex, colIndex } = currentPos.value;
// 首次按键或位置无效时,从 activeRowKey 推导起始位置
if (rowIndex < 0 || colIndex < 0 || rowIndex >= rows.length) {
const activeRowKeyStr = String(props.activeRowKey ?? "");
rowIndex = rows.findIndex((r) => String(getRowKey(r)) === activeRowKeyStr);
colIndex = 0;
if (rowIndex < 0) rowIndex = 0;
currentPos.value = { rowIndex, colIndex };
}
switch (key) {
case "ArrowUp":
if (rowIndex > 0) rowIndex--;
break;
case "ArrowDown":
if (rowIndex < rows.length - 1) rowIndex++;
break;
case "ArrowLeft":
if (colIndex > 0) colIndex--;
break;
case "ArrowRight":
case "Tab":
if (colIndex < cols.length - 1) colIndex++;
else if (rowIndex < rows.length - 1) {
rowIndex++;
colIndex = 0;
}
break;
default:
return;
}
event.preventDefault();
event.stopPropagation();
navigateTo(rowIndex, colIndex);
}
function attachKeyboard() {
nextTick(() => {
const el = tableRef.value?.$el;
if (el) el.addEventListener("keydown", onTableKeydown, true);
});
}
function detachKeyboard() {
const el = tableRef.value?.$el;
if (el) el.removeEventListener("keydown", onTableKeydown, true);
}
onMounted(attachKeyboard);
onBeforeUnmount(detachKeyboard);
// ==================== 自定义键盘导航 END ====================
const EditableCell = createEditableCellRenderer({
getRowKey,
getActiveRowKey: () => props.activeRowKey,
@@ -52,14 +187,43 @@ const { SelectionCell, SelectionHeaderCell } = createSelectionCellRenderers({
onChange: (keys, rows) => props.rowSelection?.onChange?.(keys, rows),
});
// areaSelection 仅保留 enabled 做占位(避免 StkTable prop 类型警告),
// keyboard 已关闭:键盘导航改为本组件自实现的 onTableKeydown 接管,
// 绕过 StkTable 内部每帧 30+ 次的选区计算 + overlay DOM 操作。
const areaSelectionConfig = ref({
enabled: true,
keyboard: false,
highlight: {
cell: false,
row: false,
},
});
// 列宽拖拽结果缓存:StkTable 拖拽列宽依赖 v-model:columns 回写;
// 父组件多为单向 :columns 绑定,这里在封装层内部保留拖拽后的宽度,避免重渲染时被重置。
const resizedWidths = ref({});
function getColumnKey(column) {
return column.dataIndex ?? column.key;
}
// StkTable 的编辑单元格、操作列和多选列统一在封装层生成,业务页面只声明 editor / actions / rowSelection。
const tableColumnsModel = computed({
get: () => {
// 依赖 activeRowKey:切换编辑行时触发 columns 重算,迫使 StkTable 重渲染单元格
void props.activeRowKey;
const columns = props.columns.map((column) => {
// 不依赖 activeRowKey:EditableCell 作为函数式组件,内部已通过 getActiveRowKey()/
// getActiveColKey() 读取响应式 props.activeRowKey 与 activeColKey,切换编辑行时
// 只有可见的编辑单元格会自动重渲染。若在此处依赖 activeRowKey,每次切换都会生成
// 全新列对象数组,触发 StkTable 的 watch(columns) → handleDealColumns 全表重渲染,
// 这是编辑/新增时卡顿的根源(对比 TableDemo:customCell 直接读 ref 即可精确更新)。
const columns = props.columns.map((rawColumn) => {
const width = resizedWidths.value[getColumnKey(rawColumn)];
const column = width != null ? { ...rawColumn, width } : rawColumn;
if (column.actions && !column.customCell) {
return { ...column, customCell: createActionsCellRenderer(column.actions), __fmsAction: true };
return {
...column,
customCell: createActionsCellRenderer(column.actions),
__fmsAction: true,
};
}
if (column.editor && !column.customCell) {
return { ...column, customCell: EditableCell, __fmsEditor: true };
@@ -83,7 +247,13 @@ const tableColumnsModel = computed({
...columns,
];
},
set: (value) =>
set: (value) => {
// 缓存拖拽后的列宽,供 get 重新应用(即使父组件只用单向 :columns 绑定)
for (const column of value) {
if (column.width != null) {
resizedWidths.value[getColumnKey(column)] = column.width;
}
}
emit(
"update:columns",
value
@@ -96,16 +266,22 @@ const tableColumnsModel = computed({
delete rest.__fmsAction;
return rest;
}),
),
);
},
});
const slots = useSlots();
const tableRef = ref(null);
// 表格样式
// 表格行高:既作为 CSS 变量控制实际 DOM 行高,又作为 StkTable 的 row-height /
// header-row-height prop 传入,确保虚拟滚动按实际 DOM 行高计算总高度、pageSize、
// offsetTop/offsetBottom。否则 StkTable 默认 rowHeight=28 与 CSS 变量 34 不一致,
// 会导致滚动到底部时计算高度与实际内容高度不匹配而回弹。
const rowHeight = 34;
const headerRowHeight = 40;
const customVars = ref({
"--header-row-height": "40px",
"--row-height": "34px",
"--header-row-height": `${headerRowHeight}px`,
"--row-height": `${rowHeight}px`,
"--footer-row-height": "36px",
});
@@ -136,14 +312,18 @@ const forwardSlots = computed(() => {
return result;
});
// areaSelection 只做外观占位,键盘导航由自实现接管以避免 StkTable 内部选区计算开销。
// 鼠标点击后 keyboard 会通过 onTableKeydown 从 activeRowKey 推导起始位置继续导航。
// 滚动到指定行;用于新增后自动聚焦
async function scrollToRow(rowKey) {
await nextTick();
const el = tableRef.value?.$el;
if (!el) return;
const key = String(rowKey);
const rowEl = el.querySelector(`[data-row-key="${key}"]`);
rowEl?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
const table = tableRef.value;
if (!table) return;
const rows = props.dataSource;
const idx = rows.findIndex((row) => String(getRowKey(row)) === String(rowKey));
if (idx < 0) return;
table.scrollTo(idx * rowHeight, null);
}
defineExpose({ scrollToRow });
@@ -156,6 +336,8 @@ defineExpose({ scrollToRow });
v-bind="$attrs"
:style="customVars"
:auto-resize="false"
:row-height="rowHeight"
:header-row-height="headerRowHeight"
:virtual="virtual"
:virtual-x="virtualX"
:col-resizable="colResizable"
@@ -164,6 +346,7 @@ defineExpose({ scrollToRow });
:row-key="rowKey"
:bordered="bordered"
:no-data-full="noDataFull"
:area-selection="areaSelectionConfig"
@row-click="onStkRowClick"
>
<template v-for="(slot, name) in forwardSlots" :key="name" #[name]="slotProps">
@@ -192,4 +375,29 @@ defineExpose({ scrollToRow });
white-space: nowrap;
vertical-align: middle;
}
/* 编辑单元格 wrapper:始终同时渲染文本 span 和编辑组件,通过 display 切换。
保证编辑/文本切换时 Vue patch 只更新 props,不触发组件 mount/unmount。 */
::deep(.fms-editable-cell-wrap) {
display: flex;
align-items: center;
width: 100%;
height: 100%;
}
::deep(.fms-editable-cell-wrap .fms-cell-text) {
flex: 1;
}
::deep(
.fms-editable-cell-wrap .ant-input-number,
.fms-editable-cell-wrap .ant-select,
.fms-editable-cell-wrap .ant-input
) {
flex: 1;
}
/* 隐藏 areaSelection 视觉覆盖层:只借用它的键盘导航 + 虚拟滚动跟随,
不需要蓝色的选区高亮遮罩。编辑态下由 EditableCell 的 input/select 展示焦点。 */
::deep(.stk-table-area-selection) {
display: none !important;
}
</style>
@@ -90,8 +90,7 @@ export function createActionsCellRenderer(actions) {
const btnNode = h(Button, btnProps, () => resolve(action.label, row));
if (action.confirm) {
const confirmConf =
typeof action.confirm === "object" ? action.confirm : {};
const confirmConf = typeof action.confirm === "object" ? action.confirm : {};
return h(
Popconfirm,
{
@@ -1,42 +0,0 @@
import { h } from "vue";
// ── 静态常量(避免每次渲染重复创建) ────────────────────────
const stopBubble = (e) => e.stopPropagation();
// ── 基础工厂 ────────────────────────────────────────────────
/**
* 通用 cell 渲染器工厂。
*
* 返回的函数是纯渲染函数,不保存 row 引用、不产生额外组件层级。
*
* @param {object} config
* @param {object} config.component Vue 组件(Button / Checkbox / Select …)
* @param {object} [config.props] 静态 props(创建一次,不随行变化)
* @param {(row, col, ctx) => object} [config.getProps] 动态 props(每次 cell 渲染调用)
* @param {(row, col, ctx) => object} [config.getChildren] 动态 children / slots
* @param {boolean} [config.stopPropagation=true] 自动注入 onClick stopPropagation
* @returns {Function} (ctx) => VNode customCell 渲染函数
*/
export function createCellRenderer({
component,
props = {},
getProps,
getChildren,
stopPropagation = true,
}) {
return (ctx) => {
const merged = getProps ? { ...props, ...getProps(ctx.row, ctx.col, ctx) } : { ...props };
if (stopPropagation) {
const click = merged.onClick;
merged.onClick = click ? (e) => (e.stopPropagation(), click(e)) : stopBubble;
}
const children = getChildren ? getChildren(ctx.row, ctx.col, ctx) : undefined;
return h(component, merged, children);
};
}
@@ -6,16 +6,67 @@ function resolveOption(option, row) {
}
export function createEditableCellRenderer({ getRowKey, getActiveRowKey, getActiveColKey }) {
// 每个单元格独立的 debounce timer,避免每次击键都触发
// onChange → emitUpdate → emit → parent drafts → watch → localData 新引用
// → FmsTable dataSource 新引用 → StkTable 全量虚拟滚动重算 → 卡顿。
// 150ms debounce 让连续打字期间只触发一次同步。
const changeTimers = new Map();
// 追踪已聚焦过的单元格 key,避免在同一个编辑会话内重复 focus+select
//(例如父组件数据变化导致行重渲染时不应打断正在输入的用户)。
// 与旧实现不同:不依赖 DOM dataset,因为优化后编辑组件始终驻留 DOM。
const focusedCells = new Map();
// 缓存每个 cellKey 的 focusRef 闭包引用,避免每次渲染创建新引用导致
// Vue ref 回调反复触发(ref 变化 → ref(null) → ref(instance) → focus+select)。
const focusRefCache = new Map();
// 稳定引用:Vue VNode diff 按引用比较 props,每次 render 创建新函数/对象
// 会触发事件监听器重绑定和样式对比。提到外面让引用恒定,消除击键时的无意义 diff。
const stopPropagation = (e) => e.stopPropagation();
const fullWidth = { width: "100%" };
function getFocusRef(cellKey, enabled) {
if (!enabled) return undefined;
let fn = focusRefCache.get(cellKey);
if (!fn) {
fn = (instance) => {
if (!instance) return;
if (focusedCells.get(cellKey)) return;
focusedCells.set(cellKey, true);
const el = instance.$el;
const target = instance.focus ? instance : el;
target?.focus?.({ preventScroll: true });
const inputEl = el?.matches?.("input") ? el : el?.querySelector?.("input");
inputEl?.select?.();
};
focusRefCache.set(cellKey, fn);
}
return fn;
}
return function EditableCell({ row, col, cellValue }) {
const editor = typeof col.editor === "string" ? { type: col.editor } : col.editor || {};
const rowKey = String(getRowKey(row));
const activeRowKey = getActiveRowKey();
const isEditing = activeRowKey != null && String(getRowKey(row)) === String(activeRowKey);
const isEditing = activeRowKey != null && String(activeRowKey) === rowKey;
const activeColKey = getActiveColKey?.();
const shouldFocus = isEditing && (activeColKey == null || col.dataIndex === activeColKey);
const cellKey = `${rowKey}_${col.dataIndex}`;
const update = (value) => {
row[col.dataIndex] = value;
editor.onChange?.(value, row, col);
if (editor.onChange) {
const timerKey = `${rowKey}_${col.dataIndex}`;
clearTimeout(changeTimers.get(timerKey));
changeTimers.set(
timerKey,
setTimeout(() => {
changeTimers.delete(timerKey);
editor.onChange?.(value, row, col);
}, 150),
);
}
};
// checkbox 始终可交互(显示态与编辑态一致)
@@ -30,32 +81,40 @@ export function createEditableCellRenderer({ getRowKey, getActiveRowKey, getActi
});
}
if (!isEditing) {
if (editor.type === "select") {
const options = resolveOption(editor.options, row) || [];
const option = options.find((item) => item.value === cellValue);
return h("span", { class: "fms-cell-text" }, option?.label ?? cellValue ?? "");
}
return h("span", { class: "fms-cell-text" }, cellValue ?? "");
}
// 文本态内容
const textContent =
editor.type === "select"
? ((resolveOption(editor.options, row) || []).find((item) => item.value === cellValue)
?.label ??
cellValue ??
"")
: (cellValue ?? "");
// 始终同时渲染文本 span 和编辑组件,仅通过 CSS display 切换可见性。
// 这样 Vue diff 看到的是同一个 div 容器 + 同一对子 VNode 类型,
// 跨行切换时只更新 props/style,不触发组件卸载/挂载。
// (之前 if(!isEditing) return span else return Input → 不同类型 VNode
// → 强制 mount/unmount Ant Design 组件,是键盘导航时 FPS 暴跌的另一主因。)
const textSpan = h(
"span",
{
class: "fms-cell-text",
style: { display: isEditing ? "none" : "inline-block" },
},
textContent,
);
const common = {
size: "small",
style: { width: "100%" },
onClick: (event) => event.stopPropagation(),
// 仅被点击的列首次渲染时自动聚焦
ref: shouldFocus
? (instance) => {
if (instance) {
const target = instance.focus ? instance : instance.$el;
target?.focus?.();
}
}
: undefined,
style: { ...fullWidth, display: isEditing ? "inline-block" : "none" },
onClick: stopPropagation,
ref: getFocusRef(cellKey, shouldFocus),
};
let editComponent;
if (editor.type === "number") {
return h(InputNumber, {
editComponent = h(InputNumber, {
...common,
value: cellValue,
min: resolveOption(editor.min, row),
@@ -64,25 +123,27 @@ export function createEditableCellRenderer({ getRowKey, getActiveRowKey, getActi
disabled: resolveOption(editor.disabled, row),
"onUpdate:value": update,
});
}
if (editor.type === "select") {
return h(Select, {
} else if (editor.type === "select") {
editComponent = h(Select, {
...common,
value: cellValue,
options: resolveOption(editor.options, row) || [],
disabled: resolveOption(editor.disabled, row),
"onUpdate:value": update,
});
} else {
editComponent = h(Input, {
...common,
value: cellValue,
disabled: resolveOption(editor.disabled, row),
maxlength: resolveOption(editor.maxlength, row),
placeholder: resolveOption(editor.placeholder, row),
status: resolveOption(editor.status, row),
allowClear: editor.allowClear ?? false,
"onUpdate:value": update,
});
}
return h(Input, {
...common,
value: cellValue,
disabled: resolveOption(editor.disabled, row),
maxlength: resolveOption(editor.maxlength, row),
placeholder: resolveOption(editor.placeholder, row),
status: resolveOption(editor.status, row),
allowClear: editor.allowClear ?? true,
"onUpdate:value": update,
});
return h("div", { class: "fms-editable-cell-wrap" }, [textSpan, editComponent]);
};
}
@@ -1,7 +1,16 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ArrowLeft, ChevronLeft, ChevronRight, House, Maximize2, Minimize2, RotateCw, X } from "@lucide/vue";
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
House,
Maximize2,
Minimize2,
RotateCw,
X,
} from "@lucide/vue";
import { theme } from "antdv-next";
import { useAppStore } from "@/stores/app";
@@ -166,12 +175,7 @@ function toggleMaximize() {
>
<div class="tab-actions">
<a-tooltip title="返回上一页" placement="bottom">
<button
type="button"
class="tab-action"
aria-label="返回上一页"
@click="goBack"
>
<button type="button" class="tab-action" aria-label="返回上一页" @click="goBack">
<ArrowLeft />
</button>
</a-tooltip>
@@ -188,12 +192,7 @@ function toggleMaximize() {
</a-tooltip>
<a-tooltip title="返回控制台" placement="bottom">
<button
type="button"
class="tab-action"
aria-label="返回控制台"
@click="goConsole"
>
<button type="button" class="tab-action" aria-label="返回控制台" @click="goConsole">
<House />
</button>
</a-tooltip>
@@ -252,7 +251,10 @@ function toggleMaximize() {
<ChevronRight />
</button>
<a-tooltip :title="appStore.contentMaximized ? '退出最大化' : '应用内最大化'" placement="bottom">
<a-tooltip
:title="appStore.contentMaximized ? '退出最大化' : '应用内最大化'"
placement="bottom"
>
<button
type="button"
class="tab-nav-button tab-maximize"
+4 -1
View File
@@ -5,11 +5,14 @@ import App from "./App.vue";
import router from "./router";
import "./styles/tailwind.css";
import "./styles/main.scss";
import 'stk-table-vue/lib/style.css';
import "stk-table-vue/lib/style.css";
import { registerFeature, useAreaSelection } from "stk-table-vue";
const app = createApp(App);
const pinia = createPinia();
registerFeature(useAreaSelection);
pinia.use(piniaPluginPersistedstate);
app.use(pinia);
app.use(router);
+6 -3
View File
@@ -13,7 +13,11 @@ export const usePermissionStore = defineStore("permissions", () => {
const powerSet = computed(() => new Set(powerCodes.value));
const isG3soft = computed(() => {
const authStore = useAuthStore();
return String(authStore.user?.account || "").trim().toLowerCase() === "g3soft";
return (
String(authStore.user?.account || "")
.trim()
.toLowerCase() === "g3soft"
);
});
async function load() {
@@ -65,8 +69,7 @@ export const usePermissionStore = defineStore("permissions", () => {
function canPower(moduleCode, powerCode) {
return (
isG3soft.value ||
(canModule(moduleCode) && powerSet.value.has(`${moduleCode}.${powerCode}`))
isG3soft.value || (canModule(moduleCode) && powerSet.value.has(`${moduleCode}.${powerCode}`))
);
}
+1 -1
View File
@@ -68,4 +68,4 @@ a {
color: var(--fms-text);
background: var(--fms-bg);
overflow: hidden;
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ export async function allocateTemporaryIds(rowGroups) {
rowGroups
.flat()
.map((row) => String(row?.b_id ?? ""))
.filter((id) => /^-\d+$/.test(id))
.filter((id) => /^-\d+$/.test(id)),
),
];
if (!temporaryIds.length) return new Map();
@@ -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" />
+4 -3
View File
@@ -3,9 +3,10 @@
## 一、构建与测试
- **不要过多封装**
- 完成功能后不主动执行构建和测试。
- 只有用户明确要求时,才执行构建、测试或启动服务。
- 交付时说明本次未执行构建和测试。
- 完成功能后不主动执行构建。
- 允许执行测试以验证改动(如后端 `./mvnw test`、前端 `pnpm fmt:check` 及页面功能验证)。
- 只有用户明确要求时,才执行构建或启动服务。
- 交付时说明本次是否执行了测试及测试结果,并说明未执行构建。
## 二、后端接口