1213 lines
41 KiB
Vue
1213 lines
41 KiB
Vue
<script lang="ts">
|
||
import { registerFeature, useAreaSelection } from 'stk-table-vue'
|
||
|
||
/**
|
||
* 区域选择特性注册。
|
||
*
|
||
* 库的区域选择是**按需注册**的(`registerFeature(useAreaSelection)`),
|
||
* 不注册则 `area-selection-change` 永不触发、行内编辑整个失效。
|
||
*
|
||
* ⚠ 注册时机是硬约束:**必须早于任何 StkTable 实例的创建**。
|
||
* 库在 `StkTable.vue` 的 **setup 顶层**做这件事:
|
||
* } = ON_DEMAND_FEATURE[useAreaSelectionName](props, emits, ...)
|
||
* 即组件一被创建就立刻从注册表取值。若那时还没注册,取到的是库内置的
|
||
* **降级 stub**(`config: computed(() => ({ enabled: false }))`),
|
||
* 此后 `area-selection` 类永不出现、单击单元格不会进入编辑。
|
||
*
|
||
* 所以不能在 `onMounted` 里注册 —— 挂载晚于子组件的 setup,已经来不及了。
|
||
* 正确做法是本模块**被求值时就注册**(见文件末尾的模块级调用)。
|
||
*
|
||
* 为什么在模块顶层调用而不是要求消费方在 main.ts 里调用:
|
||
* 组件库不拥有 app 启动流程,不该要求用户改自己的入口文件;而本模块一旦
|
||
* 被 import(用 G3Table 就必然发生),注册就已经完成,先于任何组件创建。
|
||
*
|
||
* `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
|
||
}
|
||
|
||
// 模块求值即注册(早于任何 StkTable 实例化)。原因见上方注释。
|
||
registerTableFeatures()
|
||
</script>
|
||
|
||
<script setup lang="ts">
|
||
import {
|
||
computed,
|
||
getCurrentInstance,
|
||
isReactive,
|
||
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,
|
||
DRAG_ROW_COLUMN_TYPE,
|
||
getHeaderAlign,
|
||
isInternalColumn,
|
||
readInternalWidths,
|
||
resolveCustomCell,
|
||
resolveSeqConfig,
|
||
stripInternalColumns,
|
||
} from './utils/column'
|
||
import type { EditableContext } from './utils/editable'
|
||
import type { G3TableColumn, TableRow, TableRowKey, TableRowSelection } from './types'
|
||
import type { ShallowRef } from 'vue'
|
||
|
||
/**
|
||
* G3Table —— 高性能表格。
|
||
*
|
||
* 底座 `stk-table-vue`:虚拟滚动 / 列宽拖拽 / 列序拖拽 / 区域选择 / 行拖拽排序
|
||
* 都由它提供。本组件负责三件事:**性能门控**、**内部列管理**、**编辑态管理**。
|
||
*
|
||
* 实现照搬 fms 的 `FmsTable.vue`(已上线验证),只把命名与令牌换成 @g3soft 体系。
|
||
*/
|
||
|
||
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: () => ({}) as Record<string, unknown>,
|
||
},
|
||
)
|
||
|
||
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
|
||
getTableData?: () => TableRow[]
|
||
scrollTo?: (top: number, left: number | null) => void
|
||
setCurrentRow?: (row: TableRow | undefined, options: object) => void
|
||
getSelectedArea?: () => unknown
|
||
clearSelectedArea?: () => void
|
||
setRowExpand?: (...args: unknown[]) => unknown
|
||
} | 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
|
||
|
||
/* ---------- 行编辑态 ---------- */
|
||
|
||
/**
|
||
* 单行的编辑状态。
|
||
*
|
||
* `focused` 在 `activateEditing` 里刻意不重置(fms 同此):它记录的是「重渲染前
|
||
* 编辑器是否持有焦点」,供编辑器焦点恢复判定用,不是 UI 状态。
|
||
*/
|
||
interface RowEditingState {
|
||
editing: boolean
|
||
focusCol: string | null
|
||
focused: boolean
|
||
/** 编辑开始时的各可编辑列取值快照,供 shouldNotify 去重 */
|
||
initialValues: Map<string, unknown> | null
|
||
}
|
||
|
||
/**
|
||
* 每行的编辑状态存在 WeakMap 里(key 是行对象本身)。
|
||
* 用 WeakMap 而不是给行对象打标记:不污染业务数据,行被回收时状态自动释放。
|
||
*/
|
||
const rowEditingStates = new WeakMap<TableRow, ShallowRef<RowEditingState>>()
|
||
let activeEditingRow: TableRow | null = null
|
||
let pendingEditingTarget: { row: TableRow; focusCol: string | null } | null = null
|
||
const pressedNavigationKeys = new Set<string>()
|
||
|
||
/**
|
||
* 是否存在活动编辑行。
|
||
*
|
||
* **性能关键**:为 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): ShallowRef<RowEditingState> | null {
|
||
if (!row || typeof row !== 'object') return null
|
||
let state = rowEditingStates.get(row)
|
||
if (!state) {
|
||
state = shallowRef<RowEditingState>({
|
||
editing: false,
|
||
focusCol: null,
|
||
focused: false,
|
||
initialValues: null,
|
||
})
|
||
rowEditingStates.set(row, state)
|
||
}
|
||
return state
|
||
}
|
||
|
||
/** 编辑开始时的各可编辑列取值快照,供 shouldNotify 去重(fms 的 initialValues) */
|
||
function captureEditableValues(row: TableRow): Map<string, unknown> {
|
||
return new Map(
|
||
props.columns
|
||
.filter((col) => col.editor)
|
||
.map((col) => [col.dataIndex, row[col.dataIndex]]),
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 仅清除某行的编辑态(不触碰 activeEditingRow / editingActive)。
|
||
* 行与行之间切换编辑时使用,避免 editingActive 翻转导致整表列重建。
|
||
*/
|
||
function deactivateRowEditing(row: TableRow) {
|
||
const state = rowEditingStates.get(row)
|
||
if (state && state.value.editing) {
|
||
state.value = { editing: false, focusCol: null, focused: false, initialValues: null }
|
||
}
|
||
}
|
||
|
||
function clearActiveEditing() {
|
||
if (!activeEditingRow) return
|
||
const state = rowEditingStates.get(activeEditingRow)
|
||
activeEditingRow = null
|
||
editingActive.value = false
|
||
// 仅在确实处于编辑态时写 ref:长按方向键滚动期间每键都会调用本函数,
|
||
// 跳过无变化的写入可避免活动行单元格每键重复渲染。
|
||
if (state && state.value.editing) {
|
||
state.value = { editing: false, focusCol: null, focused: false, initialValues: null }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 父级可能在 change 后回灌同 rowKey 的新对象,编辑状态需要随行对象迁移。
|
||
* 丢了这个迁移,用户正在编辑时一旦父级刷新数据,编辑态与 focus 就会凭空消失。
|
||
*/
|
||
function rebindActiveEditingRow(rows: TableRow[]) {
|
||
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))
|
||
|
||
activeEditingRow = null
|
||
deactivateRowEditing(previousRow)
|
||
if (!replacement || !previousState?.editing) {
|
||
editingActive.value = false
|
||
return
|
||
}
|
||
|
||
const state = getRowEditingState(replacement)
|
||
activeEditingRow = replacement
|
||
editingActive.value = true
|
||
if (state) {
|
||
state.value = {
|
||
editing: true,
|
||
focusCol: previousState.focusCol,
|
||
focused: previousState.focused,
|
||
initialValues: captureEditableValues(replacement),
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 开发模式:数据源非响应式时只警告一次(避免每次点单元格都刷屏) */
|
||
let warnedNonReactive = false
|
||
|
||
/**
|
||
* 校验数据源是否满足行内编辑的前提:**行对象必须可被 Vue 追踪**。
|
||
*
|
||
* 编辑回写是直接改 row 上的字段(utils/editable.ts 的 onUpdate:modelValue),
|
||
* 若行对象是普通 JS 对象(典型来源:`computed(() => [...])` 派生出的数组),
|
||
* 这次写入不会触发任何重渲染。症状极具迷惑性:
|
||
* · `change` 事件照常抛出(数据确实改了,父级能拿到)
|
||
* · 但界面上仍显示旧值
|
||
* · 点一下别处(触发无关重渲染)才"突然"更新
|
||
* 实测:computed 数组里 row.pol 从 CNSHA 改成 CNNGB 后,
|
||
* G3Select 收到的 modelValue 仍是 CNSHA。
|
||
*
|
||
* 之所以在这里检测而不是在 props 变化时检测:只有真正进入编辑才会踩到,
|
||
* 且此时行对象一定已存在。用 isReactive 判断(Vue 的响应式代理带 __v_isReactive)。
|
||
*/
|
||
function warnIfDataSourceNotReactive(row: TableRow) {
|
||
if (!import.meta.env?.DEV || warnedNonReactive) return
|
||
if (isReactive(row) || isReactive(props.dataSource)) return
|
||
|
||
warnedNonReactive = true
|
||
console.warn(
|
||
'[g3-ui] G3Table 行内编辑需要响应式数据源,但当前 dataSource 的行对象不是响应式代理。\n' +
|
||
' 症状:编辑器里改了值,界面上不刷新(change 事件仍会抛出,数据其实已改),' +
|
||
'点其他位置触发重渲染后才显示新值。\n' +
|
||
' 原因:编辑回写是直接改 row 字段,普通对象无法被 Vue 追踪。\n' +
|
||
' 修法:用 ref() 而不是 computed() 返回普通对象数组,例如\n' +
|
||
' const rows = ref<Row[]>([])\n' +
|
||
' watch(source, (v) => { rows.value = build(v) }, { immediate: true })',
|
||
)
|
||
}
|
||
|
||
function activateEditing(row: TableRow, focusCol: string | null) {
|
||
if (!props.editable || !row) return
|
||
warnIfDataSourceNotReactive(row)
|
||
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,
|
||
initialValues: captureEditableValues(row),
|
||
}
|
||
pendingFocusKey = String(getRowKey(row))
|
||
return
|
||
}
|
||
|
||
const current = state.value
|
||
if (current.editing && current.focusCol === focusCol) return
|
||
state.value = { ...current, editing: true, focusCol }
|
||
// 同行切换列:编辑器会因 focusCol 变化重挂载,标记待聚焦以恢复焦点+全选
|
||
pendingFocusKey = String(getRowKey(row))
|
||
}
|
||
|
||
/**
|
||
* 值比较:select / checkbox 用 Object.is,其余按字符串比(Input 落库的是 string)。
|
||
* 用于 shouldNotify 去重——值没真变就不 emit,否则「点进去又点出来」也会触发 change,
|
||
* 业务回灌数据后整表重渲染,进而抢焦点、再失焦,表现为高亮一闪而过。
|
||
*/
|
||
function editorValuesEqual(col: G3TableColumn<TableRow>, previous: unknown, current: unknown) {
|
||
if (col.editor?.type === 'select' || col.editor?.type === 'checkbox') {
|
||
return Object.is(previous, current)
|
||
}
|
||
return String(previous ?? '') === String(current ?? '')
|
||
}
|
||
|
||
/**
|
||
* 最近激活、等待编辑器挂载后聚焦的行 key。用于区分「用户激活单元格(点击/键盘/程序化)」
|
||
* 与「数据变更引发的重挂载」:前者需要自动聚焦+全选,后者不应聚焦——否则失焦引发的
|
||
* 重渲染会把焦点抢回来(表现为离开单元格时高亮一闪而过)。
|
||
*/
|
||
let pendingFocusKey: string | null = null
|
||
|
||
const editingCtx: EditableContext<TableRow> = {
|
||
isEditing: (row) => props.editable && Boolean(getRowEditingState(row)?.value.editing),
|
||
shouldFocus: (row, col) => getRowEditingState(row)?.value.focusCol === col.dataIndex,
|
||
wasFocused: (row) => Boolean(getRowEditingState(row)?.value.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, focused: true }
|
||
},
|
||
onEditorBlur: (row) => {
|
||
const state = getRowEditingState(row)
|
||
if (state?.value) state.value = { ...state.value, focused: false }
|
||
},
|
||
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, value) => {
|
||
emit('change', row, col, value)
|
||
},
|
||
/**
|
||
* Esc 回滚:把该行所有可编辑列恢复到进入编辑态时的值。
|
||
*
|
||
* 快照就是 initialValues(activateEditing 时用 captureEditableValues 存的),
|
||
* 与 shouldNotify 去重共用同一份 Map,所以这里**只回滚数据、不改快照**:
|
||
* 快照代表的就是「编辑前的原值」,回滚到原值后二者自然一致,
|
||
* 之后用户再编辑时去重逻辑仍然正确。
|
||
*
|
||
* 只做数据回滚,不负责退出编辑态(调用方随后 blur 即可)。
|
||
* 返回是否发生了实际变化。
|
||
*/
|
||
revertRow: (row) => {
|
||
const state = getRowEditingState(row)
|
||
const values = state?.value.initialValues
|
||
if (!values) return false
|
||
|
||
let changed = false
|
||
for (const [dataIndex, original] of values) {
|
||
if (Object.is(row[dataIndex], original)) continue
|
||
;(row as TableRow)[dataIndex] = original
|
||
changed = true
|
||
}
|
||
return changed
|
||
},
|
||
}
|
||
|
||
/* ---------- 列 ---------- */
|
||
|
||
const seqConfig = computed(() => resolveSeqConfig(props.seq))
|
||
const seqWidth = ref(seqConfig.value.width)
|
||
watch(
|
||
() => seqConfig.value.width,
|
||
(width) => {
|
||
seqWidth.value = width
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
const selectionWidth = ref(44)
|
||
const dragRowWidth = ref(DRAG_ROW_COLUMN.width)
|
||
|
||
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({
|
||
...DRAG_ROW_COLUMN,
|
||
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?.getTableData?.() ?? props.dataSource
|
||
tableRef.value?.clearSelectedArea?.()
|
||
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)
|
||
}
|
||
|
||
/* ---------- 对外方法 ---------- */
|
||
|
||
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?.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?.setCurrentRow?.(row, { silent: true })
|
||
activateEditing(row, focusCol)
|
||
}
|
||
|
||
function initVirtualScroll() {
|
||
tableRef.value?.initVirtualScroll?.()
|
||
}
|
||
|
||
function getSelectedArea() {
|
||
return tableRef.value?.getSelectedArea?.()
|
||
}
|
||
|
||
function clearSelectedArea() {
|
||
tableRef.value?.clearSelectedArea?.()
|
||
}
|
||
|
||
function setRowExpand(...args: unknown[]) {
|
||
return tableRef.value?.setRowExpand?.(...args)
|
||
}
|
||
|
||
/* ---------- 键盘导航:编辑器激活防抖 ---------- */
|
||
|
||
const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab'])
|
||
let editActivateTimer: ReturnType<typeof setTimeout> | null = 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: KeyboardEvent) {
|
||
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: KeyboardEvent) {
|
||
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()
|
||
}
|
||
|
||
/**
|
||
* 选区变化后,目标行进入编辑,活动单元格获得焦点。
|
||
*
|
||
* 三档策略(照搬 fms):
|
||
* - 按住方向键滚动(event.repeat):全程保持 0 编辑器,避免长按下反复创建组件导致卡顿/闪烁;
|
||
* 松手后再激活到最终行。
|
||
* - 慢速离散点按(非 repeat):立即激活,编辑器逐行跟随、跟手。
|
||
* - 快速连按(间隔 <150ms):防抖合并,避免连按时每键挂载掉帧。
|
||
*/
|
||
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
|
||
const row = target ? (tableRef.value?.getTableData?.() ?? props.dataSource)[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
|
||
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
|
||
}
|
||
|
||
// 非键盘导航(鼠标点击等):立即激活。同行切换列时编辑器重挂载并恢复焦点+全选
|
||
// (直接输入即替换整格内容);点击已聚焦编辑器内部则由原生行为定位光标。
|
||
pendingEditingTarget = null
|
||
clearEditActivateTimer()
|
||
activateEditing(row, focusCol)
|
||
lastEditActivateAt = performance.now()
|
||
emit('area-selection-change', ranges)
|
||
}
|
||
|
||
/* ---------- 生命周期 ---------- */
|
||
|
||
onMounted(() => {
|
||
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
|
||
removeNavigationListeners()
|
||
clearEditActivateTimer()
|
||
pressedNavigationKeys.clear()
|
||
})
|
||
|
||
// 数据被父级刷新后,行对象可能整体换新,编辑态需随 key 迁移
|
||
watch(
|
||
() => props.dataSource,
|
||
(rows) => {
|
||
rebindActiveEditingRow(rows)
|
||
},
|
||
)
|
||
|
||
// activeRowKey 只控制当前行高亮,不隐式触发编辑
|
||
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))
|
||
if (row) tableRef.value?.setCurrentRow?.(row, { silent: true })
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
watch(
|
||
() => props.editable,
|
||
(editable) => {
|
||
if (editable) return
|
||
pendingEditingTarget = null
|
||
removeNavigationListeners()
|
||
pressedNavigationKeys.clear()
|
||
isRepeatNavigation = false
|
||
clearEditActivateTimer()
|
||
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"
|
||
@keydown.capture="handleKeydownCapture"
|
||
>
|
||
<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 ? true : 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;
|
||
}
|
||
|
||
/* 边框完全交给库:传 `bordered: true`,外框 / 圆角 / 横竖线都由库自己画。
|
||
库文档(table/basic/bordered)说明:出于滚动条影响,表格**右侧和底部的边框
|
||
由单元格的 border-right / border-bottom 实现,因此会消失**,需要时自行补 CSS。
|
||
我们按库的原样使用,不再自绘外框与行分隔线。 */
|
||
|
||
.#{e('table', 'inner')} {
|
||
width: 100%;
|
||
height: 100%;
|
||
|
||
/* transform 虚拟滚动模式下把 tbody 提升为合成层:
|
||
translateY 走合成器,避免每帧重绘整表。 */
|
||
.stk-tbody-main {
|
||
will-change: transform;
|
||
}
|
||
}
|
||
|
||
/* 不自适应时按列配置宽度展示;自适应模式不启用列宽拖拽,使用库默认布局。
|
||
库的 DOM 是
|
||
.stk-table{display:flex;flex-direction:column}
|
||
└ .stk-table-scroll-container{display:flex} ← 表头粘性定位的作用域
|
||
└ table.stk-table-main ← 这里是个 flex item
|
||
库默认给 --main 是 flex:1,表头 th 靠 position:sticky 固定、作用域就是这个
|
||
flex 容器,table 一旦被拉伸到容器宽,表头几何就走样(表现为滚动时表头跟着跑)。
|
||
实测(playwright):去掉本规则并强制 flex:1 后,table 宽度 790px → 1086px
|
||
(被拉满容器),表头跟着变形,所以这条必须留着。
|
||
注意写普通后代选择器:这个 style 块**不是 scoped**,:deep() 在非 scoped
|
||
块里会被编译器整条丢弃(实测产物里该规则消失),必须用真选择器。 */
|
||
.#{b('table')}:not(.#{is('auto-fit-width')}) .#{e('table', 'inner')} .stk-table-main {
|
||
flex: none;
|
||
}
|
||
|
||
/* ---------- 行内编辑 ---------- */
|
||
|
||
/**
|
||
* 编辑态行高必须与 rowHeight 一致(32px = var(--row-height))。
|
||
*
|
||
* 曾经的 bug:编辑某行后该行从 32px 变成 42px,同行的其它单元格(包括
|
||
* 纯文本的序号列)也一起被撑高。逐条禁用声明的 bisect 实测:
|
||
* padding: 3px + 编辑器 height:100% → 42px ← 原始
|
||
* padding: 0 → 36px (减 6)
|
||
* padding: 0 + 编辑器显式 26px → 32px ← 目标达成
|
||
* padding: 0 + 编辑器显式 32px → 36px
|
||
*
|
||
* 根因:td 是 border-box,指定 height 32px 时其 content box 只有
|
||
* 32 - 6(padding) = 26px;子元素的 height:100% 却解析成 32px(不是 26px)。
|
||
* 于是「编辑器 32 + 上下 padding 6 = 38px」超过了 td 的指定高度,
|
||
* table-cell 只能按内容撑开,再加行盒的取整余量,最终落到 42px。
|
||
*
|
||
* 修法:**td 的垂直 padding 归零**,只保留水平内边距。
|
||
* 这样 td 的 content box 就等于行高,子元素 height:100% 有确定基准(32px),
|
||
* 不会再撑高行。编辑器与格线之间那点呼吸感改由编辑器自身的 padding 提供
|
||
* (G3Input/G3Select 本来就带内边距,无需再靠 td 的 padding)。
|
||
*
|
||
* ⚠ 不要再把垂直 padding 加回 td —— 那会立刻重现 42px。
|
||
* 组件自带的 E2E 有断言锁住「编辑前后行高不变」。
|
||
*/
|
||
.#{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);
|
||
/* 垂直 padding 必须为 0:见上方注释,它会让 height:100% 的基准与 td 的
|
||
border-box 高度不一致,把行撑高 10px。水平保留一点,避免输入框贴边。 */
|
||
padding-top: 0;
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
/* 水平内边距单独给(不写 padding 简写,避免把垂直方向又带回来) */
|
||
.#{e('table', 'inner')} td:has(> .#{e('table', 'cell-editor-root')}) {
|
||
padding-left: 3px;
|
||
padding-right: 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;
|
||
|
||
/**
|
||
* vertical-align 必须显式给,不能留默认的 baseline。
|
||
*
|
||
* 编辑器是**行内级**元素(G3Input/G3Select 用 inline-flex / inline-block),
|
||
* 行盒按 baseline 对齐时会在其下方额外留出 descender 高度(实测约 4px),
|
||
* 把整行从 32px 撑到 36px。bisect 实测:
|
||
* 基线(inline-flex + baseline) → 36px
|
||
* vertical-align: middle → 32px ✅
|
||
* vertical-align: top → 32px
|
||
* display: block → 32px
|
||
* 选 middle 而非 block:既消除基线空隙,又保留编辑器自身的行内布局方式,
|
||
* 同时让它垂直居中于单元格(视觉上也更合理)。
|
||
*/
|
||
vertical-align: middle;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 展示态文本可选中;拖选期间由库禁用选中,故用 :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>
|