20260724173425
This commit is contained in:
1 parent
4f40ad9f11
commit
b33c743236
27 files changed
+1310
-437
No files matched your search
@@ -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,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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,4 +68,4 @@ a {
|
||||
color: var(--fms-text);
|
||||
background: var(--fms-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@@ -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 ?? "");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -3,9 +3,10 @@
|
||||
## 一、构建与测试
|
||||
|
||||
- **不要过多封装**
|
||||
- 完成功能后不主动执行构建和测试。
|
||||
- 只有用户明确要求时,才执行构建、测试或启动服务。
|
||||
- 交付时说明本次未执行构建和测试。
|
||||
- 完成功能后不主动执行构建。
|
||||
- 允许执行测试以验证改动(如后端 `./mvnw test`、前端 `pnpm fmt:check` 及页面功能验证)。
|
||||
- 只有用户明确要求时,才执行构建或启动服务。
|
||||
- 交付时说明本次是否执行了测试及测试结果,并说明未执行构建。
|
||||
|
||||
## 二、后端接口
|
||||
|
||||
|
||||
Reference in new issue
Block a user