This commit is contained in:
oneao committed 2026-10-12 17:32:50 +08:00
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>