u
This commit is contained in:
1 parent
78eb811ff7
commit
97fe2ff306
83 files changed
+10083
-3662
No files matched your search
@@ -0,0 +1,868 @@
|
||||
<script lang="ts">
|
||||
import { registerFeature, useAreaSelection } from 'stk-table-vue'
|
||||
|
||||
/**
|
||||
* 区域选择特性注册。
|
||||
*
|
||||
* 库的区域选择是**按需全局注册**的,不注册则 `area-selection-change` 永不触发
|
||||
* (行内编辑依赖它)。组件库不拥有 app 启动流程,不能要求消费方在 main.ts 里调用,
|
||||
* 所以组件挂载时会自动注册一次;这个函数用于需要显式控制的场景
|
||||
* (例如想在挂载前就注册好,或单测里手动重置状态)。
|
||||
*
|
||||
* `registerFeature` 本身是幂等的;这里再兜一层模块级 + 全局 flag,
|
||||
* 避免多实例、多入口重复注册。
|
||||
*/
|
||||
const AREA_SELECTION_FEATURE_KEY = '__g3TableAreaSelectionRegistered__'
|
||||
let areaSelectionRegistered = false
|
||||
|
||||
export function registerTableFeatures(): void {
|
||||
if (areaSelectionRegistered) return
|
||||
const globalFlag = globalThis as unknown as Record<string, boolean>
|
||||
if (!globalFlag[AREA_SELECTION_FEATURE_KEY]) {
|
||||
registerFeature(useAreaSelection)
|
||||
globalFlag[AREA_SELECTION_FEATURE_KEY] = true
|
||||
}
|
||||
areaSelectionRegistered = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
useSlots,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { StkTable } from 'stk-table-vue'
|
||||
import type { StkTableColumn } from 'stk-table-vue'
|
||||
import { useNamespace } from '../_composables/useNamespace'
|
||||
import { createSelectionColumn, isSelectionColumn, SELECTION_COLUMN_KEY } from './utils/selection'
|
||||
import {
|
||||
DRAG_ROW_COLUMN_TYPE,
|
||||
getHeaderAlign,
|
||||
readInternalWidths,
|
||||
resolveCustomCell,
|
||||
resolveSeqConfig,
|
||||
stripInternalColumns,
|
||||
} from './utils/column'
|
||||
import type { EditableContext } from './utils/editable'
|
||||
import type { G3TableColumn, TableRow, TableRowKey, TableRowSelection } from './types'
|
||||
|
||||
/**
|
||||
* G3Table —— 高性能表格。
|
||||
*
|
||||
* 底座 `stk-table-vue`:虚拟滚动 / 列宽拖拽 / 列序拖拽 / 区域选择 / 行拖拽排序
|
||||
* 都由它提供。本组件负责三件事:**性能门控**、**内部列管理**、**编辑态管理**。
|
||||
*/
|
||||
|
||||
defineOptions({ name: 'G3Table', inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
columns?: G3TableColumn<TableRow>[]
|
||||
dataSource?: TableRow[]
|
||||
rowKey?: TableRowKey<TableRow>
|
||||
virtual?: boolean
|
||||
virtualX?: boolean
|
||||
rowHeight?: number
|
||||
headerRowHeight?: number
|
||||
bordered?: boolean | string
|
||||
colResizable?: boolean | { disabled?: boolean | ((col: G3TableColumn<TableRow>) => boolean) }
|
||||
columnDraggable?:
|
||||
| boolean
|
||||
| {
|
||||
mode?: 'none' | 'insert' | 'swap'
|
||||
disabled?: boolean | ((col: G3TableColumn<TableRow>) => boolean)
|
||||
}
|
||||
rowDraggable?: boolean
|
||||
rowSelection?: TableRowSelection<TableRow> | null
|
||||
seq?: boolean | { title?: string; width?: number }
|
||||
activeRowKey?: string | number | null
|
||||
editable?: boolean
|
||||
noDataFull?: boolean
|
||||
showOverflow?: boolean
|
||||
autoFitWidth?: boolean
|
||||
theme?: 'light' | 'dark'
|
||||
footerData?: TableRow[]
|
||||
footerConfig?: Record<string, unknown>
|
||||
}>(),
|
||||
{
|
||||
columns: () => [],
|
||||
dataSource: () => [],
|
||||
rowKey: 'id',
|
||||
virtual: true,
|
||||
virtualX: true,
|
||||
rowHeight: 30,
|
||||
headerRowHeight: 34,
|
||||
bordered: true,
|
||||
colResizable: true,
|
||||
columnDraggable: true,
|
||||
rowDraggable: false,
|
||||
rowSelection: null,
|
||||
seq: true,
|
||||
activeRowKey: null,
|
||||
editable: true,
|
||||
noDataFull: true,
|
||||
showOverflow: true,
|
||||
autoFitWidth: false,
|
||||
theme: undefined,
|
||||
footerData: () => [],
|
||||
footerConfig: () => ({}),
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:columns', columns: G3TableColumn<TableRow>[]): void
|
||||
(e: 'update:selectedRowKeys', keys: Array<string | number>): void
|
||||
(e: 'change', row: TableRow, col: G3TableColumn<TableRow>, value: unknown): void
|
||||
(e: 'row-order-change', rows: TableRow[], sourceIndex: number, targetIndex: number): void
|
||||
(e: 'area-selection-change', ranges: unknown[]): void
|
||||
}>()
|
||||
|
||||
const { b, e, is } = useNamespace('table')
|
||||
const slots = useSlots()
|
||||
|
||||
/* ---------- 性能门控 ---------- */
|
||||
|
||||
/**
|
||||
* 纵向虚拟滚动门控:行数不超过阈值时**关闭**。
|
||||
*
|
||||
* 库的虚拟滚动在「行数 > 单屏行数」时开启,会导致行数略大于一屏
|
||||
* (如容器一屏 18 行、数据 20 行)时最后几行被虚拟化吞掉、必须滚动才可见。
|
||||
* 行数少时全量渲染没有性能问题,却换来确定性正确的渲染。
|
||||
*/
|
||||
const MIN_VIRTUAL_ROW_COUNT = 100
|
||||
const effectiveVirtual = computed(
|
||||
() => props.virtual && props.dataSource.length > MIN_VIRTUAL_ROW_COUNT,
|
||||
)
|
||||
|
||||
/**
|
||||
* 横向虚拟滚动门控:列数不超过阈值时关闭。
|
||||
*
|
||||
* 库在 `virtualX = true` 时会把每列宽度**钉死**为 minWidth ?? width ?? 100
|
||||
* (cellStyleMap 的 --cw + width/min-width/max-width 三重锁),
|
||||
* 未设 width 的列会失去「按比例铺满容器」的原生自适应。
|
||||
* 列数少、总宽不超容器时用不上横向虚拟,关掉以保住原生列宽分配。
|
||||
*/
|
||||
const MIN_VIRTUAL_X_COLUMN_COUNT = 20
|
||||
const effectiveVirtualX = computed(
|
||||
() => props.virtualX && displayColumns.value.length > MIN_VIRTUAL_X_COLUMN_COUNT,
|
||||
)
|
||||
|
||||
/* ---------- 主题 ---------- */
|
||||
|
||||
/** 主题:显式 prop 优先,否则跟随 documentElement(tokens 的 data-g3-theme 约定) */
|
||||
const documentTheme = ref<'light' | 'dark'>('light')
|
||||
let themeObserver: MutationObserver | null = null
|
||||
const tableTheme = computed(() => props.theme ?? documentTheme.value)
|
||||
|
||||
function readDocumentTheme() {
|
||||
if (typeof document === 'undefined') return
|
||||
documentTheme.value = document.documentElement.dataset.g3Theme === 'dark' ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
/* ---------- 布局刷新 ---------- */
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const tableRef = ref<{ initVirtualScroll?: () => void; initVirtualScrollX?: () => void } | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
/** rAF 合并同一帧内的多次通知,避免重复重排 */
|
||||
let refreshRafId = 0
|
||||
function refreshLayout(xOnly = false) {
|
||||
if (typeof requestAnimationFrame === 'undefined') return
|
||||
if (refreshRafId) cancelAnimationFrame(refreshRafId)
|
||||
refreshRafId = requestAnimationFrame(async () => {
|
||||
refreshRafId = 0
|
||||
await nextTick()
|
||||
// 高度未变、仅宽度变化时只重算 X 方向,省掉 Y 方向重算
|
||||
if (xOnly) tableRef.value?.initVirtualScrollX?.()
|
||||
else tableRef.value?.initVirtualScroll?.()
|
||||
await nextTick()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 尺寸防抖(trailing)。
|
||||
*
|
||||
* 折叠动画 / 窗口缩放 / 拖拽调宽期间 ResizeObserver 每帧都会通知,
|
||||
* 但中间尺寸是瞬态的——逐帧 initVirtualScroll 会每帧强制 reflow 并重渲染可见行列,
|
||||
* 造成动画掉帧。这里合并通知,尺寸稳定后才刷新一次,动画期间表格仅随 CSS 拉伸。
|
||||
*/
|
||||
const RESIZE_DEBOUNCE_MS = 150
|
||||
let resizeDebounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let lastSize: { width: number; height: number } | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
/* ---------- 行编辑态 ---------- */
|
||||
|
||||
/**
|
||||
* 每行的编辑状态存在 WeakMap 里(key 是行对象本身)。
|
||||
* 用 WeakMap 而不是给行对象打标记:不污染业务数据,行被回收时状态自动释放。
|
||||
*/
|
||||
const rowEditingStates = new WeakMap<TableRow, ReturnType<typeof shallowRef>>()
|
||||
let activeEditingRow: TableRow | null = null
|
||||
let pendingFocusKey: string | null = null
|
||||
|
||||
/**
|
||||
* 是否存在活动编辑行。
|
||||
*
|
||||
* **性能关键**:为 false 时可编辑列不注入 customCell,全部走库原生 td 渲染,
|
||||
* 滚动时每格省掉一个函数式组件。行与行之间切换编辑**不翻转**此值,避免整表列重建。
|
||||
*/
|
||||
const editingActive = ref(false)
|
||||
|
||||
function getRowKey(row: TableRow): string | number {
|
||||
if (typeof props.rowKey === 'function') return props.rowKey(row)
|
||||
const key = props.rowKey as string
|
||||
return row?.[key] as string | number
|
||||
}
|
||||
|
||||
function getRowEditingState(row: TableRow) {
|
||||
if (!row || typeof row !== 'object') return null
|
||||
let state = rowEditingStates.get(row)
|
||||
if (!state) {
|
||||
state = shallowRef({ editing: false, focusCol: null as string | null, focused: false })
|
||||
rowEditingStates.set(row, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function deactivateRowEditing(row: TableRow) {
|
||||
const state = rowEditingStates.get(row)
|
||||
if (state && (state.value as { editing: boolean }).editing) {
|
||||
state.value = { editing: false, focusCol: null, focused: false }
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveEditing() {
|
||||
if (!activeEditingRow) return
|
||||
const state = rowEditingStates.get(activeEditingRow)
|
||||
activeEditingRow = null
|
||||
editingActive.value = false
|
||||
if (state && (state.value as { editing: boolean }).editing) {
|
||||
state.value = { editing: false, focusCol: null, focused: false }
|
||||
}
|
||||
}
|
||||
|
||||
function activateEditing(row: TableRow, focusCol: string | null) {
|
||||
if (!props.editable || !row) return
|
||||
const state = getRowEditingState(row)
|
||||
if (!state) return
|
||||
|
||||
if (activeEditingRow !== row) {
|
||||
if (activeEditingRow) deactivateRowEditing(activeEditingRow)
|
||||
activeEditingRow = row
|
||||
editingActive.value = true
|
||||
state.value = { editing: true, focusCol, focused: false }
|
||||
pendingFocusKey = String(getRowKey(row))
|
||||
return
|
||||
}
|
||||
|
||||
const current = state.value as { editing: boolean; focusCol: string | null; focused: boolean }
|
||||
if (current.editing && current.focusCol === focusCol) return
|
||||
state.value = { ...current, editing: true, focusCol }
|
||||
pendingFocusKey = String(getRowKey(row))
|
||||
}
|
||||
|
||||
const editingCtx: EditableContext<TableRow> = {
|
||||
isEditing: (row) =>
|
||||
props.editable && Boolean((getRowEditingState(row)?.value as { editing: boolean })?.editing),
|
||||
shouldFocus: (row, col) =>
|
||||
(getRowEditingState(row)?.value as { focusCol: string | null })?.focusCol === col.dataIndex,
|
||||
wasFocused: (row) => Boolean((getRowEditingState(row)?.value as { focused: boolean })?.focused),
|
||||
consumePendingFocus: (row) => {
|
||||
if (pendingFocusKey == null) return false
|
||||
if (pendingFocusKey !== String(getRowKey(row))) return false
|
||||
pendingFocusKey = null
|
||||
return true
|
||||
},
|
||||
onEditorFocus: (row) => {
|
||||
const state = getRowEditingState(row)
|
||||
if (state?.value) state.value = { ...(state.value as object), focused: true }
|
||||
},
|
||||
onEditorBlur: (row) => {
|
||||
const state = getRowEditingState(row)
|
||||
if (state?.value) state.value = { ...(state.value as object), focused: false }
|
||||
},
|
||||
shouldNotify: () => true,
|
||||
onChange: (row, col, value) => {
|
||||
emit('change', row, col, value)
|
||||
},
|
||||
}
|
||||
|
||||
/* ---------- 列 ---------- */
|
||||
|
||||
const seqConfig = computed(() => resolveSeqConfig(props.seq))
|
||||
const seqWidth = ref(seqConfig.value.width)
|
||||
const selectionWidth = ref(44)
|
||||
const dragRowWidth = ref(40)
|
||||
|
||||
const hasRowSelection = computed(() => Boolean(props.rowSelection))
|
||||
|
||||
const selectionColumn = createSelectionColumn<TableRow>({
|
||||
getDataSource: () => props.dataSource,
|
||||
getRowKey,
|
||||
getSelectedRowKeys: () => props.rowSelection?.selectedRowKeys ?? [],
|
||||
isRowDisabled: (row) => Boolean(props.rowSelection?.getCheckboxProps?.(row)?.disabled),
|
||||
onChange: (keys, rows) => {
|
||||
props.rowSelection?.onChange?.(keys, rows)
|
||||
emit('update:selectedRowKeys', keys)
|
||||
},
|
||||
})
|
||||
|
||||
/** 列宽拖拽 / 列序拖拽都要求父级绑定 v-model:columns,否则拖了无处回写(松手回弹),直接关掉 */
|
||||
const columnsBound = 'onUpdate:columns' in (getCurrentInstance()?.vnode.props ?? {})
|
||||
|
||||
const effectiveColResizable = (() => {
|
||||
if (!columnsBound || props.autoFitWidth || !props.colResizable) return false
|
||||
const configured: { disabled?: boolean | ((col: G3TableColumn<TableRow>) => boolean) } =
|
||||
props.colResizable === true ? {} : props.colResizable
|
||||
return {
|
||||
// 入参用库自己的列类型:库的 props 泛型是 any,若这里收窄成 G3TableColumn<TableRow>,
|
||||
// 函数参数逆变会判定不兼容(库可能传任意列进来)。收宽再在内部窄化是唯一正解。
|
||||
disabled: (column: StkTableColumn<TableRow>) => {
|
||||
const col = column as G3TableColumn<TableRow>
|
||||
// 操作列 / 展开列宽度由代码算,拖了不落库、列重建即丢,一律禁止
|
||||
if (col.resizable === false) return true
|
||||
return typeof configured.disabled === 'function'
|
||||
? Boolean(configured.disabled(col))
|
||||
: Boolean(configured.disabled)
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const columnDragConfig = computed(() => {
|
||||
if (!columnsBound || !props.columnDraggable) return false
|
||||
const configured: {
|
||||
mode?: 'none' | 'insert' | 'swap'
|
||||
disabled?: boolean | ((col: G3TableColumn<TableRow>) => boolean)
|
||||
} = props.columnDraggable === true ? {} : props.columnDraggable
|
||||
return {
|
||||
...configured,
|
||||
disabled: (column: StkTableColumn<TableRow>) => {
|
||||
const col = column as G3TableColumn<TableRow>
|
||||
if (
|
||||
isSelectionColumn(col) ||
|
||||
col.type === 'seq' ||
|
||||
col.type === DRAG_ROW_COLUMN_TYPE ||
|
||||
col.type === 'expand' ||
|
||||
col.dataIndex === '' ||
|
||||
col.dataIndex === '__actions__'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return typeof configured.disabled === 'function'
|
||||
? configured.disabled(col)
|
||||
: Boolean(configured.disabled)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const displayColumns = computed<G3TableColumn<TableRow>[]>(() => {
|
||||
const cols = props.columns.map((col) =>
|
||||
resolveCustomCell(col, editingCtx, editingActive.value),
|
||||
)
|
||||
const seq = seqConfig.value
|
||||
if (seq.enabled) {
|
||||
cols.unshift({
|
||||
type: 'seq',
|
||||
width: seqWidth.value,
|
||||
dataIndex: '',
|
||||
title: seq.title,
|
||||
} as unknown as G3TableColumn<TableRow>)
|
||||
}
|
||||
if (props.rowDraggable) {
|
||||
cols.unshift({
|
||||
type: DRAG_ROW_COLUMN_TYPE,
|
||||
dataIndex: '__g3DragRow',
|
||||
title: '',
|
||||
width: dragRowWidth.value,
|
||||
} as unknown as G3TableColumn<TableRow>)
|
||||
}
|
||||
if (hasRowSelection.value) {
|
||||
cols.unshift({
|
||||
...selectionColumn,
|
||||
width: selectionWidth.value,
|
||||
} as G3TableColumn<TableRow>)
|
||||
}
|
||||
return cols.map((column) => ({ ...column, headerAlign: getHeaderAlign(column) }))
|
||||
})
|
||||
|
||||
/** 库回写列:同步内部列宽度,剥离内部列后回传业务列 */
|
||||
// 入参用库的类型(props 泛型是 any),内部再窄化——与 colResizable 同因
|
||||
function onColumnsUpdate(rawColumns: StkTableColumn<TableRow>[]) {
|
||||
const columns = rawColumns as G3TableColumn<TableRow>[]
|
||||
const widths = readInternalWidths(columns)
|
||||
if (widths.selection != null) selectionWidth.value = widths.selection
|
||||
if (widths.seq != null) seqWidth.value = widths.seq
|
||||
if (widths.dragRow != null) dragRowWidth.value = widths.dragRow
|
||||
emit('update:columns', stripInternalColumns(columns, props.columns))
|
||||
}
|
||||
|
||||
/**
|
||||
* 行拖拽结束。
|
||||
*
|
||||
* 库回传的是**行 key**(dragStartKey / targetRowKey),不是索引;
|
||||
* 这里换算成索引再抛给业务——索引更通用,业务不必知道 rowKey 怎么取值。
|
||||
*/
|
||||
function handleRowOrderChange(dragStartKey: string, targetRowKey: string) {
|
||||
const rows =
|
||||
(tableRef.value as unknown as { getTableData?: () => TableRow[] })?.getTableData?.() ??
|
||||
props.dataSource
|
||||
const sourceIndex = rows.findIndex((row) => String(getRowKey(row)) === String(dragStartKey))
|
||||
const targetIndex = rows.findIndex((row) => String(getRowKey(row)) === String(targetRowKey))
|
||||
emit('row-order-change', [...rows], sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
function handleAreaSelectionChange(ranges: unknown[]) {
|
||||
if (!props.editable) return
|
||||
const list = ranges as Array<{ index?: { end?: { row: number; col: number } } }>
|
||||
const target = list?.at(-1)?.index?.end
|
||||
if (!target) {
|
||||
clearActiveEditing()
|
||||
return
|
||||
}
|
||||
const row = props.dataSource[target.row]
|
||||
const col = displayColumns.value[target.col]
|
||||
if (!row) {
|
||||
clearActiveEditing()
|
||||
return
|
||||
}
|
||||
activateEditing(row, col?.editor ? col.dataIndex : null)
|
||||
emit('area-selection-change', ranges)
|
||||
}
|
||||
|
||||
/* ---------- 对外方法 ---------- */
|
||||
|
||||
async function scrollToRow(rowKey: string | number) {
|
||||
await nextTick()
|
||||
const idx = props.dataSource.findIndex((row) => String(getRowKey(row)) === String(rowKey))
|
||||
if (idx < 0) return
|
||||
;(tableRef.value as unknown as { scrollTo?: (top: number, left: number | null) => void })?.scrollTo?.(
|
||||
idx * props.rowHeight,
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
async function editRow(rowKey: string | number, focusCol: string | null = null) {
|
||||
await scrollToRow(rowKey)
|
||||
await nextTick()
|
||||
const row = props.dataSource.find((item) => String(getRowKey(item)) === String(rowKey))
|
||||
if (!row) return
|
||||
;(tableRef.value as unknown as { setCurrentRow?: (r: TableRow, o: object) => void })?.setCurrentRow?.(
|
||||
row,
|
||||
{ silent: true },
|
||||
)
|
||||
activateEditing(row, focusCol)
|
||||
}
|
||||
|
||||
function initVirtualScroll() {
|
||||
tableRef.value?.initVirtualScroll?.()
|
||||
}
|
||||
|
||||
function getSelectedArea() {
|
||||
return (tableRef.value as unknown as { getSelectedArea?: () => unknown })?.getSelectedArea?.()
|
||||
}
|
||||
|
||||
function clearSelectedArea() {
|
||||
;(tableRef.value as unknown as { clearSelectedArea?: () => void })?.clearSelectedArea?.()
|
||||
}
|
||||
|
||||
function setRowExpand(...args: unknown[]) {
|
||||
return (tableRef.value as unknown as { setRowExpand?: (...a: unknown[]) => unknown })?.setRowExpand?.(
|
||||
...args,
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- 生命周期 ---------- */
|
||||
|
||||
onMounted(() => {
|
||||
registerTableFeatures()
|
||||
readDocumentTheme()
|
||||
|
||||
if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
|
||||
themeObserver = new MutationObserver(readDocumentTheme)
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-g3-theme'],
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined' && rootRef.value) {
|
||||
resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect
|
||||
if (width <= 0 || height <= 0) return
|
||||
clearTimeout(resizeDebounceTimer)
|
||||
resizeDebounceTimer = setTimeout(() => {
|
||||
resizeDebounceTimer = undefined
|
||||
const xOnly = lastSize != null && lastSize.height === height && lastSize.width !== width
|
||||
lastSize = { width, height }
|
||||
refreshLayout(xOnly)
|
||||
}, RESIZE_DEBOUNCE_MS)
|
||||
})
|
||||
resizeObserver.observe(rootRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (refreshRafId) cancelAnimationFrame(refreshRafId)
|
||||
clearTimeout(resizeDebounceTimer)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
themeObserver?.disconnect()
|
||||
themeObserver = null
|
||||
})
|
||||
|
||||
// 数据被父级刷新后,行对象可能整体换新,编辑态需随 key 迁移
|
||||
watch(
|
||||
() => props.dataSource,
|
||||
(rows) => {
|
||||
if (!activeEditingRow) return
|
||||
if (rows.includes(activeEditingRow)) return
|
||||
const previousKey = getRowKey(activeEditingRow)
|
||||
const replacement = rows.find((row) => String(getRowKey(row)) === String(previousKey))
|
||||
deactivateRowEditing(activeEditingRow)
|
||||
activeEditingRow = null
|
||||
editingActive.value = false
|
||||
if (replacement) activateEditing(replacement, null)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.editable,
|
||||
(editable) => {
|
||||
if (editable) return
|
||||
clearActiveEditing()
|
||||
clearSelectedArea()
|
||||
},
|
||||
)
|
||||
|
||||
// 行高是虚拟滚动计算的输入,变化后必须重算
|
||||
watch(
|
||||
() => [props.rowHeight, props.headerRowHeight],
|
||||
() => refreshLayout(),
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
scrollToRow,
|
||||
editRow,
|
||||
initVirtualScroll,
|
||||
refreshLayout: initVirtualScroll,
|
||||
getSelectedArea,
|
||||
clearSelectedArea,
|
||||
setRowExpand,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
:class="[
|
||||
b(),
|
||||
bordered ? is('bordered') : '',
|
||||
autoFitWidth ? is('auto-fit-width') : '',
|
||||
]"
|
||||
data-slot="table"
|
||||
>
|
||||
<StkTable
|
||||
ref="tableRef"
|
||||
:class="e('inner')"
|
||||
v-bind="$attrs"
|
||||
:theme="tableTheme"
|
||||
:row-height="rowHeight"
|
||||
:header-row-height="headerRowHeight"
|
||||
:virtual="effectiveVirtual"
|
||||
:virtual-x="effectiveVirtualX"
|
||||
:smooth-scroll="true"
|
||||
:auto-resize="false"
|
||||
:col-resizable="effectiveColResizable"
|
||||
:header-drag="columnDragConfig"
|
||||
:columns="displayColumns"
|
||||
:data-source="dataSource"
|
||||
:footer-data="footerData"
|
||||
:footer-config="footerConfig"
|
||||
:drag-row-config="{ mode: rowDraggable ? 'insert' : 'none' }"
|
||||
:row-key="rowKey as never"
|
||||
:no-data-full="noDataFull"
|
||||
:show-overflow="showOverflow"
|
||||
:empty-cell-text="''"
|
||||
:area-selection="editable"
|
||||
:bordered="bordered ? 'v' : false"
|
||||
@update:columns="onColumnsUpdate"
|
||||
@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="e('empty')" role="status">暂无数据</div>
|
||||
</template>
|
||||
</StkTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@g3soft/tokens/scss' as *;
|
||||
@use './style/index';
|
||||
|
||||
.#{b('table')} {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 自绘外框:描边沿圆弧走、四角闭合。
|
||||
库自身的边框恒为 false(我们传 bordered="v" 只取它的竖向分割线),
|
||||
外框与圆角由这一层提供,避免库的方形渐变外框与圆角打架。 */
|
||||
.#{b('table')}.#{is('bordered')} {
|
||||
border: 1px solid v('border-color', #d9d9d9);
|
||||
border-radius: v('radius-lg', 8px);
|
||||
}
|
||||
|
||||
.#{e('table', 'inner')} {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
/* transform 虚拟滚动模式下把 tbody 提升为合成层:
|
||||
translateY 走合成器,避免每帧重绘整表。 */
|
||||
.stk-tbody-main {
|
||||
will-change: transform;
|
||||
}
|
||||
}
|
||||
|
||||
/* 不自适应时按列配置宽度展示;自适应模式不启用列宽拖拽,使用库默认布局 */
|
||||
.#{b('table')}:not(.#{is('auto-fit-width')}) :deep(.stk-table-main) {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* bordered="v" 仍会给根节点画方形外框与最后一列的右侧渐变线,
|
||||
这些边缘该由外层圆角容器提供,去掉库的外边缘后只保留单元格之间的竖线。
|
||||
注意只能抹掉最右一列的外侧右边线——整份清空会把库给右固定列补的
|
||||
fixed-cell--border-left 也一并干掉(表现为操作列压住内容却没有分割线)。 */
|
||||
.#{e('table', 'inner')}.stk-table.bordered-v {
|
||||
border-left: none;
|
||||
background-image: none;
|
||||
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
--bg-border-right: linear-gradient(transparent, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 行分隔线 ---------- */
|
||||
.#{e('table', 'inner')}.stk-table {
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid v('border-color', #d9d9d9);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 行内编辑 ---------- */
|
||||
.#{e('table', 'cell-editor')} {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
font-size: v('font-size', 14px);
|
||||
}
|
||||
|
||||
/* 编辑器所在的 td 需要确定高度:非虚拟模式下(≤100 行,库不给 td 定高、只有 tr 有)
|
||||
子元素 height:100% 无法解析,Input 会塌成内容高度。 */
|
||||
.#{e('table', 'inner')}.stk-table .stk-tbody-main td:has(> .#{e('table', 'cell-editor-root')}) {
|
||||
height: var(--row-height);
|
||||
}
|
||||
|
||||
.#{e('table', 'inner')} td:has(> .#{e('table', 'cell-editor-root')}) {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
/* 编辑器根节点填满单元格 */
|
||||
.#{e('table', 'inner')}.stk-table {
|
||||
.#{e('table', 'cell-editor-root')} {
|
||||
&.g3-input,
|
||||
&.g3-select {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 展示态文本可选中;拖选期间由库禁用选中,故用 :not() 排除避免冲突 */
|
||||
.#{e('table', 'inner')}.stk-table:not(.is-area-selecting) .stk-tbody-main .table-cell-wrapper {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.#{e('table', 'inner')}.text-overflow .#{e('table', 'cell-display')} {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.#{e('table', 'cell-display')} {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* ---------- 选择列:原生 checkbox + 自绘方框,视觉对齐 G3Checkbox ---------- */
|
||||
.#{e('table', 'checkbox')} {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.#{is('disabled')} {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* 视觉隐藏但保留可访问性与可聚焦性 */
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.#{e('table', 'checkbox-box')} {
|
||||
position: relative;
|
||||
display: block;
|
||||
flex: none;
|
||||
width: v('checkbox-size', 16px);
|
||||
height: v('checkbox-size', 16px);
|
||||
box-sizing: border-box;
|
||||
border: 1px solid v('border-color', #d9d9d9);
|
||||
border-radius: v('radius-sm', 4px);
|
||||
background: v('bg-container', #fff);
|
||||
/* 只过渡颜色:transition: all 会连带过渡定位/尺寸 */
|
||||
transition:
|
||||
background-color v('duration', 0.2s) v('ease', ease),
|
||||
border-color v('duration', 0.2s) v('ease', ease);
|
||||
}
|
||||
|
||||
&:hover:not(.#{is('disabled')}) .#{e('table', 'checkbox-box')} {
|
||||
border-color: v('color-primary', #18181b);
|
||||
}
|
||||
|
||||
&.#{is('checked')} .#{e('table', 'checkbox-box')},
|
||||
&.#{is('indeterminate')} .#{e('table', 'checkbox-box')} {
|
||||
border-color: v('color-primary', #18181b);
|
||||
background: v('color-primary', #18181b);
|
||||
}
|
||||
|
||||
/* 对勾:用边框拼,不引入图标组件(逐行渲染场景下要零开销) */
|
||||
&.#{is('checked')} .#{e('table', 'checkbox-box')}::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: 5px;
|
||||
top: 2px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid v('color-primary-on', #fafafa);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
&.#{is('indeterminate')} .#{e('table', 'checkbox-box')}::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: 3px;
|
||||
top: 7px;
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: v('color-primary-on', #fafafa);
|
||||
}
|
||||
|
||||
input:focus-visible + .#{e('table', 'checkbox-box')} {
|
||||
@include focus-ring();
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 操作列 ---------- */
|
||||
.#{e('table', 'actions-cell')} {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: v('space-1', 4px);
|
||||
}
|
||||
|
||||
.#{e('table', 'action-btn')} {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: v('color-primary', #18181b);
|
||||
font-size: v('font-size-sm', 12px);
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: v('text-disabled', rgba(0, 0, 0, 0.25));
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.#{e('table', 'action-btn-danger')} {
|
||||
color: v('color-danger', #ec4246);
|
||||
}
|
||||
}
|
||||
|
||||
.#{e('table', 'action-more')} {
|
||||
&.#{e('table', 'action-more-open')} {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
.#{e('table', 'confirm-content')} {
|
||||
color: v('text-2', rgba(0, 0, 0, 0.65));
|
||||
font-size: v('font-size', 14px);
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
/* ---------- 空态 ---------- */
|
||||
.#{e('table', 'empty')} {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: v('space-5', 24px);
|
||||
color: v('text-3', rgba(0, 0, 0, 0.45));
|
||||
font-size: v('font-size', 14px);
|
||||
}
|
||||
|
||||
/* ---------- 表尾合计 ---------- */
|
||||
.#{b('table')} .stk-footer {
|
||||
td {
|
||||
border-top: 1px solid v('border-color', #d9d9d9);
|
||||
background: v('fill', rgba(0, 0, 0, 0.02));
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user