20260810223432

This commit is contained in:
oneao committed 2026-08-10 22:34:33 +08:00
1 parent ed5ab3885d
commit 5adbf59425
364 files changed
+7559 -4358

No files matched your search

@@ -0,0 +1,822 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useSlots, watch } from "vue";
import { useRafFn, useResizeObserver } from "@vueuse/core";
import { StkTable } from "stk-table-vue";
import { useAppStore } from "@/stores/app";
import { createSelectionColumn, isSelectionColumn } from "./utils/createSelectionCellRenderers";
import { resolveCustomCell } from "./utils/createCellRenderer";
defineOptions({ inheritAttrs: false });
const props = defineProps({
virtual: { type: Boolean, default: true },
virtualX: { type: Boolean, default: true },
colResizable: { type: Boolean, default: true },
bordered: { type: [Boolean, String], default: true },
noDataFull: { type: Boolean, default: true },
showOverflow: { type: Boolean, default: true },
columns: { type: Array, default: () => [] },
// 序号列默认显示;false=关闭;{ title, width }=自定义
seq: { type: [Boolean, Object], default: true },
// 列头拖拽默认开启;false=关闭;{ mode, disabled }=自定义
columnDraggable: { type: [Boolean, Object], default: true },
// 行多选:{ selectedRowKeys, onChange },提供时在最前列插入复选框列
rowSelection: { type: Object, default: null },
dataSource: { type: Array, default: () => [] },
rowKey: { type: [String, Function], default: "id" },
activeRowKey: { type: [String, Number], default: null },
// 行拖拽排序:开启后显示拖拽手柄,并通过 row-order-change 回传排序后的完整数据。
rowDraggable: { type: Boolean, default: false },
// 存在 editor 配置的列默认可编辑;设为 false 可整体禁用编辑。
editable: { type: Boolean, default: true },
// 默认跟随应用深浅色模式,也允许单个表格显式覆盖。
theme: {
type: String,
default: undefined,
validator: (value) => value === "light" || value === "dark",
},
});
const emit = defineEmits(["update:columns", "change", "row-order-change"]);
const rootRef = ref(null);
const tableRef = ref(null);
const slots = useSlots();
const appStore = useAppStore();
const tableTheme = computed(() => props.theme ?? (appStore.isDark ? "dark" : "light"));
const containerHeight = ref(0);
// Tab 面板从隐藏恢复时,等到容器拥有有效尺寸再刷新虚拟滚动布局。
// RAF 将同一帧内的多次 ResizeObserver 通知合并,避免重复重排。
const { resume: refreshLayout } = useRafFn(
async () => {
await nextTick();
tableRef.value?.initVirtualScroll?.();
await nextTick();
},
{ immediate: false, once: true },
);
useResizeObserver(rootRef, ([entry]) => {
const { width, height } = entry.contentRect;
containerHeight.value = Math.floor(height);
if (width > 0 && height > 0) refreshLayout();
});
const NAVIGATION_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Tab"]);
const rowEditingStates = new WeakMap();
let activeEditingRow = null;
let pendingEditingTarget = null;
const pressedNavigationKeys = new Set();
function getRowKey(row) {
return typeof props.rowKey === "function" ? props.rowKey(row) : row?.[props.rowKey];
}
function getRowEditingState(row) {
if (!row || typeof row !== "object") return null;
let state = rowEditingStates.get(row);
if (!state) {
state = shallowRef({ editing: false, focusCol: null, initialValues: null });
rowEditingStates.set(row, state);
}
return state;
}
function captureEditableValues(row) {
return new Map(
props.columns.filter((col) => col.editor).map((col) => [col.dataIndex, row[col.dataIndex]]),
);
}
function clearActiveEditing() {
if (!activeEditingRow) return;
const state = rowEditingStates.get(activeEditingRow);
activeEditingRow = null;
if (state) state.value = { editing: false, focusCol: null, initialValues: null };
}
// 父级可能在 change 后回灌同 rowKey 的新对象,编辑状态需要随行对象迁移。
function rebindActiveEditingRow(rows) {
if (!activeEditingRow || rows.includes(activeEditingRow)) return;
const previousRow = activeEditingRow;
const previousState = rowEditingStates.get(previousRow)?.value;
const previousKey = getRowKey(previousRow);
const replacement =
previousKey == null ? null : rows.find((row) => String(getRowKey(row)) === String(previousKey));
clearActiveEditing();
if (!replacement || !previousState?.editing) return;
const state = getRowEditingState(replacement);
activeEditingRow = replacement;
state.value = {
editing: true,
focusCol: previousState.focusCol,
initialValues: captureEditableValues(replacement),
};
}
function activateEditing(row, focusCol) {
if (!props.editable || !row) return;
const state = getRowEditingState(row);
if (!state) return;
if (activeEditingRow !== row) {
clearActiveEditing();
activeEditingRow = row;
state.value = {
editing: true,
focusCol,
initialValues: captureEditableValues(row),
};
return;
}
const current = state.value;
if (current.editing && current.focusCol === focusCol) return;
state.value = { ...current, editing: true, focusCol };
}
function editorValuesEqual(col, previous, current) {
if (col.editor?.type === "select" || col.editor?.type === "checkbox") {
return Object.is(previous, current);
}
return String(previous ?? "") === String(current ?? "");
}
const selectOnFocus = ref(true);
const editingCtx = {
isEditing: (row) => props.editable && getRowEditingState(row)?.value.editing,
shouldFocus: (row, col) => getRowEditingState(row)?.value.focusCol === col.dataIndex,
shouldSelectOnFocus: selectOnFocus,
shouldNotify: (row, col, newValue) => {
const values = getRowEditingState(row)?.value.initialValues;
if (!values?.has(col.dataIndex)) return true;
if (editorValuesEqual(col, values.get(col.dataIndex), newValue)) return false;
values.set(col.dataIndex, newValue);
return true;
},
onChange: (row, col, newValue) => {
emit("change", row, col, newValue);
},
};
// 由 seq 派生:enabled / title / width
const seqConfig = computed(() => {
const enabled = !!props.seq;
if (!enabled) return { enabled: false, title: "序号", width: 50 };
const cfg = props.seq === true ? {} : props.seq;
return {
enabled: true,
title: cfg.title ?? "序号",
width: Number(cfg.width ?? 50),
};
});
const columnDragConfig = computed(() => {
if (!props.columnDraggable) return false;
const configured = props.columnDraggable === true ? {} : props.columnDraggable;
return {
...configured,
disabled: (column) => {
if (
isSelectionColumn(column) ||
column.type === "seq" ||
column.type === "dragRow" ||
column.type === "expand" ||
column.dataIndex === "" ||
column.dataIndex === "__actions__"
) {
return true;
}
if (typeof configured.disabled === "function") return configured.disabled(column);
return Boolean(configured.disabled);
},
};
});
const hasRowSelection = computed(() => Boolean(props.rowSelection));
const columnResizeConfig = computed(() => {
if (!props.colResizable) return false;
return true;
});
const dragRowColumn = {
type: "dragRow",
dataIndex: "__drag_row__",
title: "",
width: 40,
resizable: false,
};
function getHeaderAlign(column) {
if (column.headerAlign) return column.headerAlign;
if (isSelectionColumn(column)) return "center";
if (column.dataIndex === "actions") return "center";
if (column.actions && column.actions.length) return "center";
if (column.checkbox === true) return "center";
if (column.editor && column.editor.type === "checkbox") return "center";
return "left";
}
const displayColumns = computed(() => {
const cols = props.columns.map((col) => resolveCustomCell(col, editingCtx));
const seq = seqConfig.value;
if (seq.enabled) {
cols.unshift({
type: "seq",
width: seq.width,
dataIndex: "",
title: seq.title,
resizable: false,
});
}
if (props.rowDraggable) cols.unshift(dragRowColumn);
if (hasRowSelection.value) cols.unshift(selectionColumn);
return cols.map((column) => ({
...column,
headerAlign: getHeaderAlign(column),
}));
});
// 剥离内部列(选择列、序号列、拖拽列)后回传,避免污染父组件 columns
function onColumnsUpdate(columns) {
const originalByKey = new Map(
props.columns.map((col) => [String(col.key ?? col.dataIndex ?? ""), col]),
);
const real = (columns || [])
.filter(
(c) =>
!isSelectionColumn(c) && c.type !== "seq" && c.type !== "dragRow" && c.dataIndex !== "",
)
.map((col) => {
const original = originalByKey.get(String(col.key ?? col.dataIndex ?? ""));
return original ? { ...original, width: col.width ?? original.width } : col;
});
emit("update:columns", real);
}
function handleRowOrderChange(sourceIndex, targetIndex) {
const rows = tableRef.value?.getTableData?.() ?? props.dataSource;
tableRef.value?.clearSelectedArea?.();
emit("row-order-change", [...rows], sourceIndex, targetIndex);
}
// 行高同时作为 CSS 变量与 StkTable 的 row-height / header-row-height
const rowHeight = 34;
const headerRowHeight = 40;
const hasBottomGap = computed(() => {
if (!props.dataSource.length || containerHeight.value <= 0) return false;
const bodyHeight = Math.max(containerHeight.value - headerRowHeight, 0);
return props.dataSource.length * rowHeight < bodyHeight - 1;
});
const customVars = computed(() => {
// 线条只覆盖真实数据行;数据铺满视口时不绘制最后一行,避免与外框底线叠加。
const lineRowCount = Math.max(props.dataSource.length - (hasBottomGap.value ? 0 : 1), 0);
return {
"--header-row-height": `${headerRowHeight}px`,
"--row-height": `${rowHeight}px`,
"--footer-row-height": "36px",
"--th-bgc": "var(--background)",
"--tf-bgc": "var(--background)",
"--fms-data-line-height": `${lineRowCount * rowHeight}px`,
};
});
// 选择列:已选状态管理 + 渲染逻辑全部内聚在 createSelectionColumn 中
const selectionColumn = createSelectionColumn({
getDataSource: () => props.dataSource,
getRowKey,
getSelectedRowKeys: () => props.rowSelection?.selectedRowKeys ?? [],
isRowDisabled: (row) => Boolean(props.rowSelection?.getCheckboxProps?.(row)?.disabled),
onChange: (keys, rows) => props.rowSelection?.onChange?.(keys, rows),
});
// 滚动到指定行(新增后聚焦)
async function scrollToRow(rowKey) {
await nextTick();
const table = tableRef.value;
if (!table) return;
const idx = props.dataSource.findIndex((row) => String(getRowKey(row)) === String(rowKey));
if (idx < 0) return;
table.scrollTo(idx * rowHeight, null);
}
// 程序化激活行编辑。新增行等场景默认先滚动到目标行;行内操作可传
// { scroll: false } 保持当前滚动位置。
async function editRow(rowKey, focusCol = null, options = {}) {
if (options.scroll === false) await nextTick();
else await scrollToRow(rowKey);
await nextTick();
const row = props.dataSource.find((item) => String(getRowKey(item)) === String(rowKey));
if (!row) return;
const table = tableRef.value;
table?.setCurrentRow?.(row, { silent: true });
activateEditing(row, focusCol);
const focusColumn = displayColumns.value.find((column) => column.dataIndex === focusCol);
if (focusColumn) {
table?.setAreaSelection?.(
{ begin: { row, col: focusColumn } },
{ silent: true, scrollToView: false },
);
}
}
function getSelectedArea() {
return tableRef.value?.getSelectedArea?.();
}
function clearSelectedArea() {
tableRef.value?.clearSelectedArea?.();
}
function setAreaSelection(...args) {
return tableRef.value?.setAreaSelection?.(...args);
}
function setRowExpand(...args) {
return tableRef.value?.setRowExpand?.(...args);
}
function initVirtualScroll() {
return tableRef.value?.initVirtualScroll?.();
}
let editActivateTimer = null;
let lastEditActivateAt = 0;
let isRepeatNavigation = false;
function clearEditActivateTimer() {
if (editActivateTimer) {
clearTimeout(editActivateTimer);
editActivateTimer = null;
}
}
// 键盘导航停止(松开按键或停顿)后再激活编辑器,合并连续/按住按键,
// 避免滚动过程中每帧创建/销毁编辑器组件导致掉帧。
function scheduleEditActivation() {
clearEditActivateTimer();
editActivateTimer = setTimeout(() => {
editActivateTimer = null;
commitPendingEditing();
}, 100);
}
function commitPendingEditing() {
const target = pendingEditingTarget;
pendingEditingTarget = null;
if (!target) return;
const row = props.dataSource.find(
(item) => item === target.row || String(getRowKey(item)) === String(getRowKey(target.row)),
);
if (row) {
activateEditing(row, target.focusCol);
tableRef.value?.setCurrentRow?.(row, { silent: true });
lastEditActivateAt = performance.now();
}
}
function removeNavigationListeners() {
window.removeEventListener("keyup", handleKeyupCapture, true);
window.removeEventListener("blur", handleWindowBlur);
}
function handleKeydownCapture(event) {
if (!NAVIGATION_KEYS.has(event.key)) return;
if (!pressedNavigationKeys.size) {
window.addEventListener("keyup", handleKeyupCapture, true);
window.addEventListener("blur", handleWindowBlur);
}
pressedNavigationKeys.add(event.key);
// event.repeat=true 表示系统自动重复(按住方向键不松),用于区分“按住滚动”与“离散点按”
if (event.repeat) isRepeatNavigation = true;
}
function handleKeyupCapture(event) {
if (!NAVIGATION_KEYS.has(event.key)) return;
if (!pressedNavigationKeys.delete(event.key) || pressedNavigationKeys.size) return;
removeNavigationListeners();
if (isRepeatNavigation) {
// 长按已经结束,立即恢复最终目标行,不再额外等待防抖时间。
isRepeatNavigation = false;
clearEditActivateTimer();
commitPendingEditing();
return;
}
// 快速离散连按仍使用短防抖,避免连续挂载/卸载整行编辑器。
if (pendingEditingTarget) scheduleEditActivation();
}
function handleWindowBlur() {
removeNavigationListeners();
pressedNavigationKeys.clear();
isRepeatNavigation = false;
clearEditActivateTimer();
commitPendingEditing();
}
// 选区变化后,目标行进入编辑,活动单元格获得焦点。
function handleAreaSelectionChange(ranges) {
if (!props.editable) return;
const target = ranges?.at(-1)?.index.end;
const row = target ? tableRef.value?.getTableData?.()[target.row] : null;
const col = target ? displayColumns.value[target.col] : null;
if (!row) {
pendingEditingTarget = null;
clearActiveEditing();
clearEditActivateTimer();
return;
}
const focusCol = col?.editor ? col.dataIndex : null;
// 键盘导航期间(按下方向键等):
// - 按住方向键滚动(event.repeat=true):全程保持 0 编辑器,避免长按下反复创建组件导致卡顿/闪烁;
// 松手后再激活到最终行。
// - 慢速离散点按(非 repeat):立即激活,编辑器逐行跟随、跟手;
// - 快速连按(间隔 <150ms):防抖合并,避免连按时每键挂载掉帧。
if (pressedNavigationKeys.size > 0) {
if (isRepeatNavigation) {
pendingEditingTarget = { row, focusCol };
clearActiveEditing();
} else {
const now = performance.now();
if (now - lastEditActivateAt >= 150) {
pendingEditingTarget = null;
clearEditActivateTimer();
activateEditing(row, focusCol);
tableRef.value?.setCurrentRow?.(row, { silent: true });
lastEditActivateAt = now;
} else {
pendingEditingTarget = { row, focusCol };
scheduleEditActivation();
}
}
return;
}
// 非键盘导航(鼠标点击等):立即激活。
// 同一行已编辑时仍更新 focusCol(保证焦点切换到新列),
// 但暂时禁用 select 全选(避免鼠标点击触发全选文本)。
pendingEditingTarget = null;
clearEditActivateTimer();
if (activeEditingRow === row) {
selectOnFocus.value = false;
activateEditing(row, focusCol);
nextTick(() => {
selectOnFocus.value = true;
});
} else {
activateEditing(row, focusCol);
}
lastEditActivateAt = performance.now();
}
watch(
[() => props.activeRowKey, () => props.dataSource],
async ([rowKey]) => {
rebindActiveEditingRow(props.dataSource);
await nextTick();
if (rowKey == null) {
tableRef.value?.setCurrentRow?.(undefined, { silent: true });
return;
}
const row = props.dataSource.find((item) => String(getRowKey(item)) === String(rowKey));
tableRef.value?.setCurrentRow?.(row, { silent: true });
},
{ immediate: true },
);
watch(
() => props.editable,
(editable) => {
if (editable) return;
pendingEditingTarget = null;
removeNavigationListeners();
pressedNavigationKeys.clear();
isRepeatNavigation = false;
clearEditActivateTimer();
clearActiveEditing();
tableRef.value?.clearSelectedArea?.();
},
);
onBeforeUnmount(() => {
removeNavigationListeners();
clearEditActivateTimer();
pressedNavigationKeys.clear();
});
defineExpose({
scrollToRow,
editRow,
getSelectedArea,
setAreaSelection,
clearSelectedArea,
setRowExpand,
initVirtualScroll,
refreshLayout: initVirtualScroll,
});
</script>
<template>
<div ref="rootRef" class="fms-table" @keydown.capture="handleKeydownCapture">
<StkTable
ref="tableRef"
class="fms-table__inner"
:class="{ 'fms-table--bottom-gap': hasBottomGap }"
v-bind="$attrs"
:style="customVars"
:theme="tableTheme"
:row-height="rowHeight"
:header-row-height="headerRowHeight"
:virtual="virtual"
:virtual-x="virtualX"
:auto-resize="false"
:col-resizable="columnResizeConfig"
:header-drag="columnDragConfig"
:columns="displayColumns"
@update:columns="onColumnsUpdate"
:data-source="dataSource"
:drag-row-config="{ mode: rowDraggable ? 'insert' : 'none' }"
:row-key="rowKey"
:no-data-full="noDataFull"
:show-overflow="showOverflow"
:area-selection="editable"
:bordered="false"
@area-selection-change="handleAreaSelectionChange"
@row-order-change="handleRowOrderChange"
>
<template v-if="slots.expand" #expand="slotProps">
<slot name="expand" v-bind="slotProps" />
</template>
<template v-if="slots.empty" #empty>
<slot name="empty" />
</template>
<template v-else #empty>
<div class="fms-table__empty" role="status">
<strong>暂无数据</strong>
<span>当前没有可显示的内容</span>
</div>
</template>
</StkTable>
</div>
</template>
<style scoped>
.fms-table {
width: 100%;
height: 100%;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.fms-table__inner {
width: 100%;
height: 100%;
}
</style>
<!-- 非 scoped 全局样式:原生 checkbox 保持低组件开销,外观使用 shadcn 语义变量。 -->
<style lang="scss">
/* 双类选择器 .fms-table__inner.stk-table 权重 (0,2,1) > 库默认 .stk-table (0,1,1) */
.fms-table__inner.stk-table {
th,
td {
font-size: 13px;
}
th.fms-selection-header {
vertical-align: middle;
.table-header-cell-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
}
}
/*
* 外层绘制表格边框,表体只保留横向分割线。
* STK 主表继续使用 fit-content 保持配置列宽,独立装饰层只负责补齐右侧空白区域的横线。
*/
.fms-table__inner.stk-table {
th,
td {
border-bottom: 1px solid var(--border);
}
thead th {
border-bottom-color: var(--border);
}
// 数据铺满视口时隐藏最后一行底线,避免与外框底线叠加;数据较少时保留底线标记数据区域结束。
&:not(.fms-table--bottom-gap) {
.stk-tbody-main tr:last-child {
td {
border-bottom: none;
}
}
}
// 不改变表格列宽,单独用装饰层把行分割线延伸到容器右侧空白区域。
.stk-table-scroll-container {
position: relative;
min-width: 100%;
&::before {
position: absolute;
top: calc(var(--header-row-height) - 1px);
left: 0;
width: 100%;
height: calc(var(--fms-data-line-height) + 1px);
pointer-events: none;
content: "";
background-image: repeating-linear-gradient(
to bottom,
var(--border) 0,
var(--border) 1px,
transparent 1px,
transparent var(--row-height)
);
}
}
}
.fms-checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
margin: 0;
cursor: pointer;
line-height: 1;
&.disabled {
cursor: not-allowed;
opacity: 0.5;
}
input {
position: absolute;
width: 0;
height: 0;
margin: 0;
opacity: 0;
}
.fms-checkbox-box {
position: relative;
display: block;
flex: none;
width: 16px;
height: 16px;
box-sizing: border-box;
background: var(--background);
border: 1px solid var(--input);
border-radius: 4px;
box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
transition:
border-color 150ms,
background-color 150ms,
box-shadow 150ms;
}
&.checked .fms-checkbox-box {
color: var(--primary-foreground);
background: var(--primary);
border-color: var(--primary);
&::after {
position: absolute;
top: 2px;
left: 5px;
width: 4px;
height: 8px;
content: "";
border: solid var(--primary-foreground);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
&.indeterminate .fms-checkbox-box {
color: var(--primary-foreground);
background: var(--primary);
border-color: var(--primary);
&::after {
position: absolute;
top: 7px;
left: 3px;
width: 8px;
height: 2px;
content: "";
background: var(--primary-foreground);
border: none;
border-radius: 2px;
}
}
&:focus-within .fms-checkbox-box {
border-color: var(--ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
}
}
.fms-cell-editor {
min-width: 0;
font-size: 13px;
}
.fms-text-editor {
width: 100%;
height: 28px;
max-width: 100%;
padding: 0 8px;
color: var(--foreground);
background-color: var(--background);
border: 1px solid var(--input);
border-radius: var(--radius-md);
outline: none;
box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
}
.fms-text-editor:focus {
border-color: var(--ring);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 28%, transparent);
}
.fms-select-editor {
width: 100%;
height: 28px !important;
max-width: 100%;
background-color: var(--background) !important;
border-color: var(--input);
box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
}
.fms-select-editor:focus-visible {
border-color: var(--ring);
}
.fms-cell-display {
display: block;
min-width: 0;
user-select: text;
}
.fms-table__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 96px;
color: var(--muted-foreground);
font-size: 13px;
}
.fms-table__empty strong {
color: var(--foreground);
font-weight: 500;
}
.fms-table__inner.text-overflow .fms-cell-display {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fms-table__inner td:has(> .fms-cell-editor-root) {
padding-inline: 3px;
}
// actions 列按钮容器
.fms-actions-cell {
display: inline-flex;
align-items: center;
gap: 4px;
}
.fms-action-btn {
font-size: 12px;
cursor: pointer;
}
.fms-action-btn:disabled {
cursor: not-allowed;
}
</style>