niuma-ui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +25 -0
  4. package/README.en.md +99 -0
  5. package/README.md +161 -0
  6. package/package.json +106 -0
  7. package/src/__tests__/RsBadge.spec.ts +40 -0
  8. package/src/__tests__/RsBreadcrumb.spec.ts +172 -0
  9. package/src/__tests__/RsButton.spec.ts +108 -0
  10. package/src/__tests__/RsCalendarGrid.spec.ts +82 -0
  11. package/src/__tests__/RsCard.spec.ts +97 -0
  12. package/src/__tests__/RsCheckbox.spec.ts +36 -0
  13. package/src/__tests__/RsCodeEditor.spec.ts +111 -0
  14. package/src/__tests__/RsConfirmDialog.spec.ts +158 -0
  15. package/src/__tests__/RsContainer.spec.ts +89 -0
  16. package/src/__tests__/RsContextMenu.spec.ts +118 -0
  17. package/src/__tests__/RsDatePicker.spec.ts +88 -0
  18. package/src/__tests__/RsDateTimePicker.spec.ts +69 -0
  19. package/src/__tests__/RsDialog.spec.ts +404 -0
  20. package/src/__tests__/RsDrawer.spec.ts +145 -0
  21. package/src/__tests__/RsDropdown.spec.ts +135 -0
  22. package/src/__tests__/RsEmpty.spec.ts +99 -0
  23. package/src/__tests__/RsForm.spec.ts +173 -0
  24. package/src/__tests__/RsIcon.spec.ts +244 -0
  25. package/src/__tests__/RsInput.spec.ts +269 -0
  26. package/src/__tests__/RsInputNumber.spec.ts +118 -0
  27. package/src/__tests__/RsLabel.spec.ts +106 -0
  28. package/src/__tests__/RsLink.spec.ts +49 -0
  29. package/src/__tests__/RsLoading.spec.ts +84 -0
  30. package/src/__tests__/RsMarkdown.spec.ts +143 -0
  31. package/src/__tests__/RsMenu.spec.ts +119 -0
  32. package/src/__tests__/RsNewComponents.spec.ts +17 -0
  33. package/src/__tests__/RsPagination.spec.ts +158 -0
  34. package/src/__tests__/RsPopover.spec.ts +126 -0
  35. package/src/__tests__/RsScrollbar.spec.ts +53 -0
  36. package/src/__tests__/RsSelect.spec.ts +291 -0
  37. package/src/__tests__/RsSidebar.spec.ts +119 -0
  38. package/src/__tests__/RsSplitPane.spec.ts +177 -0
  39. package/src/__tests__/RsStatCard.spec.ts +79 -0
  40. package/src/__tests__/RsSteps.spec.ts +125 -0
  41. package/src/__tests__/RsTable.perf.spec.ts +103 -0
  42. package/src/__tests__/RsTable.spec.ts +1135 -0
  43. package/src/__tests__/RsTabs.spec.ts +328 -0
  44. package/src/__tests__/RsTerminal.spec.ts +218 -0
  45. package/src/__tests__/RsTimePicker.spec.ts +77 -0
  46. package/src/__tests__/RsToaster.spec.ts +95 -0
  47. package/src/__tests__/RsToolbar.spec.ts +75 -0
  48. package/src/__tests__/RsTooltip.spec.ts +111 -0
  49. package/src/__tests__/RsTree.spec.ts +572 -0
  50. package/src/__tests__/RsUpload.spec.ts +160 -0
  51. package/src/__tests__/RsVirtualList.spec.ts +77 -0
  52. package/src/__tests__/date-picker-utils.spec.ts +70 -0
  53. package/src/__tests__/dialog-viewport.spec.ts +38 -0
  54. package/src/__tests__/iso-local-datetime.spec.ts +58 -0
  55. package/src/__tests__/rs-clipboard.spec.ts +142 -0
  56. package/src/__tests__/setup.ts +12 -0
  57. package/src/__tests__/split-pane-utils.spec.ts +136 -0
  58. package/src/__tests__/table-drag.spec.ts +35 -0
  59. package/src/__tests__/table-edit-utils.spec.ts +194 -0
  60. package/src/__tests__/table-utils.spec.ts +286 -0
  61. package/src/__tests__/theme.spec.ts +22 -0
  62. package/src/__tests__/tree-utils.spec.ts +234 -0
  63. package/src/__tests__/useTableCellTooltip.spec.ts +101 -0
  64. package/src/components/RsAvatar.vue +115 -0
  65. package/src/components/RsBadge.vue +59 -0
  66. package/src/components/RsBreadcrumb.vue +65 -0
  67. package/src/components/RsBreadcrumbItems.vue +36 -0
  68. package/src/components/RsButton.vue +393 -0
  69. package/src/components/RsCalendarGrid.vue +284 -0
  70. package/src/components/RsCard.vue +237 -0
  71. package/src/components/RsCheckbox.vue +189 -0
  72. package/src/components/RsCodeBlock.vue +278 -0
  73. package/src/components/RsCodeEditor.vue +597 -0
  74. package/src/components/RsConfigProvider.vue +92 -0
  75. package/src/components/RsConfirmDialog.vue +178 -0
  76. package/src/components/RsContainer.vue +235 -0
  77. package/src/components/RsContextMenu.vue +238 -0
  78. package/src/components/RsContextMenuItems.vue +77 -0
  79. package/src/components/RsDatePicker.vue +756 -0
  80. package/src/components/RsDateTimePicker.vue +42 -0
  81. package/src/components/RsDialog.vue +486 -0
  82. package/src/components/RsDrawer.vue +175 -0
  83. package/src/components/RsDropdown.vue +252 -0
  84. package/src/components/RsDropdownItems.vue +90 -0
  85. package/src/components/RsEmpty.vue +107 -0
  86. package/src/components/RsForm.vue +131 -0
  87. package/src/components/RsIcon.vue +104 -0
  88. package/src/components/RsInput.vue +1101 -0
  89. package/src/components/RsInputNumber.vue +532 -0
  90. package/src/components/RsLabel.vue +54 -0
  91. package/src/components/RsLink.vue +106 -0
  92. package/src/components/RsLoading.vue +171 -0
  93. package/src/components/RsMarkdown.vue +329 -0
  94. package/src/components/RsMenu.vue +256 -0
  95. package/src/components/RsMenuItems.vue +125 -0
  96. package/src/components/RsMonacoEditor.vue +541 -0
  97. package/src/components/RsPagination.vue +184 -0
  98. package/src/components/RsPopover.vue +77 -0
  99. package/src/components/RsProseEditor.vue +160 -0
  100. package/src/components/RsScrollbar.vue +171 -0
  101. package/src/components/RsSelect.vue +1045 -0
  102. package/src/components/RsSidebar.vue +121 -0
  103. package/src/components/RsSidebarGroup.vue +32 -0
  104. package/src/components/RsSidebarItem.vue +72 -0
  105. package/src/components/RsSplitPane.vue +619 -0
  106. package/src/components/RsStatCard.vue +85 -0
  107. package/src/components/RsSteps.vue +138 -0
  108. package/src/components/RsTable.vue +2664 -0
  109. package/src/components/RsTableColgroup.vue +17 -0
  110. package/src/components/RsTableHeaderRow.vue +30 -0
  111. package/src/components/RsTabs.vue +951 -0
  112. package/src/components/RsTerminal.vue +697 -0
  113. package/src/components/RsTimePicker.vue +554 -0
  114. package/src/components/RsTimePickerColumns.vue +226 -0
  115. package/src/components/RsToaster.vue +396 -0
  116. package/src/components/RsToolbar.vue +135 -0
  117. package/src/components/RsTooltip.vue +142 -0
  118. package/src/components/RsTooltipProvider.vue +20 -0
  119. package/src/components/RsTree.vue +1248 -0
  120. package/src/components/RsUpload.vue +279 -0
  121. package/src/components/RsVirtualList.vue +217 -0
  122. package/src/components/avatar-utils.ts +19 -0
  123. package/src/components/breadcrumb-utils.ts +19 -0
  124. package/src/components/class-utils.ts +29 -0
  125. package/src/components/code-editor-utils.ts +106 -0
  126. package/src/components/code-mirror-completion.ts +40 -0
  127. package/src/components/code-mirror-diagnostics.ts +52 -0
  128. package/src/components/code-mirror-ghost.ts +158 -0
  129. package/src/components/code-mirror-inline-edit.ts +55 -0
  130. package/src/components/code-mirror-intel.ts +81 -0
  131. package/src/components/code-mirror-lang.ts +128 -0
  132. package/src/components/context-menu-utils.ts +20 -0
  133. package/src/components/date-picker-utils.ts +284 -0
  134. package/src/components/date-range-validation.ts +29 -0
  135. package/src/components/date-validation.ts +38 -0
  136. package/src/components/dialog-viewport.ts +48 -0
  137. package/src/components/dialog-window.ts +253 -0
  138. package/src/components/dropdown-utils.ts +31 -0
  139. package/src/components/form-utils.ts +77 -0
  140. package/src/components/input-number-utils.ts +128 -0
  141. package/src/components/input-rules.ts +84 -0
  142. package/src/components/loading-utils.ts +8 -0
  143. package/src/components/markdown-utils.ts +126 -0
  144. package/src/components/menu-utils.ts +48 -0
  145. package/src/components/overlay-utils.ts +47 -0
  146. package/src/components/pagination-utils.ts +38 -0
  147. package/src/components/popover-utils.ts +8 -0
  148. package/src/components/reka.ts +106 -0
  149. package/src/components/resolve-radius.ts +22 -0
  150. package/src/components/resolve-size.ts +29 -0
  151. package/src/components/scrollbar-utils.ts +10 -0
  152. package/src/components/select-utils.ts +72 -0
  153. package/src/components/split-pane-utils.ts +187 -0
  154. package/src/components/steps-utils.ts +22 -0
  155. package/src/components/table/RsTableBodyRow.vue +287 -0
  156. package/src/components/table/RsTableCell.vue +239 -0
  157. package/src/components/table/RsTableCellContent.ts +18 -0
  158. package/src/components/table/RsTableCellEditor.vue +351 -0
  159. package/src/components/table/RsTableEditGutter.vue +54 -0
  160. package/src/components/table/RsTableHeaderFilter.vue +107 -0
  161. package/src/components/table/RsTableRowStatus.vue +69 -0
  162. package/src/components/table/rs-table.css +1141 -0
  163. package/src/components/table/table-context-menu.ts +53 -0
  164. package/src/components/table/table-edit-utils.ts +496 -0
  165. package/src/components/table/table-keys.ts +12 -0
  166. package/src/components/table-drag.ts +215 -0
  167. package/src/components/table-utils.ts +950 -0
  168. package/src/components/tabs-utils.ts +107 -0
  169. package/src/components/terminal-utils.ts +301 -0
  170. package/src/components/terminal-wheel.ts +49 -0
  171. package/src/components/time-picker-utils.ts +167 -0
  172. package/src/components/time-range-validation.ts +23 -0
  173. package/src/components/time-validation.ts +20 -0
  174. package/src/components/tree-utils.ts +518 -0
  175. package/src/components/upload-utils.ts +55 -0
  176. package/src/components/virtual-list-utils.ts +17 -0
  177. package/src/composables/useClickOutside.ts +22 -0
  178. package/src/composables/useRsConfig.ts +91 -0
  179. package/src/composables/useRsI18n.ts +22 -0
  180. package/src/composables/useRsTabsNav.ts +187 -0
  181. package/src/composables/useRsToast.ts +48 -0
  182. package/src/composables/useTableCellTooltip.ts +239 -0
  183. package/src/composables/useTableEdit.ts +271 -0
  184. package/src/composables/useTableRowHighlight.ts +50 -0
  185. package/src/dev/vite-codemirror-deps.ts +20 -0
  186. package/src/icons/custom/api.ts +54 -0
  187. package/src/icons/custom/clickhouse.ts +52 -0
  188. package/src/icons/custom/dameng.ts +83 -0
  189. package/src/icons/custom/ftp.ts +61 -0
  190. package/src/icons/custom/kingbase.ts +52 -0
  191. package/src/icons/custom/mongodb.ts +50 -0
  192. package/src/icons/custom/mysql.ts +51 -0
  193. package/src/icons/custom/oracle.ts +52 -0
  194. package/src/icons/custom/redis.ts +51 -0
  195. package/src/icons/custom/sqlite.ts +50 -0
  196. package/src/icons/custom/sqlserver.ts +52 -0
  197. package/src/icons/custom/vastbase.ts +57 -0
  198. package/src/icons/lucide.ts +78 -0
  199. package/src/icons/registry.ts +35 -0
  200. package/src/index.ts +425 -0
  201. package/src/lib/iso-local-datetime.ts +152 -0
  202. package/src/lib/rs-dayjs.ts +56 -0
  203. package/src/locale/messages.ts +362 -0
  204. package/src/locale/types.ts +5 -0
  205. package/src/monaco/debug-decorations.css +20 -0
  206. package/src/monaco/debug-decorations.ts +70 -0
  207. package/src/monaco/index.ts +22 -0
  208. package/src/monaco/languages/index.ts +21 -0
  209. package/src/monaco/languages/mongodb-shell.ts +125 -0
  210. package/src/monaco/languages/types.ts +55 -0
  211. package/src/monaco/setup.ts +101 -0
  212. package/src/styles.css +1040 -0
  213. package/src/theme/apply.ts +12 -0
  214. package/src/theme/brand.example.css +37 -0
  215. package/src/theme/presets.ts +69 -0
  216. package/src/theme/types.ts +81 -0
  217. package/src/utils/rs-clipboard.ts +272 -0
  218. package/src/vite-env.d.ts +7 -0
  219. package/vite-plugins/monaco-zh-nls.ts +101 -0
  220. package/vite-plugins/silence-antlr-parse-console.ts +29 -0
@@ -0,0 +1,2664 @@
1
+ <script setup lang="ts" generic="T extends import('./table-utils').RsTableRowData">
2
+ import {
3
+ computed,
4
+ nextTick,
5
+ onActivated,
6
+ onDeactivated,
7
+ onMounted,
8
+ onUnmounted,
9
+ ref,
10
+ useSlots,
11
+ watch,
12
+ } from 'vue'
13
+ import { useRsI18n } from '../composables/useRsI18n'
14
+ import RsIcon from './RsIcon.vue'
15
+ import RsContextMenu from './RsContextMenu.vue'
16
+ import type { RsContextMenuItem } from './context-menu-utils'
17
+ import type {
18
+ RsTableColumn,
19
+ RsTableGroupBy,
20
+ RsTableRowDropPosition,
21
+ RsTableRowEntry,
22
+ RsTableRowKey,
23
+ RsTableSelectionType,
24
+ RsTableSize,
25
+ RsTableSortOrder,
26
+ RsTableSortState,
27
+ } from './table-utils'
28
+ import { useTableCellTooltip } from '../composables/useTableCellTooltip'
29
+ import { useTableEdit } from '../composables/useTableEdit'
30
+ import { useTableRowHighlight } from '../composables/useTableRowHighlight'
31
+ import RsTableBodyRow from './table/RsTableBodyRow.vue'
32
+ import RsTableHeaderFilter from './table/RsTableHeaderFilter.vue'
33
+ import {
34
+ isBooleanToggleColumn,
35
+ isCellValueChanged,
36
+ isColumnEditable,
37
+ isColumnNullable,
38
+ isRowEditPending,
39
+ listBatchColumnTargets,
40
+ navigateEditableCell,
41
+ nullToEditText,
42
+ parseCellEditInput,
43
+ parseClipboardGrid,
44
+ resolveCellEditText,
45
+ resolveColumnEditTrigger,
46
+ resolveColumnEditorOptions,
47
+ resolveColumnRawValue,
48
+ validateCellValueAsync,
49
+ type RsTableCellEditFocusMode,
50
+ type RsTableCellNavigateDirection,
51
+ } from './table/table-edit-utils'
52
+ import {
53
+ buildDefaultTableContextMenuItems,
54
+ resolveTableCellCopyText,
55
+ resolveTableRowCopyText,
56
+ TABLE_CTX_COPY_CELL,
57
+ TABLE_CTX_COPY_ROW,
58
+ } from './table/table-context-menu'
59
+ import { copyTextToClipboard } from '../utils/rs-clipboard'
60
+ import {
61
+ buildTableEntries,
62
+ buildVirtualHeightModel,
63
+ clampColumnWidth,
64
+ columnUsesSharedTooltip,
65
+ createInitialColumnWidths,
66
+ fixedCellStyle,
67
+ getSortOrderForKey,
68
+ getSortPriorityForKey,
69
+ injectExpandRows,
70
+ isColumnFilterActive,
71
+ isNearScrollBottom,
72
+ isTableRowDisabled,
73
+ parseColumnWidth,
74
+ reorderColumnKeys,
75
+ resolveCellTooltipMode,
76
+ resolveCellTooltipText,
77
+ resolveColumnOrder,
78
+ resolveColumnStyle,
79
+ resolveEntryKey,
80
+ resolveFixedColumnStyles,
81
+ resolveOrderedColumns,
82
+ resolveRowKey,
83
+ resolveScrollWidth,
84
+ resolveSelectAllState,
85
+ resolveTableRowHeight,
86
+ resolveTableSize,
87
+ selectRowKeys,
88
+ selectRowKeysByClick,
89
+ sliceVirtualColumns,
90
+ sliceVirtualHeightModel,
91
+ resolveColumnPixelWidth,
92
+ toggleExpandedRowKeys,
93
+ toggleMultiSortState,
94
+ toggleSelectAll,
95
+ toggleSortState,
96
+ } from './table-utils'
97
+ import {
98
+ createTableRowDragHandlers,
99
+ createTableRowDragState,
100
+ type RsTableRowDragTrigger,
101
+ type RsTableRowDropMode,
102
+ } from './table-drag'
103
+ import { resolveVirtualListHeight } from './virtual-list-utils'
104
+ import './table/rs-table.css'
105
+
106
+ defineOptions({ inheritAttrs: false })
107
+
108
+ const props = withDefaults(
109
+ defineProps<{
110
+ columns: RsTableColumn<T>[]
111
+ data: T[]
112
+ rowKey?: RsTableRowKey<T>
113
+ loading?: boolean
114
+ bordered?: boolean
115
+ columnBordered?: boolean
116
+ /** 外容器圆角;嵌套在已有圆角/直角父级时可设为 false */
117
+ rounded?: boolean
118
+ /** 填充父级高度:表格自身成为受限滚动容器,纵向滚动内置、横向滚动条固定在可视底部 */
119
+ fill?: boolean
120
+ compact?: boolean
121
+ size?: RsTableSize
122
+ scrollX?: number | string
123
+ selectable?: boolean
124
+ selectionType?: RsTableSelectionType
125
+ selectedRowKeys?: string[]
126
+ defaultSelectedRowKeys?: string[]
127
+ rowSelectable?: (row: T, index: number) => boolean
128
+ expandable?: boolean
129
+ expandedRowKeys?: string[]
130
+ defaultExpandedRowKeys?: string[]
131
+ rowExpandable?: (row: T, index: number) => boolean
132
+ expandRowHeight?: number
133
+ striped?: boolean
134
+ showIndex?: boolean
135
+ /** 行号列宽度(px,showIndex 且未显示 edit gutter 时生效) */
136
+ indexWidth?: number
137
+ showHeader?: boolean
138
+ remoteSort?: boolean
139
+ filterText?: string
140
+ filterKeys?: string[]
141
+ columnFilters?: Record<string, string>
142
+ defaultColumnFilters?: Record<string, string>
143
+ groupBy?: RsTableGroupBy<T>
144
+ groupLabel?: (key: string) => string
145
+ sort?: RsTableSortState | null
146
+ defaultSort?: RsTableSortState | null
147
+ multiSort?: boolean
148
+ maxSort?: number
149
+ sorts?: RsTableSortState[]
150
+ defaultSorts?: RsTableSortState[]
151
+ columnDraggable?: boolean
152
+ columnOrder?: string[]
153
+ defaultColumnOrder?: string[]
154
+ rowDraggable?: boolean
155
+ /** 行拖拽触发方式:手柄列或整行 */
156
+ rowDragTrigger?: RsTableRowDragTrigger
157
+ /** reorder=上下插入排序;into=拖入目标行 */
158
+ rowDropMode?: RsTableRowDropMode
159
+ rowDraggableWhen?: (row: T, index: number) => boolean
160
+ rowDropTargetWhen?: (row: T, index: number) => boolean
161
+ canRowDrop?: (dragKeys: string[], dropKey: string) => boolean
162
+ virtual?: boolean
163
+ /** fill 模式下数据行数达到该阈值时自动启用虚拟滚动;0 表示禁用自动启用 */
164
+ virtualAutoThreshold?: number
165
+ /**
166
+ * 列虚拟化:仅渲染横向可视区附近的数据列。
167
+ * true 强制开;false 强制关;默认 `'auto'` 由 virtualColumnsAutoThreshold 判定。
168
+ * (勿用可选 boolean:Vue 会把缺省 prop 收成 false,导致自动模式失效。)
169
+ */
170
+ virtualColumns?: boolean | 'auto'
171
+ /** 数据列数达到该阈值时自动列虚拟化;0 表示禁用自动。默认 40 */
172
+ virtualColumnsAutoThreshold?: number
173
+ /** 列虚拟化左右额外保留列数 */
174
+ virtualColumnOverscan?: number
175
+ height?: number | string
176
+ rowHeight?: number
177
+ overscan?: number
178
+ infinite?: boolean
179
+ hasMore?: boolean
180
+ loadingMore?: boolean
181
+ infiniteDistance?: number
182
+ virtualOnInfinite?: boolean
183
+ resizable?: boolean
184
+ /** auto:列宽随内容/表头自适应;fixed:列宽固定(拖拽后精确锁定) */
185
+ columnLayout?: 'auto' | 'fixed'
186
+ /** 列宽初始值(单向);拖拽后由组件内部维护,不受外部 prop 覆盖 */
187
+ initialColumnWidths?: Record<string, number>
188
+ minColumnWidth?: number
189
+ maxColumnWidth?: number
190
+ /** 右键行时自动选中(selectable 开启时默认 true) */
191
+ selectOnContextmenu?: boolean
192
+ /**
193
+ * 额外右键菜单项(追加在默认「复制单元格 / 复制行」之后)。
194
+ * @param row 右键的行(空白区域为 null)
195
+ * @param selectedRows 当前已选行
196
+ */
197
+ contextMenuItems?: (row: T | null, selectedRows: T[]) => RsContextMenuItem[]
198
+ /** 启用内置右键菜单(复制单元格 / 复制行),默认 true */
199
+ contextMenu?: boolean
200
+ /** 启用表格级共享 cell tooltip(ellipsis / column.tooltip);整表仅一个浮层 */
201
+ cellTooltip?: boolean
202
+ /** 共享 tooltip 悬停延迟(ms),0 为即时显示 */
203
+ cellTooltipDelay?: number
204
+ /** 列头 headerTip 走同一共享浮层,默认 true */
205
+ headerTooltip?: boolean
206
+ /** 启用行内单元格编辑 */
207
+ editable?: boolean
208
+ /** 默认单元格编辑触发方式 */
209
+ editTrigger?: 'click' | 'dblclick'
210
+ /** 行内编辑时显示 Monaco 式 gutter(行号 / 待提交图标) */
211
+ editGutter?: boolean
212
+ /**
213
+ * 编辑 gutter(行号 / 提交)列宽度(px)。
214
+ * 可与 RsCodeEditor 的 gutterWidth 对齐。
215
+ */
216
+ editGutterWidth?: number
217
+ /** 启用当前行高亮(与 checkbox 选区独立) */
218
+ highlightRow?: boolean
219
+ highlightedRowKey?: string
220
+ defaultHighlightedRowKey?: string
221
+ /** 点击行时更新高亮行 */
222
+ highlightRowOnClick?: boolean
223
+ /** 单行手动提交:单元格编辑暂存,点击行状态列提交 */
224
+ rowCommit?: boolean
225
+ /**
226
+ * 额外标记行待提交(如业务侧「新建行」草稿)。
227
+ * 为 true 时 gutter 显示提交/撤销;点击提交触发 rowEditCommit(changes 可为空)。
228
+ */
229
+ rowPending?: (row: T, index: number) => boolean
230
+ /** 显示行状态列(commit / rollback 图标) */
231
+ showRowStatus?: boolean
232
+ /** 允许显式 NULL(Ctrl/Cmd+0);列级 nullable 可覆盖 */
233
+ allowNull?: boolean
234
+ /** NULL 展示文案 */
235
+ nullLabel?: string
236
+ /** 进入编辑时光标策略 */
237
+ editFocusMode?: RsTableCellEditFocusMode
238
+ /** 启用键盘导航(F2 / 方向键 / Tab) */
239
+ editKeyboard?: boolean
240
+ /** 启用 Ctrl+Z / Ctrl+Y 撤销重做 */
241
+ editUndo?: boolean
242
+ /** 撤销栈上限 */
243
+ editUndoLimit?: number
244
+ /** 启用 TSV 粘贴到焦点格 */
245
+ editPaste?: boolean
246
+ /**
247
+ * 多选行时,提交当前列会批量写入所有已选可编辑行。
248
+ * 需同时开启 selectable(checkbox 或 row)。
249
+ */
250
+ editBatch?: boolean
251
+ /**
252
+ * 视图切换标识:变化时重置滚动位置(不销毁实例)。
253
+ * 用于同一表格实例切换不同数据集(如多结果页签),避免 :key 整表重建。
254
+ */
255
+ viewKey?: string | number
256
+ /**
257
+ * 外层 keep-alive 可见性;变为 true 时重测视口并把已保存的 scroll 写回 DOM。
258
+ * 脱离文档后浏览器常把 scrollTop 清零,故须用内部状态恢复,而非从 DOM 回读。
259
+ * 父级 Session 经 onActivated/onDeactivated 传入(比单靠本组件 onActivated 更稳)。
260
+ */
261
+ layoutActive?: boolean
262
+ }>(),
263
+ {
264
+ loading: false,
265
+ bordered: true,
266
+ columnBordered: false,
267
+ rounded: false,
268
+ fill: false,
269
+ compact: false,
270
+ size: 'md',
271
+ selectable: false,
272
+ selectionType: 'checkbox',
273
+ expandable: false,
274
+ defaultExpandedRowKeys: () => [],
275
+ striped: false,
276
+ showIndex: false,
277
+ indexWidth: 56,
278
+ showHeader: true,
279
+ remoteSort: false,
280
+ defaultSelectedRowKeys: () => [],
281
+ defaultSorts: () => [],
282
+ defaultColumnOrder: () => [],
283
+ multiSort: false,
284
+ maxSort: 3,
285
+ columnDraggable: false,
286
+ rowDraggable: false,
287
+ rowDragTrigger: 'handle',
288
+ rowDropMode: 'reorder',
289
+ overscan: 4,
290
+ virtualColumnOverscan: 2,
291
+ infiniteDistance: 80,
292
+ virtualOnInfinite: true,
293
+ virtualAutoThreshold: 50,
294
+ virtualColumns: 'auto',
295
+ virtualColumnsAutoThreshold: 40,
296
+ resizable: false,
297
+ columnLayout: 'auto',
298
+ minColumnWidth: 48,
299
+ maxColumnWidth: 640,
300
+ cellTooltip: true,
301
+ cellTooltipDelay: 300,
302
+ headerTooltip: true,
303
+ editable: false,
304
+ editTrigger: 'dblclick',
305
+ editGutter: true,
306
+ editGutterWidth: 32,
307
+ highlightRow: false,
308
+ highlightRowOnClick: true,
309
+ rowCommit: false,
310
+ showRowStatus: true,
311
+ allowNull: true,
312
+ nullLabel: '(NULL)',
313
+ editFocusMode: 'end',
314
+ editKeyboard: true,
315
+ editUndo: true,
316
+ editUndoLimit: 50,
317
+ editPaste: true,
318
+ editBatch: true,
319
+ contextMenu: true,
320
+ },
321
+ )
322
+
323
+ const emit = defineEmits<{
324
+ rowClick: [row: T, index: number]
325
+ rowDblclick: [row: T, index: number]
326
+ /**
327
+ * 单元格双击且未进入编辑时发出(只读查看大字段等)。
328
+ * 与 rowDblclick 同时发出,便于结果网格打开查看弹窗而不影响行级双击。
329
+ */
330
+ cellView: [row: T, column: RsTableColumn<T>, index: number]
331
+ rowContextmenu: [row: T, index: number, event: MouseEvent]
332
+ contextMenuSelect: [key: string, row: T | null, selectedRows: T[]]
333
+ 'update:sort': [value: RsTableSortState | null]
334
+ 'update:sorts': [value: RsTableSortState[]]
335
+ sortsChange: [value: RsTableSortState[]]
336
+ 'update:columnOrder': [value: string[]]
337
+ columnOrderChange: [value: string[]]
338
+ 'update:columnFilters': [value: Record<string, string>]
339
+ columnFiltersChange: [value: Record<string, string>]
340
+ rowDrop: [dragKeys: string[], dropKey: string, position: RsTableRowDropPosition]
341
+ rowDragStart: [dragKeys: string[], event: DragEvent]
342
+ 'update:selectedRowKeys': [keys: string[]]
343
+ 'update:expandedRowKeys': [keys: string[]]
344
+ selectionChange: [keys: string[]]
345
+ expandChange: [keys: string[]]
346
+ loadMore: []
347
+ columnResize: [key: string, width: number]
348
+ 'update:highlightedRowKey': [key: string | undefined]
349
+ highlightChange: [key: string | undefined]
350
+ cellEditStart: [row: T, column: RsTableColumn<T>, index: number]
351
+ /** 大字段等:请求外层弹窗编辑(不进入行内编辑态) */
352
+ cellEditDialog: [row: T, column: RsTableColumn<T>, index: number, draft: string]
353
+ cellEditCommit: [row: T, column: RsTableColumn<T>, index: number, value: unknown, previous: unknown]
354
+ cellEditCancel: [row: T, column: RsTableColumn<T>, index: number]
355
+ cellEditInvalid: [row: T, column: RsTableColumn<T>, index: number, message: string, value: unknown]
356
+ cellEditBatchCommit: [
357
+ column: RsTableColumn<T>,
358
+ changes: Array<{ row: T; index: number; value: unknown; previous: unknown }>,
359
+ ]
360
+ cellEditUndo: [entry: {
361
+ items: Array<{ rowKey: string; colKey: string; rowIndex: number; previous: unknown; next: unknown }>
362
+ }]
363
+ cellEditRedo: [entry: {
364
+ items: Array<{ rowKey: string; colKey: string; rowIndex: number; previous: unknown; next: unknown }>
365
+ }]
366
+ cellEditReject: [row: T, index: number, reason?: string]
367
+ rowEditCommit: [
368
+ row: T,
369
+ index: number,
370
+ changes: Array<{ colKey: string; value: unknown; previous: unknown }>,
371
+ ]
372
+ rowEditRollback: [row: T, index: number]
373
+ }>()
374
+
375
+ const { t } = useRsI18n()
376
+ const slots = useSlots()
377
+
378
+ const sharedTipRef = ref<HTMLElement | null>(null)
379
+ const {
380
+ state: cellTipState,
381
+ onPointerOver: onCellTipOver,
382
+ onPointerOut: onCellTipOut,
383
+ hide: hideCellTooltip,
384
+ } = useTableCellTooltip({
385
+ enabled: () => props.cellTooltip,
386
+ headerEnabled: () => props.headerTooltip,
387
+ delay: () => props.cellTooltipDelay,
388
+ tipRef: sharedTipRef,
389
+ })
390
+
391
+ const showEditGutterColumn = computed(() => props.editable && props.editGutter)
392
+ const showIndexColumn = computed(() => props.showIndex && !showEditGutterColumn.value)
393
+ const showRowStatusColumn = computed(() => props.rowCommit && props.showRowStatus && !showEditGutterColumn.value)
394
+
395
+ const resolvedGutterWidth = computed(() => Math.max(24, props.editGutterWidth || 32))
396
+ const resolvedIndexWidth = computed(() => Math.max(24, props.indexWidth || 56))
397
+
398
+ const tableEdit = useTableEdit({
399
+ enabled: () => props.editable,
400
+ undoLimit: () => props.editUndoLimit,
401
+ })
402
+
403
+ const activeEditCellKey = computed(() => {
404
+ const cell = tableEdit.editingCell.value
405
+ return cell ? `${cell.rowKey}:${cell.colKey}` : ''
406
+ })
407
+
408
+ const activeFocusCellKey = computed(() => {
409
+ const cell = tableEdit.focusCell.value
410
+ return cell ? `${cell.rowKey}:${cell.colKey}` : ''
411
+ })
412
+
413
+ const activeErrorMapSize = computed(() => tableEdit.errorMap.value.size)
414
+ const activeValidatingMapSize = computed(() => tableEdit.validatingMap.value.size)
415
+
416
+ const contextMenuEnabled = computed(() => props.contextMenu !== false)
417
+
418
+ const isHighlightControlled = computed(() => props.highlightedRowKey !== undefined)
419
+ const rowHighlight = useTableRowHighlight({
420
+ enabled: () => props.highlightRow,
421
+ highlightRowOnClick: () => props.highlightRowOnClick,
422
+ isControlled: () => isHighlightControlled.value,
423
+ highlightedRowKey: () => props.highlightedRowKey,
424
+ defaultHighlightedRowKey: () => props.defaultHighlightedRowKey,
425
+ onUpdate: (value) => {
426
+ if (isHighlightControlled.value) emit('update:highlightedRowKey', value)
427
+ emit('highlightChange', value)
428
+ },
429
+ })
430
+
431
+ const internalSort = ref<RsTableSortState | null>(props.defaultSort ?? null)
432
+ const internalSorts = ref<RsTableSortState[]>([...props.defaultSorts])
433
+ const internalColumnOrder = ref<string[]>([...props.defaultColumnOrder])
434
+ const scrollTop = ref(0)
435
+ const scrollLeft = ref(0)
436
+ const internalColumnWidths = ref<Record<string, number>>({})
437
+ const loadMoreLocked = ref(false)
438
+ const internalSelectedRowKeys = ref<string[]>([...props.defaultSelectedRowKeys])
439
+ const internalExpandedRowKeys = ref<string[]>([...props.defaultExpandedRowKeys])
440
+ const dragColumnKey = ref<string | null>(null)
441
+ const dropColumnKey = ref<string | null>(null)
442
+ const rowDragState = createTableRowDragState()
443
+ const { dragRowKeys, dropRowTargetKey, dropRowPosition } = rowDragState
444
+ const showRowDragHandle = computed(
445
+ () => props.rowDraggable && props.rowDragTrigger === 'handle',
446
+ )
447
+
448
+ const resolvedSize = computed(() => resolveTableSize(props.compact, props.size))
449
+
450
+ const isSelectionControlled = computed(() => props.selectedRowKeys !== undefined)
451
+ const selectedRowKeys = computed({
452
+ get: () => (isSelectionControlled.value ? props.selectedRowKeys ?? [] : internalSelectedRowKeys.value),
453
+ set: (value: string[]) => {
454
+ if (isSelectionControlled.value) emit('update:selectedRowKeys', value)
455
+ else internalSelectedRowKeys.value = value
456
+ emit('selectionChange', value)
457
+ },
458
+ })
459
+
460
+ const selectedKeySet = computed(() => new Set(selectedRowKeys.value))
461
+
462
+ const isExpandedControlled = computed(() => props.expandedRowKeys !== undefined)
463
+ const expandedRowKeys = computed({
464
+ get: () => (isExpandedControlled.value ? props.expandedRowKeys ?? [] : internalExpandedRowKeys.value),
465
+ set: (value: string[]) => {
466
+ if (isExpandedControlled.value) emit('update:expandedRowKeys', value)
467
+ else internalExpandedRowKeys.value = value
468
+ emit('expandChange', value)
469
+ },
470
+ })
471
+ const expandedKeySet = computed(() => new Set(expandedRowKeys.value))
472
+
473
+ const virtualScrollEnabled = computed(() => {
474
+ if (props.virtual === true) return true
475
+ if (props.infinite && props.virtualOnInfinite !== false) return true
476
+ if (props.virtual === false) return false
477
+ const threshold = props.virtualAutoThreshold ?? 0
478
+ return Boolean(props.fill && threshold > 0 && props.data.length >= threshold)
479
+ })
480
+
481
+ const isSortControlled = computed(() => props.sort !== undefined)
482
+ const sortState = computed({
483
+ get: () => (isSortControlled.value ? props.sort ?? null : internalSort.value),
484
+ set: (value: RsTableSortState | null) => {
485
+ if (isSortControlled.value) emit('update:sort', value)
486
+ else internalSort.value = value
487
+ },
488
+ })
489
+
490
+ const isSortsControlled = computed(() => props.sorts !== undefined)
491
+ const sortsState = computed({
492
+ get: () => (isSortsControlled.value ? props.sorts ?? [] : internalSorts.value),
493
+ set: (value: RsTableSortState[]) => {
494
+ if (isSortsControlled.value) emit('update:sorts', value)
495
+ else internalSorts.value = value
496
+ emit('sortsChange', value)
497
+ },
498
+ })
499
+
500
+ const isColumnOrderControlled = computed(() => props.columnOrder !== undefined)
501
+ const columnOrderState = computed({
502
+ get: () => {
503
+ if (isColumnOrderControlled.value) {
504
+ return props.columnOrder ?? []
505
+ }
506
+ if (internalColumnOrder.value.length) {
507
+ return internalColumnOrder.value
508
+ }
509
+ return resolveColumnOrder(props.columns)
510
+ },
511
+ set: (value: string[]) => {
512
+ if (isColumnOrderControlled.value) emit('update:columnOrder', value)
513
+ else internalColumnOrder.value = value
514
+ emit('columnOrderChange', value)
515
+ },
516
+ })
517
+
518
+ const isColumnFiltersControlled = computed(() => props.columnFilters !== undefined)
519
+ const internalColumnFilters = ref<Record<string, string>>(props.defaultColumnFilters ?? {})
520
+ const columnFiltersState = computed(() => props.columnFilters ?? internalColumnFilters.value)
521
+
522
+ function updateColumnFilter(key: string, value: string) {
523
+ const next = { ...columnFiltersState.value }
524
+ if (value.trim()) next[key] = value
525
+ else delete next[key]
526
+ if (isColumnFiltersControlled.value) emit('update:columnFilters', next)
527
+ else internalColumnFilters.value = next
528
+ emit('columnFiltersChange', next)
529
+ }
530
+
531
+ function columnFilterValue(key: string): string {
532
+ return columnFiltersState.value[key] ?? ''
533
+ }
534
+
535
+ const displayColumns = computed(() => resolveOrderedColumns(props.columns, columnOrderState.value))
536
+
537
+ const resolvedColumnWidths = computed({
538
+ get: () => internalColumnWidths.value,
539
+ set: (value: Record<string, number>) => {
540
+ internalColumnWidths.value = value
541
+ },
542
+ })
543
+
544
+ /** 列宽已显式定义(fixed / 每列均有 width 或初始值)时需稳定布局,避免松手回弹 */
545
+ const useStableColumnWidths = computed(() => {
546
+ if (!props.resizable) return false
547
+ if (props.columnLayout === 'fixed') return true
548
+ const widths = internalColumnWidths.value
549
+ return displayColumns.value.every((col) => {
550
+ return typeof widths[col.key] === 'number' || col.width !== undefined
551
+ })
552
+ })
553
+
554
+ function syncInternalColumnWidths(columns: typeof props.columns): void {
555
+ const preserved: Record<string, number> = {}
556
+ for (const column of columns) {
557
+ const width = internalColumnWidths.value[column.key]
558
+ if (typeof width === 'number') preserved[column.key] = width
559
+ }
560
+ internalColumnWidths.value = createInitialColumnWidths(
561
+ columns,
562
+ { ...props.initialColumnWidths, ...preserved },
563
+ { forceAll: props.resizable && useStableColumnWidths.value },
564
+ )
565
+ }
566
+
567
+ watch(
568
+ () => props.columns,
569
+ (columns) => {
570
+ syncInternalColumnWidths(columns)
571
+ if (!isColumnOrderControlled.value) {
572
+ internalColumnOrder.value = resolveColumnOrder(columns, internalColumnOrder.value)
573
+ }
574
+ },
575
+ { immediate: true },
576
+ )
577
+
578
+ let viewportResizeObserver: ResizeObserver | null = null
579
+
580
+ /** keep-alive 恢复用:不受 DOM 清零 / 恢复期伪 scroll 事件干扰 */
581
+ let preservedScrollTop = 0
582
+ let preservedScrollLeft = 0
583
+ /** 正在把保存的滚动写回 DOM;期间忽略 scroll 事件,避免被清零覆盖 */
584
+ let restoringScroll = false
585
+ let restoreGeneration = 0
586
+
587
+ function syncMeasuredViewportSize(): void {
588
+ const el = scrollContainerRef.value
589
+ if (!el) return
590
+ const nextH = el.clientHeight
591
+ if (nextH > 0 && nextH !== measuredViewportHeight.value) {
592
+ measuredViewportHeight.value = nextH
593
+ }
594
+ const nextW = el.clientWidth
595
+ if (nextW > 0 && nextW !== measuredViewportWidth.value) {
596
+ measuredViewportWidth.value = nextW
597
+ }
598
+ }
599
+
600
+ /**
601
+ * 失活前快照滚动。
602
+ * DOM 脱离文档后 scrollTop 常为 0——切勿用 0 覆盖 onScroll 已写入的位置。
603
+ */
604
+ function captureScrollFromDom(): void {
605
+ const el = scrollContainerRef.value
606
+ if (el) {
607
+ if (el.scrollTop > 0) scrollTop.value = el.scrollTop
608
+ if (el.scrollLeft > 0) scrollLeft.value = el.scrollLeft
609
+ }
610
+ if (scrollTop.value > 0) preservedScrollTop = scrollTop.value
611
+ if (scrollLeft.value > 0) preservedScrollLeft = scrollLeft.value
612
+ }
613
+
614
+ /** 将内部保存的滚动写回 DOM,并同步 reactive(驱动虚拟切片)。 */
615
+ function restoreScrollToDom(): void {
616
+ const el = scrollContainerRef.value
617
+ if (!el) return
618
+ syncMeasuredViewportSize()
619
+ const top = preservedScrollTop
620
+ const left = preservedScrollLeft
621
+ if (scrollTop.value !== top) scrollTop.value = top
622
+ if (scrollLeft.value !== left) scrollLeft.value = left
623
+ if (el.scrollTop !== top) el.scrollTop = top
624
+ if (el.scrollLeft !== left) el.scrollLeft = left
625
+ }
626
+
627
+ /**
628
+ * 布局稳定后多次写回滚动:keep-alive 切回首帧 clientHeight 常为 0,
629
+ * 需等 nextTick / rAF / 视口测稳后再设 scrollTop,否则会被浏览器钳成 0。
630
+ */
631
+ function scheduleVirtualLayoutSync(): void {
632
+ restoreGeneration += 1
633
+ const gen = restoreGeneration
634
+ restoringScroll = true
635
+ const apply = (): void => {
636
+ if (gen !== restoreGeneration) return
637
+ restoreScrollToDom()
638
+ }
639
+ apply()
640
+ void nextTick(() => {
641
+ apply()
642
+ requestAnimationFrame(() => {
643
+ apply()
644
+ requestAnimationFrame(() => {
645
+ apply()
646
+ if (gen === restoreGeneration) restoringScroll = false
647
+ })
648
+ })
649
+ })
650
+ }
651
+
652
+ function resetScrollPosition(): void {
653
+ preservedScrollTop = 0
654
+ preservedScrollLeft = 0
655
+ scrollTop.value = 0
656
+ scrollLeft.value = 0
657
+ const el = scrollContainerRef.value
658
+ if (el) {
659
+ el.scrollTop = 0
660
+ el.scrollLeft = 0
661
+ }
662
+ scheduleVirtualLayoutSync()
663
+ }
664
+
665
+ const dragColumnOffset = computed(() => (showRowDragHandle.value ? 40 : 0))
666
+ const expandColumnOffset = computed(() => dragColumnOffset.value + (props.expandable ? 40 : 0))
667
+ /** 左侧选择列仅 checkbox / radio 显示;row 模式靠点击行多选 */
668
+ const showSelectColumn = computed(
669
+ () => props.selectable && props.selectionType !== 'row',
670
+ )
671
+ const isRowSelection = computed(() => props.selectable && props.selectionType === 'row')
672
+ const selectColumnOffset = computed(
673
+ () => expandColumnOffset.value + (showSelectColumn.value ? 40 : 0),
674
+ )
675
+ /** 行点击多选的 Shift 锚点 */
676
+ const selectionAnchorKey = ref<string | null>(null)
677
+
678
+ const fixedRowHeight = computed(() => resolveTableRowHeight(resolvedSize.value, props.rowHeight))
679
+ const scrollContainerRef = ref<HTMLElement | null>(null)
680
+ const measuredViewportHeight = ref(0)
681
+ const measuredViewportWidth = ref(0)
682
+ const viewportHeight = computed(() => {
683
+ if (typeof props.height === 'number' && props.height > 0) return props.height
684
+ if (typeof props.height === 'string') {
685
+ const parsed = Number.parseInt(props.height, 10)
686
+ if (Number.isFinite(parsed) && parsed > 0 && !props.height.includes('%')) return parsed
687
+ }
688
+ if (measuredViewportHeight.value > 0) return measuredViewportHeight.value
689
+ const el = scrollContainerRef.value
690
+ if (el && el.clientHeight > 0) return el.clientHeight
691
+ return 320
692
+ })
693
+ const tableMinWidth = computed(() => resolveScrollWidth(props.scrollX))
694
+
695
+ onMounted(() => {
696
+ scheduleVirtualLayoutSync()
697
+ const el = scrollContainerRef.value
698
+ if (!el || typeof ResizeObserver === 'undefined') return
699
+ viewportResizeObserver = new ResizeObserver(() => {
700
+ const prevH = measuredViewportHeight.value
701
+ syncMeasuredViewportSize()
702
+ // keep-alive 切回:视口从 0→实高时再写一次,否则虚拟切片按错误高度算完后滚动会被钳掉
703
+ if (
704
+ prevH === 0 &&
705
+ measuredViewportHeight.value > 0 &&
706
+ (preservedScrollTop > 0 || preservedScrollLeft > 0)
707
+ ) {
708
+ restoringScroll = true
709
+ restoreScrollToDom()
710
+ requestAnimationFrame(() => {
711
+ restoringScroll = false
712
+ })
713
+ }
714
+ })
715
+ viewportResizeObserver.observe(el)
716
+ })
717
+
718
+ onActivated(() => {
719
+ scheduleVirtualLayoutSync()
720
+ })
721
+
722
+ onDeactivated(() => {
723
+ captureScrollFromDom()
724
+ })
725
+
726
+ /** 失活同步快照:须在 DOM scrollTop 被清零前(或依赖 onScroll 已写入的值)。 */
727
+ watch(
728
+ () => props.layoutActive,
729
+ (active, prev) => {
730
+ if (active === false && prev !== false) {
731
+ captureScrollFromDom()
732
+ }
733
+ },
734
+ { flush: 'sync' },
735
+ )
736
+
737
+ /** 激活后再测视口并写回滚动(需等布局稳定)。 */
738
+ watch(
739
+ () => props.layoutActive,
740
+ (active) => {
741
+ if (active) scheduleVirtualLayoutSync()
742
+ },
743
+ { flush: 'post' },
744
+ )
745
+
746
+ watch(
747
+ () => props.viewKey,
748
+ (_next, prev) => {
749
+ if (prev === undefined) return
750
+ resetScrollPosition()
751
+ },
752
+ )
753
+
754
+ /** 前缀列固定宽度(与 CSS 一致),用于 resizable 模式精确计算表格总宽 */
755
+ const PREFIX_COL_WIDTH = {
756
+ drag: 40,
757
+ expand: 40,
758
+ select: 40,
759
+ status: 52,
760
+ } as const
761
+
762
+ /** 拖拽会话中的列宽快照(仅在拖拽开始时写入一次,供 colgroup 初始渲染) */
763
+ const resizePaintWidths = ref<Record<string, number> | null>(null)
764
+ /** 是否处于列宽拖拽中(切换 fixed 布局,移动过程走 DOM 直写避免整表重渲染) */
765
+ const isColumnResizing = ref(false)
766
+ const tableRef = ref<HTMLTableElement | null>(null)
767
+
768
+ /** 拖拽中的实时列宽(非响应式,避免 mousemove 触发整表更新) */
769
+ let activeDragWidths: Record<string, number> | null = null
770
+
771
+ /** 样式计算用:拖拽初始帧用快照,否则用持久化列宽 */
772
+ const effectiveColumnWidths = computed(() => resizePaintWidths.value ?? resolvedColumnWidths.value)
773
+
774
+ const dataColumnWidth = (column: RsTableColumn<T>): number =>
775
+ resolveColumnPixelWidth(column, effectiveColumnWidths.value)
776
+
777
+ /** 左/右冻结列始终实体渲染;中间列参与横向虚拟化 */
778
+ const leftFixedDataColumns = computed(() => displayColumns.value.filter((c) => c.fixed === 'left'))
779
+ const rightFixedDataColumns = computed(() => displayColumns.value.filter((c) => c.fixed === 'right'))
780
+ const fluidDataColumns = computed(() => displayColumns.value.filter((c) => !c.fixed))
781
+
782
+ const virtualColumnsEnabled = computed(() => {
783
+ if (props.virtualColumns === false) return false
784
+ if (isColumnResizing.value) return false
785
+ if (props.virtualColumns === true) return fluidDataColumns.value.length > 0
786
+ // 'auto':按列数阈值
787
+ const threshold = props.virtualColumnsAutoThreshold ?? 0
788
+ return threshold > 0 && fluidDataColumns.value.length >= threshold
789
+ })
790
+
791
+ const fluidLeadingOffset = computed(() => {
792
+ let sum = 0
793
+ if (showRowDragHandle.value) sum += PREFIX_COL_WIDTH.drag
794
+ if (props.expandable) sum += PREFIX_COL_WIDTH.expand
795
+ if (showSelectColumn.value) sum += PREFIX_COL_WIDTH.select
796
+ if (showEditGutterColumn.value) sum += resolvedGutterWidth.value
797
+ else if (showIndexColumn.value) sum += resolvedIndexWidth.value
798
+ if (showRowStatusColumn.value) sum += PREFIX_COL_WIDTH.status
799
+ for (const col of leftFixedDataColumns.value) sum += dataColumnWidth(col)
800
+ return sum
801
+ })
802
+
803
+ const columnVirtualSlice = computed(() => {
804
+ const fluidCount = fluidDataColumns.value.length
805
+ if (!virtualColumnsEnabled.value) {
806
+ return {
807
+ columns: displayColumns.value,
808
+ paddingLeft: 0,
809
+ paddingRight: 0,
810
+ startIndex: 0,
811
+ endIndex: fluidCount,
812
+ fluidCount,
813
+ }
814
+ }
815
+ const viewport = Math.max(measuredViewportWidth.value || 320, 120)
816
+ const midScroll = Math.max(0, scrollLeft.value - fluidLeadingOffset.value)
817
+ const midViewport = Math.max(120, viewport - Math.min(viewport - 40, fluidLeadingOffset.value))
818
+ const sliced = sliceVirtualColumns(fluidDataColumns.value, {
819
+ scrollLeft: midScroll,
820
+ viewportWidth: midViewport,
821
+ getWidth: dataColumnWidth,
822
+ overscan: props.virtualColumnOverscan ?? 2,
823
+ })
824
+ return {
825
+ columns: [
826
+ ...leftFixedDataColumns.value,
827
+ ...sliced.columns,
828
+ ...rightFixedDataColumns.value,
829
+ ],
830
+ paddingLeft: sliced.paddingLeft,
831
+ paddingRight: sliced.paddingRight,
832
+ startIndex: sliced.startIndex,
833
+ endIndex: sliced.endIndex,
834
+ fluidCount,
835
+ }
836
+ })
837
+
838
+ const visibleDataColumns = computed(() => columnVirtualSlice.value.columns)
839
+ const columnPadLeft = computed(() => columnVirtualSlice.value.paddingLeft)
840
+ const columnPadRight = computed(() => columnVirtualSlice.value.paddingRight)
841
+
842
+ const useScrollContainer = computed(
843
+ () => virtualScrollEnabled.value || props.infinite || virtualColumnsEnabled.value,
844
+ )
845
+
846
+ const scrollContainerStyle = computed(() => {
847
+ const style: Record<string, string> = {}
848
+ if (useScrollContainer.value && !props.fill) {
849
+ const resolvedHeight = resolveVirtualListHeight(props.height, 320)
850
+ if (resolvedHeight) {
851
+ style.height = resolvedHeight
852
+ style.maxHeight = resolvedHeight
853
+ }
854
+ }
855
+ if (showEditGutterColumn.value) {
856
+ style['--rs-table-gutter-width'] = `${resolvedGutterWidth.value}px`
857
+ }
858
+ if (showIndexColumn.value) {
859
+ style['--rs-table-index-width'] = `${resolvedIndexWidth.value}px`
860
+ }
861
+ return Object.keys(style).length ? style : undefined
862
+ })
863
+
864
+ /** resizable 且列宽稳定时,或拖拽会话中:表格内容最小宽度 = 各列宽度之和(不含拉满补宽) */
865
+ const resizableTableWidth = computed(() => {
866
+ if (!props.resizable) return undefined
867
+ if (!resizePaintWidths.value && props.columnLayout === 'auto' && !useStableColumnWidths.value) {
868
+ return undefined
869
+ }
870
+ let sum = 0
871
+ if (showRowDragHandle.value) sum += PREFIX_COL_WIDTH.drag
872
+ if (props.expandable) sum += PREFIX_COL_WIDTH.expand
873
+ if (showSelectColumn.value) sum += PREFIX_COL_WIDTH.select
874
+ if (showEditGutterColumn.value) sum += resolvedGutterWidth.value
875
+ else if (showIndexColumn.value) sum += resolvedIndexWidth.value
876
+ const widths = effectiveColumnWidths.value
877
+ for (const col of displayColumns.value) {
878
+ sum += widths[col.key] ?? parseColumnWidth(col.width ?? col.minWidth)
879
+ }
880
+ return `${sum}px`
881
+ })
882
+
883
+ /**
884
+ * 列总宽不足视口时,把剩余像素均分到数据列(前缀列不动)。
885
+ * 避免 fixed + resizable 把 table 锁成「列宽之和」,右侧露出表头灰底。
886
+ */
887
+ const emptyFillExtras: Record<string, number> = Object.freeze({})
888
+ const useFixedColumnLayout = computed(
889
+ () => useStableColumnWidths.value || isColumnResizing.value,
890
+ )
891
+ const columnFillExtras = computed((): Record<string, number> => {
892
+ if (isColumnResizing.value) return emptyFillExtras
893
+ if (!useFixedColumnLayout.value) return emptyFillExtras
894
+ if (virtualColumnsEnabled.value) return emptyFillExtras
895
+ const viewportW = measuredViewportWidth.value
896
+ if (viewportW <= 0) return emptyFillExtras
897
+ const cols = displayColumns.value
898
+ if (cols.length === 0) return emptyFillExtras
899
+
900
+ let sum = measurePrefixColumnWidth()
901
+ if (showRowStatusColumn.value) sum += PREFIX_COL_WIDTH.status
902
+ const widths = effectiveColumnWidths.value
903
+ for (const col of cols) {
904
+ sum += widths[col.key] ?? parseColumnWidth(col.width ?? col.minWidth)
905
+ }
906
+ // 预留 1px,避免边框/亚像素导致偶发横向条
907
+ const excess = Math.floor(viewportW - sum) - 1
908
+ if (excess <= 0) return emptyFillExtras
909
+
910
+ const extras: Record<string, number> = {}
911
+ const base = Math.floor(excess / cols.length)
912
+ let rem = excess - base * cols.length
913
+ for (const col of cols) {
914
+ extras[col.key] = base + (rem > 0 ? 1 : 0)
915
+ if (rem > 0) rem -= 1
916
+ }
917
+ return extras
918
+ })
919
+
920
+ /** 渲染用列宽 = 持久化/拖拽列宽 + 拉满补宽 */
921
+ const renderColumnWidths = computed(() => {
922
+ const extras = columnFillExtras.value
923
+ const base = effectiveColumnWidths.value
924
+ if (extras === emptyFillExtras) return base
925
+ const next: Record<string, number> = { ...base }
926
+ for (const col of displayColumns.value) {
927
+ const extra = extras[col.key]
928
+ if (!extra) continue
929
+ const cur = next[col.key] ?? parseColumnWidth(col.width ?? col.minWidth)
930
+ next[col.key] = cur + extra
931
+ }
932
+ return next
933
+ })
934
+
935
+ const tableInlineStyle = computed(() => {
936
+ if (isColumnResizing.value) {
937
+ return undefined
938
+ }
939
+ if (props.resizable) {
940
+ if (props.columnLayout === 'auto' && !useStableColumnWidths.value) {
941
+ return tableMinWidth.value
942
+ ? { width: '100%', minWidth: tableMinWidth.value }
943
+ : { width: '100%' }
944
+ }
945
+ const width = resizableTableWidth.value
946
+ if (!width) return undefined
947
+ // 列少时 width:100% 拉满容器;列多时靠 minWidth 撑开并横向滚动
948
+ return { width: '100%', minWidth: tableMinWidth.value ?? width }
949
+ }
950
+ return tableMinWidth.value ? { minWidth: tableMinWidth.value } : undefined
951
+ })
952
+
953
+ /** 列最小/显式宽度之和(不含可任意压缩的流体列)。用于判断是否需要横向滚动。 */
954
+ function estimateRequiredTableWidth(): number {
955
+ let sum = measurePrefixColumnWidth()
956
+ if (showRowStatusColumn.value) sum += PREFIX_COL_WIDTH.status
957
+ const widths = effectiveColumnWidths.value
958
+ for (const col of displayColumns.value) {
959
+ const stored = widths[col.key]
960
+ if (typeof stored === 'number') {
961
+ sum += stored
962
+ continue
963
+ }
964
+ if (typeof col.width === 'number') {
965
+ sum += col.width
966
+ continue
967
+ }
968
+ if (col.minWidth !== undefined) {
969
+ sum += typeof col.minWidth === 'number' ? col.minWidth : parseColumnWidth(col.minWidth, 0)
970
+ }
971
+ }
972
+ return sum
973
+ }
974
+
975
+ /** 仅当列宽确需超过容器时开启横向滚动(避免纵向条引发的伪横向条)。 */
976
+ const overflowXEnabled = computed(() => {
977
+ if (virtualColumnsEnabled.value) return true
978
+ if (props.scrollX != null && props.scrollX !== '') return true
979
+ const viewportW = measuredViewportWidth.value
980
+ if (viewportW <= 0) return false
981
+ if (useFixedColumnLayout.value) {
982
+ const raw = resizableTableWidth.value
983
+ const sum = raw ? Number.parseInt(raw, 10) : estimateRequiredTableWidth()
984
+ return Number.isFinite(sum) && sum > viewportW
985
+ }
986
+ return estimateRequiredTableWidth() > viewportW
987
+ })
988
+
989
+ function measurePrefixColumnWidth(): number {
990
+ let sum = 0
991
+ if (showRowDragHandle.value) sum += PREFIX_COL_WIDTH.drag
992
+ if (props.expandable) sum += PREFIX_COL_WIDTH.expand
993
+ if (showSelectColumn.value) sum += PREFIX_COL_WIDTH.select
994
+ if (showEditGutterColumn.value) sum += resolvedGutterWidth.value
995
+ else if (showIndexColumn.value) sum += resolvedIndexWidth.value
996
+ return sum
997
+ }
998
+
999
+ function applyTableColumnWidths(table: HTMLTableElement, widths: Record<string, number>): void {
1000
+ let sum = measurePrefixColumnWidth()
1001
+ for (const column of displayColumns.value) {
1002
+ const col = table.querySelector<HTMLTableColElement>(`colgroup col[data-col-key="${column.key}"]`)
1003
+ const width = widths[column.key] ?? parseColumnWidth(column.width ?? column.minWidth)
1004
+ if (col) col.style.width = `${width}px`
1005
+ const th = table.querySelector<HTMLTableCellElement>(`thead th[data-col-key="${column.key}"]`)
1006
+ if (th) th.style.width = `${width}px`
1007
+ sum += width
1008
+ }
1009
+ table.style.width = `${sum}px`
1010
+ const minWidth = tableMinWidth.value
1011
+ if (minWidth) table.style.minWidth = minWidth
1012
+ }
1013
+
1014
+ function resolvedDataColumnWidth(key: string, fallback?: number | string): number {
1015
+ const stored = renderColumnWidths.value[key]
1016
+ if (typeof stored === 'number') return stored
1017
+ return parseColumnWidth(fallback)
1018
+ }
1019
+ const fixedColumnStyles = computed(() =>
1020
+ resolveFixedColumnStyles(displayColumns.value, renderColumnWidths.value, {
1021
+ selectable: showSelectColumn.value,
1022
+ showIndex: showIndexColumn.value,
1023
+ showEditGutter: showEditGutterColumn.value,
1024
+ gutterWidth: resolvedGutterWidth.value,
1025
+ indexWidth: resolvedIndexWidth.value,
1026
+ expandable: props.expandable,
1027
+ rowDraggable: showRowDragHandle.value,
1028
+ }),
1029
+ )
1030
+
1031
+ const tableEntries = computed(() =>
1032
+ injectExpandRows(
1033
+ buildTableEntries(props.data, displayColumns.value, {
1034
+ sort: sortState.value,
1035
+ sorts: sortsState.value,
1036
+ multiSort: props.multiSort,
1037
+ filterText: props.filterText,
1038
+ filterKeys: props.filterKeys,
1039
+ columnFilters: columnFiltersState.value,
1040
+ groupBy: props.groupBy,
1041
+ groupLabel: props.groupLabel,
1042
+ remoteSort: props.remoteSort,
1043
+ }),
1044
+ expandedKeySet.value,
1045
+ props.rowKey,
1046
+ props.rowExpandable,
1047
+ ),
1048
+ )
1049
+
1050
+ const virtualHeightModel = computed(() =>
1051
+ buildVirtualHeightModel(
1052
+ tableEntries.value,
1053
+ fixedRowHeight.value,
1054
+ undefined,
1055
+ (props.expandRowHeight ?? undefined) as 80 | undefined,
1056
+ ),
1057
+ )
1058
+
1059
+ const virtualSlice = computed(() => {
1060
+ if (!virtualScrollEnabled.value) {
1061
+ return { entries: tableEntries.value, paddingTop: 0, paddingBottom: 0 }
1062
+ }
1063
+ return sliceVirtualHeightModel(
1064
+ virtualHeightModel.value,
1065
+ scrollTop.value,
1066
+ viewportHeight.value,
1067
+ fixedRowHeight.value,
1068
+ props.overscan,
1069
+ )
1070
+ })
1071
+
1072
+ /** DEV:行/列虚拟是否生效 + 可视切片,便于对照 heap 里 `<tr>` / `<td>` 数量 */
1073
+ if (import.meta.env.DEV) {
1074
+ watch(
1075
+ () => {
1076
+ const enabled = virtualScrollEnabled.value
1077
+ const slice = virtualSlice.value
1078
+ const total = tableEntries.value.length
1079
+ const rendered = enabled ? slice.entries.length : total
1080
+ return {
1081
+ enabled,
1082
+ prop: props.virtual,
1083
+ threshold: props.virtualAutoThreshold,
1084
+ fill: props.fill,
1085
+ infinite: props.infinite,
1086
+ rows: props.data.length,
1087
+ entries: total,
1088
+ rendered,
1089
+ rowSlice: enabled
1090
+ ? `pad ${Math.round(slice.paddingTop)}px + ${rendered} + pad ${Math.round(slice.paddingBottom)}px`
1091
+ : 'all',
1092
+ }
1093
+ },
1094
+ (state, prev) => {
1095
+ if (
1096
+ prev &&
1097
+ state.enabled === prev.enabled &&
1098
+ state.rows === prev.rows &&
1099
+ state.rendered === prev.rendered &&
1100
+ state.rowSlice === prev.rowSlice
1101
+ ) {
1102
+ return
1103
+ }
1104
+ console.info('[RsTable:virtual-rows]', state)
1105
+ },
1106
+ { immediate: true },
1107
+ )
1108
+ watch(
1109
+ () => {
1110
+ const slice = columnVirtualSlice.value
1111
+ const enabled = virtualColumnsEnabled.value
1112
+ return {
1113
+ enabled,
1114
+ prop: props.virtualColumns,
1115
+ threshold: props.virtualColumnsAutoThreshold,
1116
+ fluidCols: slice.fluidCount,
1117
+ renderedCols: slice.columns.length,
1118
+ fluidSlice: enabled ? `${slice.startIndex}..${slice.endIndex}` : 'all',
1119
+ padLeft: Math.round(slice.paddingLeft),
1120
+ padRight: Math.round(slice.paddingRight),
1121
+ resizing: isColumnResizing.value,
1122
+ }
1123
+ },
1124
+ (state, prev) => {
1125
+ if (
1126
+ prev &&
1127
+ state.enabled === prev.enabled &&
1128
+ state.fluidCols === prev.fluidCols &&
1129
+ state.fluidSlice === prev.fluidSlice &&
1130
+ state.renderedCols === prev.renderedCols &&
1131
+ state.resizing === prev.resizing
1132
+ ) {
1133
+ return
1134
+ }
1135
+ console.info('[RsTable:virtual-cols]', state)
1136
+ },
1137
+ { immediate: true },
1138
+ )
1139
+ }
1140
+
1141
+ const visibleEntries = computed(() => virtualSlice.value.entries)
1142
+ const hasData = computed(() => dataRows.value.length > 0)
1143
+
1144
+ /**
1145
+ * 单次遍历 tableEntries,产出 dataRows、selectableRowKeys、rowKeyByIndex。
1146
+ * - 仅依赖 tableEntries(排序/过滤/数据变化),不依赖 selectedKeySet,
1147
+ * 避免选中操作触发不必要的全量重遍历。
1148
+ * - rowKeyByIndex:rowIndex → rowKey,供模板和内部函数 O(1) 查询,
1149
+ * 消除每行 8+ 次重复 resolveRowKey 调用。
1150
+ */
1151
+ const rowDerivedState = computed(() => {
1152
+ const rows: Extract<RsTableRowEntry<T>, { type: 'row' }>[] = []
1153
+ const selKeys: string[] = []
1154
+ const keyByIndex = new Map<number, string>()
1155
+ for (const entry of tableEntries.value) {
1156
+ if (entry.type !== 'row') continue
1157
+ rows.push(entry)
1158
+ const key = resolveRowKey(entry.row, entry.rowIndex, props.rowKey)
1159
+ keyByIndex.set(entry.rowIndex, key)
1160
+ const disabled = isTableRowDisabled(entry.row)
1161
+ if (!disabled && (props.rowSelectable ? props.rowSelectable(entry.row, entry.rowIndex) : true)) {
1162
+ selKeys.push(key)
1163
+ }
1164
+ }
1165
+ return { rows, selKeys, keyByIndex }
1166
+ })
1167
+
1168
+ const dataRows = computed(() => rowDerivedState.value.rows)
1169
+ const selectableRowKeys = computed(() => rowDerivedState.value.selKeys)
1170
+ /** rowIndex → rowKey 缓存,消除模板热路径重复 resolveRowKey */
1171
+ const rowKeyByIndex = computed(() => rowDerivedState.value.keyByIndex)
1172
+
1173
+ /**
1174
+ * 当前已选行对象(供 contextMenuItems 使用)。
1175
+ * 依赖 dataRows + selectedKeySet,选中变化时单独重算,
1176
+ * 不触发 dataRows/selectableRowKeys 的重遍历。
1177
+ */
1178
+ const selectedRows = computed<T[]>(() => {
1179
+ const keySet = selectedKeySet.value
1180
+ const keyMap = rowKeyByIndex.value
1181
+ return dataRows.value
1182
+ .filter((entry) => keySet.has(keyMap.get(entry.rowIndex) ?? ''))
1183
+ .map((entry) => entry.row)
1184
+ })
1185
+
1186
+ const selectAllState = computed(() => resolveSelectAllState(selectedRowKeys.value, selectableRowKeys.value))
1187
+
1188
+ const bodyColspan = computed(() => {
1189
+ let count = visibleDataColumns.value.length
1190
+ if (columnPadLeft.value > 0) count += 1
1191
+ if (columnPadRight.value > 0) count += 1
1192
+ if (showRowDragHandle.value) count += 1
1193
+ if (props.expandable) count += 1
1194
+ if (showSelectColumn.value) count += 1
1195
+ if (showEditGutterColumn.value || showIndexColumn.value) count += 1
1196
+ if (showRowStatusColumn.value) count += 1
1197
+ return count
1198
+ })
1199
+
1200
+ const isRadioSelection = computed(() => props.selectionType === 'radio')
1201
+
1202
+ let resizeState: {
1203
+ key: string
1204
+ startX: number
1205
+ startWidth: number
1206
+ currentWidth: number
1207
+ /** 拖拽开始时从 DOM 批量测量的各列宽度 */
1208
+ domSnapshot: Record<string, number>
1209
+ /** auto 模式:拖拽前已持久化的列宽 */
1210
+ persistedWidths: Record<string, number>
1211
+ } | null = null
1212
+ let resizeRafId = 0
1213
+ let latestResizeClientX = 0
1214
+ let resizeMoved = false
1215
+
1216
+ /** 列样式 Map:按 column.key 缓存,避免模板热路径每次创建新对象 */
1217
+ const columnStyleMap = computed<Map<string, Record<string, string> | undefined>>(() => {
1218
+ const map = new Map<string, Record<string, string> | undefined>()
1219
+ for (const col of displayColumns.value) {
1220
+ const base = resolveColumnStyle(col, renderColumnWidths.value) ?? {}
1221
+ const fixed = fixedCellStyle(fixedColumnStyles.value.get(col.key))
1222
+ let merged: Record<string, string> | undefined
1223
+ if (fixed) merged = { ...base, ...fixed }
1224
+ else if (Object.keys(base).length) merged = base
1225
+ map.set(col.key, merged)
1226
+ }
1227
+ return map
1228
+ })
1229
+
1230
+ /** 表头列样式:固定列需同时 sticky top + left/right */
1231
+ const columnHeaderStyleMap = computed<Map<string, Record<string, string> | undefined>>(() => {
1232
+ const map = new Map<string, Record<string, string> | undefined>()
1233
+ for (const col of displayColumns.value) {
1234
+ const base = resolveColumnStyle(col, renderColumnWidths.value) ?? {}
1235
+ const fixed = fixedCellStyle(fixedColumnStyles.value.get(col.key), { header: true })
1236
+ let merged: Record<string, string> | undefined
1237
+ if (fixed) merged = { ...base, ...fixed }
1238
+ else if (Object.keys(base).length) merged = base
1239
+ map.set(col.key, merged)
1240
+ }
1241
+ return map
1242
+ })
1243
+
1244
+ /** td 静态 class Map:列配置不变时复用,消除每行每列重复拼接 */
1245
+ const columnTdClassMap = computed<Map<string, string[]>>(() => {
1246
+ const map = new Map<string, string[]>()
1247
+ for (const col of displayColumns.value) {
1248
+ const classes: string[] = [`rs-table__cell--${col.align ?? 'left'}`]
1249
+ if (col.ellipsis) classes.push('rs-table__td--ellipsis')
1250
+ if (col.fixed) classes.push('rs-table__cell--fixed')
1251
+ map.set(col.key, classes)
1252
+ }
1253
+ return map
1254
+ })
1255
+
1256
+ /** 4 个固定前缀列的 style(拖拽/展开/选择/序号),避免每行重复计算 */
1257
+ const dragLeadStyle = computed(() => fixedCellStyle({ fixed: 'left', left: 0 }) ?? {})
1258
+ const expandLeadStyle = computed(() => fixedCellStyle({ fixed: 'left', left: dragColumnOffset.value }) ?? {})
1259
+ const selectLeadStyle = computed(() => fixedCellStyle({ fixed: 'left', left: expandColumnOffset.value }) ?? {})
1260
+ const indexLeadStyle = computed(() => fixedCellStyle({ fixed: 'left', left: selectColumnOffset.value }) ?? {})
1261
+ const gutterLeadStyle = computed(() => fixedCellStyle({ fixed: 'left', left: selectColumnOffset.value }) ?? {})
1262
+ const dragLeadHeaderStyle = computed(() => fixedCellStyle({ fixed: 'left', left: 0 }, { header: true }) ?? {})
1263
+ const expandLeadHeaderStyle = computed(() => fixedCellStyle({ fixed: 'left', left: dragColumnOffset.value }, { header: true }) ?? {})
1264
+ const selectLeadHeaderStyle = computed(() => fixedCellStyle({ fixed: 'left', left: expandColumnOffset.value }, { header: true }) ?? {})
1265
+ const indexLeadHeaderStyle = computed(() => fixedCellStyle({ fixed: 'left', left: selectColumnOffset.value }, { header: true }) ?? {})
1266
+ const gutterLeadHeaderStyle = computed(() => fixedCellStyle({ fixed: 'left', left: selectColumnOffset.value }, { header: true }) ?? {})
1267
+
1268
+ function sortOrderFor(key: string): RsTableSortOrder {
1269
+ if (props.multiSort) return getSortOrderForKey(sortsState.value, key)
1270
+ return sortState.value?.key === key ? sortState.value.order : null
1271
+ }
1272
+
1273
+ function sortPriorityFor(key: string): number {
1274
+ if (!props.multiSort) return 0
1275
+ return getSortPriorityForKey(sortsState.value, key)
1276
+ }
1277
+
1278
+ function sortIconName(key: string): string {
1279
+ const order = sortOrderFor(key)
1280
+ if (order === 'asc') return 'arrow-up'
1281
+ if (order === 'desc') return 'arrow-down'
1282
+ return 'arrow-up-down'
1283
+ }
1284
+
1285
+ function onHeaderClick(column: RsTableColumn<T>): void {
1286
+ if (!column.sortable) return
1287
+ if (props.multiSort) {
1288
+ sortsState.value = toggleMultiSortState(sortsState.value, column.key, props.maxSort)
1289
+ return
1290
+ }
1291
+ sortState.value = toggleSortState(sortState.value, column.key)
1292
+ }
1293
+
1294
+ function onColumnDragStart(key: string, event: DragEvent): void {
1295
+ if (!props.columnDraggable) return
1296
+ dragColumnKey.value = key
1297
+ dropColumnKey.value = null
1298
+ if (!event.dataTransfer) return
1299
+ event.dataTransfer.setData('text/plain', key)
1300
+ event.dataTransfer.effectAllowed = 'move'
1301
+ }
1302
+
1303
+ function onColumnDragOver(key: string, event: DragEvent): void {
1304
+ if (!props.columnDraggable || !dragColumnKey.value) return
1305
+ if (dragColumnKey.value === key) {
1306
+ if (dropColumnKey.value !== null) dropColumnKey.value = null
1307
+ return
1308
+ }
1309
+ event.preventDefault()
1310
+ if (dropColumnKey.value !== key) dropColumnKey.value = key
1311
+ }
1312
+
1313
+ function onColumnDrop(key: string, event: DragEvent): void {
1314
+ if (!props.columnDraggable || !dragColumnKey.value) return
1315
+ event.preventDefault()
1316
+ if (dragColumnKey.value === key) return
1317
+ columnOrderState.value = reorderColumnKeys(columnOrderState.value, dragColumnKey.value, key)
1318
+ dragColumnKey.value = null
1319
+ dropColumnKey.value = null
1320
+ }
1321
+
1322
+ function onColumnDragEnd(): void {
1323
+ dragColumnKey.value = null
1324
+ dropColumnKey.value = null
1325
+ }
1326
+
1327
+ function onRowClick(entry: RsTableRowEntry<T>, event?: MouseEvent): void {
1328
+ if (entry.type !== 'row') return
1329
+ const rowKey = rowKeyFor(entry)
1330
+ if (isRowSelection.value && canSelectRow(entry) && !tableEdit.editingCell.value) {
1331
+ const toggle = Boolean(event && (event.ctrlKey || event.metaKey))
1332
+ const range = Boolean(event?.shiftKey)
1333
+ selectedRowKeys.value = selectRowKeysByClick(selectedRowKeys.value, rowKey, {
1334
+ toggle,
1335
+ range,
1336
+ orderedKeys: selectableRowKeys.value,
1337
+ anchorKey: selectionAnchorKey.value,
1338
+ })
1339
+ if (!range) selectionAnchorKey.value = rowKey
1340
+ // 清掉浏览器划选,避免与行选区叠成「拖蓝选中文字」观感
1341
+ if (typeof window !== 'undefined') {
1342
+ window.getSelection()?.removeAllRanges()
1343
+ }
1344
+ }
1345
+ rowHighlight.applyRowClickHighlight(rowKey)
1346
+ emit('rowClick', entry.row, entry.rowIndex)
1347
+ }
1348
+
1349
+ /** 行选模式下 Shift/Ctrl 按下时禁止开始原生文本选区 */
1350
+ function onRowSelectMouseDown(event: MouseEvent): void {
1351
+ if (!isRowSelection.value) return
1352
+ if (event.shiftKey || event.ctrlKey || event.metaKey) {
1353
+ event.preventDefault()
1354
+ }
1355
+ }
1356
+
1357
+ function onRowDblclick(entry: RsTableRowEntry<T>, _event?: MouseEvent): void {
1358
+ if (entry.type !== 'row') return
1359
+ emit('rowDblclick', entry.row, entry.rowIndex)
1360
+ }
1361
+
1362
+ /** 当前右键行(null = 空白区域) */
1363
+ const ctxRow = ref<T | null>(null)
1364
+ const ctxRowIndex = ref<number | null>(null)
1365
+ const ctxColKey = ref<string | null>(null)
1366
+ const ctxMenuItems = ref<RsContextMenuItem[]>([])
1367
+
1368
+ function refreshCtxMenuItems(): void {
1369
+ if (!contextMenuEnabled.value) {
1370
+ ctxMenuItems.value = []
1371
+ return
1372
+ }
1373
+
1374
+ const items = buildDefaultTableContextMenuItems({
1375
+ copyCellLabel: t('table.copyCell'),
1376
+ copyRowLabel: t('table.copyRow'),
1377
+ hasRow: ctxRow.value != null,
1378
+ hasCell: ctxColKey.value != null,
1379
+ })
1380
+
1381
+ const custom = props.contextMenuItems?.(ctxRow.value as T | null, selectedRows.value) ?? []
1382
+ if (custom.length > 0) {
1383
+ if (items.length > 0) {
1384
+ items.push({ key: '__ctx-sep', label: '', separator: true })
1385
+ }
1386
+ items.push(...custom)
1387
+ }
1388
+
1389
+ ctxMenuItems.value = items
1390
+ }
1391
+
1392
+ async function copyCtxCell(): Promise<void> {
1393
+ if (!ctxRow.value || ctxRowIndex.value == null || !ctxColKey.value) return
1394
+ const column = displayColumns.value.find((item) => item.key === ctxColKey.value)
1395
+ if (!column) return
1396
+ const key = resolveRowKey(ctxRow.value, ctxRowIndex.value, props.rowKey)
1397
+ const text = resolveTableCellCopyText(
1398
+ ctxRow.value,
1399
+ column,
1400
+ ctxRowIndex.value,
1401
+ tableEdit.getDraft(key, column.key),
1402
+ )
1403
+ await copyTextToClipboard(text)
1404
+ }
1405
+
1406
+ async function copyCtxRow(): Promise<void> {
1407
+ if (!ctxRow.value || ctxRowIndex.value == null) return
1408
+ const key = resolveRowKey(ctxRow.value, ctxRowIndex.value, props.rowKey)
1409
+ const text = resolveTableRowCopyText(
1410
+ ctxRow.value,
1411
+ ctxRowIndex.value,
1412
+ displayColumns.value,
1413
+ tableEdit.getDraft,
1414
+ key,
1415
+ )
1416
+ await copyTextToClipboard(text)
1417
+ }
1418
+
1419
+ async function onCtxMenuSelect(key: string): Promise<void> {
1420
+ if (key === TABLE_CTX_COPY_CELL) {
1421
+ await copyCtxCell()
1422
+ emit('contextMenuSelect', key, ctxRow.value as T | null, selectedRows.value)
1423
+ return
1424
+ }
1425
+ if (key === TABLE_CTX_COPY_ROW) {
1426
+ await copyCtxRow()
1427
+ emit('contextMenuSelect', key, ctxRow.value as T | null, selectedRows.value)
1428
+ return
1429
+ }
1430
+ emit('contextMenuSelect', key, ctxRow.value as T | null, selectedRows.value)
1431
+ }
1432
+
1433
+ function prepareRowContextmenu(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): void {
1434
+ const shouldSelect = props.selectOnContextmenu ?? props.selectable
1435
+ if (shouldSelect && canSelectRow(entry) && !isRowSelected(entry)) {
1436
+ selectedRowKeys.value = selectRowKeys(selectedRowKeys.value, rowKeyFor(entry), props.selectionType)
1437
+ }
1438
+ if (contextMenuEnabled.value) {
1439
+ ctxRow.value = entry.row
1440
+ ctxRowIndex.value = entry.rowIndex
1441
+ }
1442
+ }
1443
+
1444
+ function onTableContextmenu(event: MouseEvent): void {
1445
+ if (!contextMenuEnabled.value) return
1446
+ const tr = (event.target as HTMLElement).closest('tr.rs-table__row')
1447
+ if (!tr) {
1448
+ ctxRow.value = null
1449
+ ctxRowIndex.value = null
1450
+ ctxColKey.value = null
1451
+ refreshCtxMenuItems()
1452
+ }
1453
+ }
1454
+
1455
+ function onRowContextmenu(entry: RsTableRowEntry<T>, event: MouseEvent): void {
1456
+ if (entry.type !== 'row') return
1457
+ if ((event.target as HTMLElement).closest('.rs-table__td--data')) return
1458
+ prepareRowContextmenu(entry)
1459
+ ctxColKey.value = null
1460
+ refreshCtxMenuItems()
1461
+ emit('rowContextmenu', entry.row, entry.rowIndex, event)
1462
+ }
1463
+
1464
+ function onCellContextmenu(entry: RsTableRowEntry<T>, colKey: string, event: MouseEvent): void {
1465
+ if (entry.type !== 'row') return
1466
+ prepareRowContextmenu(entry)
1467
+ ctxColKey.value = colKey
1468
+ refreshCtxMenuItems()
1469
+ emit('rowContextmenu', entry.row, entry.rowIndex, event)
1470
+ }
1471
+
1472
+ /** 从缓存 Map 中 O(1) 取行 key,消除重复 resolveRowKey 调用 */
1473
+ function rowKeyFor(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): string {
1474
+ return rowKeyByIndex.value.get(entry.rowIndex) ?? resolveRowKey(entry.row, entry.rowIndex, props.rowKey)
1475
+ }
1476
+
1477
+ const rowDrag = createTableRowDragHandlers<T>({
1478
+ state: rowDragState,
1479
+ getRowDraggable: () => props.rowDraggable,
1480
+ getRowDragTrigger: () => props.rowDragTrigger,
1481
+ getRowDropMode: () => props.rowDropMode,
1482
+ rowDraggableWhen: props.rowDraggableWhen,
1483
+ rowDropTargetWhen: props.rowDropTargetWhen,
1484
+ canRowDrop: props.canRowDrop,
1485
+ rowKeyFor: (row, index) => resolveRowKey(row, index, props.rowKey),
1486
+ isRowDisabled: isTableRowDisabled,
1487
+ getSelectedKeys: () => selectedRowKeys.value,
1488
+ onDragStart: (dragKeys, event) => emit('rowDragStart', dragKeys, event),
1489
+ onDrop: (dragKeys, dropKey, position) => emit('rowDrop', dragKeys, dropKey, position),
1490
+ })
1491
+
1492
+ function onRowDragStart(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>, event: DragEvent): void {
1493
+ rowDrag.onRowDragStart(entry.row, entry.rowIndex, event)
1494
+ }
1495
+
1496
+ function onRowDragOver(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>, event: DragEvent): void {
1497
+ rowDrag.onRowDragOver(entry.row, entry.rowIndex, event)
1498
+ }
1499
+
1500
+ function onRowDragLeave(event: DragEvent): void {
1501
+ rowDrag.onRowDragLeave(event)
1502
+ }
1503
+
1504
+ function onRowDrop(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>, event: DragEvent): void {
1505
+ rowDrag.onRowDrop(entry.row, entry.rowIndex, event)
1506
+ }
1507
+
1508
+ function onRowDragEnd(): void {
1509
+ rowDrag.onRowDragEnd()
1510
+ }
1511
+
1512
+ function isRowDropTarget(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): boolean {
1513
+ return rowDrag.isRowDropTarget(rowKeyFor(entry))
1514
+ }
1515
+
1516
+ function isRowDragging(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): boolean {
1517
+ return rowDrag.isRowDragging(rowKeyFor(entry))
1518
+ }
1519
+
1520
+ function canDragRow(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): boolean {
1521
+ return rowDrag.canDragRow(entry.row, entry.rowIndex)
1522
+ }
1523
+
1524
+ function isRowDragByRow(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): boolean {
1525
+ return props.rowDraggable && props.rowDragTrigger === 'row' && canDragRow(entry)
1526
+ }
1527
+
1528
+ function isRowSelected(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): boolean {
1529
+ return selectedKeySet.value.has(rowKeyFor(entry))
1530
+ }
1531
+
1532
+ function canSelectRow(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): boolean {
1533
+ if (isTableRowDisabled(entry.row)) return false
1534
+ return props.rowSelectable ? props.rowSelectable(entry.row, entry.rowIndex) : true
1535
+ }
1536
+
1537
+ function onToggleRow(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): void {
1538
+ if (!canSelectRow(entry)) return
1539
+ selectedRowKeys.value = selectRowKeys(selectedRowKeys.value, rowKeyFor(entry), props.selectionType)
1540
+ }
1541
+
1542
+ function canExpandRow(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): boolean {
1543
+ if (!props.expandable) return false
1544
+ if (props.rowExpandable) return props.rowExpandable(entry.row, entry.rowIndex)
1545
+ return true
1546
+ }
1547
+
1548
+ function isRowExpanded(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): boolean {
1549
+ return expandedKeySet.value.has(rowKeyFor(entry))
1550
+ }
1551
+
1552
+ function onToggleExpand(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): void {
1553
+ if (!canExpandRow(entry)) return
1554
+ expandedRowKeys.value = toggleExpandedRowKeys(expandedRowKeys.value, rowKeyFor(entry))
1555
+ }
1556
+
1557
+ function onToggleSelectAll(): void {
1558
+ const select = selectAllState.value !== 'checked'
1559
+ selectedRowKeys.value = toggleSelectAll(selectedRowKeys.value, selectableRowKeys.value, select)
1560
+ }
1561
+
1562
+ function cellTooltipEnabled(column: RsTableColumn<T>, rowIndex: number): boolean {
1563
+ return props.cellTooltip && columnUsesSharedTooltip(column) && !slots[column.key]
1564
+ }
1565
+
1566
+ function cellTooltipMode(column: RsTableColumn<T>, rowIndex: number): string | undefined {
1567
+ if (!cellTooltipEnabled(column, rowIndex)) return undefined
1568
+ return resolveCellTooltipMode(column) ?? undefined
1569
+ }
1570
+
1571
+ function cellTooltipText(column: RsTableColumn<T>, row: T, rowIndex: number): string | undefined {
1572
+ if (!cellTooltipEnabled(column, rowIndex)) return undefined
1573
+ const text = resolveCellTooltipText(column, row, rowIndex)
1574
+ return text || undefined
1575
+ }
1576
+
1577
+ function cellTooltipFallbackTitle(column: RsTableColumn<T>, row: T, rowIndex: number): string | undefined {
1578
+ if (props.cellTooltip || !column.ellipsis || slots[column.key]) return undefined
1579
+ const text = resolveCellTooltipText(column, row, rowIndex)
1580
+ return text || undefined
1581
+ }
1582
+
1583
+ function hasColumnSlot(key: string): boolean {
1584
+ return Boolean(slots[key])
1585
+ }
1586
+
1587
+ function hasEditSlot(key: string): boolean {
1588
+ return Boolean(slots[`edit-${key}`])
1589
+ }
1590
+
1591
+ function resolveColumnByKey(colKey: string): RsTableColumn<T> | undefined {
1592
+ return displayColumns.value.find((column) => column.key === colKey)
1593
+ }
1594
+
1595
+ function findRowEntryByKey(rowKey: string): Extract<RsTableRowEntry<T>, { type: 'row' }> | undefined {
1596
+ return dataRows.value.find((item) => rowKeyByIndex.value.get(item.rowIndex) === rowKey)
1597
+ }
1598
+
1599
+ function buildEditableCellList() {
1600
+ const result: Array<{ rowKey: string; colKey: string; rowIndex: number }> = []
1601
+ for (const entry of dataRows.value) {
1602
+ const rowKey = rowKeyFor(entry)
1603
+ for (const column of displayColumns.value) {
1604
+ if (!isColumnEditable(column, entry.row, entry.rowIndex, props.editable)) continue
1605
+ result.push({ rowKey, colKey: column.key, rowIndex: entry.rowIndex })
1606
+ }
1607
+ }
1608
+ return result
1609
+ }
1610
+
1611
+ function onCellStartEdit(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>, colKey: string): void {
1612
+ const column = resolveColumnByKey(colKey)
1613
+ if (!column) return
1614
+ if (isBooleanToggleColumn(column)) return
1615
+ if (!isColumnEditable(column, entry.row, entry.rowIndex, props.editable)) return
1616
+ const key = rowKeyFor(entry)
1617
+ const initialText = resolveCellEditText(entry.row, column, entry.rowIndex, tableEdit.getDraft(key, colKey))
1618
+ const editorOpts = resolveColumnEditorOptions(column, entry.row, entry.rowIndex)
1619
+ if (editorOpts.presentation === 'dialog') {
1620
+ emit('cellEditDialog', entry.row, column, entry.rowIndex, initialText)
1621
+ return
1622
+ }
1623
+ tableEdit.startEdit({ rowKey: key, colKey, rowIndex: entry.rowIndex }, initialText)
1624
+ emit('cellEditStart', entry.row, column, entry.rowIndex)
1625
+ }
1626
+
1627
+ function onCellUpdateDraft(_entry: Extract<RsTableRowEntry<T>, { type: 'row' }>, _colKey: string, value: string): void {
1628
+ tableEdit.updateDraft(value)
1629
+ }
1630
+
1631
+ function onCellCancelEdit(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>, colKey: string): void {
1632
+ const column = resolveColumnByKey(colKey)
1633
+ tableEdit.cancelEdit()
1634
+ if (column) emit('cellEditCancel', entry.row, column, entry.rowIndex)
1635
+ }
1636
+
1637
+ async function onCellCommitEdit(
1638
+ entry: Extract<RsTableRowEntry<T>, { type: 'row' }>,
1639
+ colKey: string,
1640
+ rawText: string,
1641
+ options?: { navigate?: RsTableCellNavigateDirection; skipUndo?: boolean; skipBatch?: boolean },
1642
+ ): Promise<boolean> {
1643
+ const column = resolveColumnByKey(colKey)
1644
+ if (!column) return false
1645
+ const allowNull = isColumnNullable(column, props.allowNull)
1646
+ const parsed = parseCellEditInput(rawText, entry.row, column, entry.rowIndex, { allowNull })
1647
+ const rowKey = rowKeyFor(entry)
1648
+
1649
+ tableEdit.setValidating(rowKey, colKey, true)
1650
+ let error: string | null = null
1651
+ try {
1652
+ error = await validateCellValueAsync(parsed, entry.row, column, entry.rowIndex)
1653
+ } finally {
1654
+ tableEdit.setValidating(rowKey, colKey, false)
1655
+ }
1656
+
1657
+ if (error) {
1658
+ tableEdit.updateDraft(rawText)
1659
+ tableEdit.setCellError(rowKey, colKey, error)
1660
+ emit('cellEditInvalid', entry.row, column, entry.rowIndex, error, parsed)
1661
+ return false
1662
+ }
1663
+ tableEdit.clearCellError(rowKey, colKey)
1664
+
1665
+ const batchEnabled =
1666
+ props.editBatch &&
1667
+ props.selectable &&
1668
+ props.selectionType !== 'radio' &&
1669
+ !props.rowCommit &&
1670
+ !options?.skipBatch
1671
+
1672
+ const targets = batchEnabled
1673
+ ? listBatchColumnTargets({
1674
+ rows: dataRows.value.map((item) => ({
1675
+ row: item.row,
1676
+ rowIndex: item.rowIndex,
1677
+ rowKey: rowKeyFor(item),
1678
+ })),
1679
+ column,
1680
+ tableEditable: props.editable,
1681
+ selectedKeys: selectedRowKeys.value,
1682
+ anchorRowKey: rowKey,
1683
+ })
1684
+ : [{ row: entry.row, rowIndex: entry.rowIndex, rowKey }]
1685
+
1686
+ if (props.rowCommit) {
1687
+ const previous = resolveColumnRawValue(entry.row, column, entry.rowIndex)
1688
+ if (!isCellValueChanged(previous, parsed)) {
1689
+ tableEdit.unstageCell(rowKey, colKey)
1690
+ tableEdit.cancelEdit()
1691
+ if (options?.navigate) moveEditFocus(entry, colKey, options.navigate, false)
1692
+ return true
1693
+ }
1694
+ tableEdit.stageCell({
1695
+ rowKey,
1696
+ colKey,
1697
+ rowIndex: entry.rowIndex,
1698
+ draft: rawText,
1699
+ original: previous,
1700
+ })
1701
+ if (options?.navigate) moveEditFocus(entry, colKey, options.navigate, true)
1702
+ return true
1703
+ }
1704
+
1705
+ tableEdit.cancelEdit()
1706
+
1707
+ const undoItems: Array<{
1708
+ rowKey: string
1709
+ colKey: string
1710
+ rowIndex: number
1711
+ previous: unknown
1712
+ next: unknown
1713
+ }> = []
1714
+ const batchChanges: Array<{ row: T; index: number; value: unknown; previous: unknown }> = []
1715
+
1716
+ for (const target of targets) {
1717
+ const previous = resolveColumnRawValue(target.row, column, target.rowIndex)
1718
+ if (!isCellValueChanged(previous, parsed)) continue
1719
+ undoItems.push({
1720
+ rowKey: target.rowKey,
1721
+ colKey,
1722
+ rowIndex: target.rowIndex,
1723
+ previous,
1724
+ next: parsed,
1725
+ })
1726
+ batchChanges.push({
1727
+ row: target.row,
1728
+ index: target.rowIndex,
1729
+ value: parsed,
1730
+ previous,
1731
+ })
1732
+ if (target.rowKey === rowKey) {
1733
+ emit('cellEditCommit', target.row, column, target.rowIndex, parsed, previous)
1734
+ } else {
1735
+ emit('cellEditCommit', target.row, column, target.rowIndex, parsed, previous)
1736
+ }
1737
+ }
1738
+
1739
+ if (batchChanges.length > 1) {
1740
+ emit('cellEditBatchCommit', column, batchChanges)
1741
+ }
1742
+
1743
+ if (!options?.skipUndo && props.editUndo && undoItems.length) {
1744
+ tableEdit.pushUndo({ items: undoItems })
1745
+ }
1746
+
1747
+ if (options?.navigate) moveEditFocus(entry, colKey, options.navigate, true)
1748
+ return true
1749
+ }
1750
+
1751
+ function moveEditFocus(
1752
+ entry: Extract<RsTableRowEntry<T>, { type: 'row' }>,
1753
+ colKey: string,
1754
+ direction: RsTableCellNavigateDirection,
1755
+ startEditNext: boolean,
1756
+ ): void {
1757
+ if (!props.editKeyboard) return
1758
+ const cells = buildEditableCellList()
1759
+ const next = navigateEditableCell(
1760
+ cells,
1761
+ rowKeyFor(entry),
1762
+ colKey,
1763
+ direction,
1764
+ displayColumns.value.length || 1,
1765
+ )
1766
+ if (!next) return
1767
+ const nextEntry = findRowEntryByKey(next.rowKey)
1768
+ if (!nextEntry) return
1769
+ tableEdit.setFocusCell(next)
1770
+ rowHighlight.applyRowClickHighlight(next.rowKey)
1771
+ if (startEditNext) {
1772
+ const column = resolveColumnByKey(next.colKey)
1773
+ if (column && isBooleanToggleColumn(column)) return
1774
+ onCellStartEdit(nextEntry, next.colKey)
1775
+ }
1776
+ }
1777
+
1778
+ async function onCellNavigate(
1779
+ entry: Extract<RsTableRowEntry<T>, { type: 'row' }>,
1780
+ colKey: string,
1781
+ direction: RsTableCellNavigateDirection,
1782
+ ): Promise<void> {
1783
+ const draft = tableEdit.editingDraft.value
1784
+ await onCellCommitEdit(entry, colKey, draft, { navigate: direction })
1785
+ }
1786
+
1787
+ function onCellClick(entry: RsTableRowEntry<T>, colKey: string, event?: MouseEvent): void {
1788
+ if (entry.type !== 'row') return
1789
+ onRowClick(entry, event)
1790
+ if (!props.editable) return
1791
+ const column = resolveColumnByKey(colKey)
1792
+ if (!column) return
1793
+ tableEdit.setFocusCell({
1794
+ rowKey: rowKeyFor(entry),
1795
+ colKey,
1796
+ rowIndex: entry.rowIndex,
1797
+ })
1798
+ }
1799
+
1800
+ /**
1801
+ * 单元格双击:可编辑且触发方式为 dblclick 时进入编辑;
1802
+ * 否则发出 cellView(供只读大字段查看),并转发 rowDblclick(FTP 进目录等)。
1803
+ * 数据单元格上 click/dblclick 使用 .stop,必须在此桥接,否则行级事件收不到。
1804
+ */
1805
+ function onCellDblclick(entry: RsTableRowEntry<T>, colKey: string, event?: MouseEvent): void {
1806
+ if (entry.type !== 'row') return
1807
+ const column = resolveColumnByKey(colKey)
1808
+ if (
1809
+ column &&
1810
+ props.editable &&
1811
+ resolveColumnEditTrigger(column, props.editTrigger) === 'dblclick' &&
1812
+ isColumnEditable(column, entry.row, entry.rowIndex, props.editable) &&
1813
+ !isBooleanToggleColumn(column)
1814
+ ) {
1815
+ onCellStartEdit(entry, colKey)
1816
+ return
1817
+ }
1818
+ if (column) {
1819
+ emit('cellView', entry.row, column, entry.rowIndex)
1820
+ }
1821
+ onRowDblclick(entry, event)
1822
+ }
1823
+
1824
+ function applyUndoRedoValue(
1825
+ entry: Extract<RsTableRowEntry<T>, { type: 'row' }>,
1826
+ colKey: string,
1827
+ value: unknown,
1828
+ ): void {
1829
+ const column = resolveColumnByKey(colKey)
1830
+ if (!column) return
1831
+ const previous = resolveColumnRawValue(entry.row, column, entry.rowIndex)
1832
+ emit('cellEditCommit', entry.row, column, entry.rowIndex, value, previous)
1833
+ }
1834
+
1835
+ function onEditUndo(): void {
1836
+ if (!props.editUndo) return
1837
+ const entry = tableEdit.undo()
1838
+ if (!entry) return
1839
+ for (const item of entry.items) {
1840
+ const rowEntry = findRowEntryByKey(item.rowKey)
1841
+ if (!rowEntry) continue
1842
+ applyUndoRedoValue(rowEntry, item.colKey, item.previous)
1843
+ }
1844
+ emit('cellEditUndo', entry)
1845
+ }
1846
+
1847
+ function onEditRedo(): void {
1848
+ if (!props.editUndo) return
1849
+ const entry = tableEdit.redo()
1850
+ if (!entry) return
1851
+ for (const item of entry.items) {
1852
+ const rowEntry = findRowEntryByKey(item.rowKey)
1853
+ if (!rowEntry) continue
1854
+ applyUndoRedoValue(rowEntry, item.colKey, item.next)
1855
+ }
1856
+ emit('cellEditRedo', entry)
1857
+ }
1858
+
1859
+ function onTableKeydown(event: KeyboardEvent): void {
1860
+ if (!props.editable || !props.editKeyboard) return
1861
+ const target = event.target as HTMLElement | null
1862
+ if (target?.closest('input, textarea, select, [contenteditable="true"]')) {
1863
+ // 编辑器内部自行处理;仅拦截全局撤销
1864
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z' && !event.shiftKey) {
1865
+ if (!tableEdit.editingCell.value) {
1866
+ event.preventDefault()
1867
+ onEditUndo()
1868
+ }
1869
+ }
1870
+ if (
1871
+ (event.ctrlKey || event.metaKey) &&
1872
+ (event.key.toLowerCase() === 'y' || (event.key.toLowerCase() === 'z' && event.shiftKey))
1873
+ ) {
1874
+ if (!tableEdit.editingCell.value) {
1875
+ event.preventDefault()
1876
+ onEditRedo()
1877
+ }
1878
+ }
1879
+ return
1880
+ }
1881
+
1882
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z' && !event.shiftKey) {
1883
+ event.preventDefault()
1884
+ onEditUndo()
1885
+ return
1886
+ }
1887
+ if (
1888
+ (event.ctrlKey || event.metaKey) &&
1889
+ (event.key.toLowerCase() === 'y' || (event.key.toLowerCase() === 'z' && event.shiftKey))
1890
+ ) {
1891
+ event.preventDefault()
1892
+ onEditRedo()
1893
+ return
1894
+ }
1895
+
1896
+ const focus = tableEdit.focusCell.value
1897
+ if (!focus) return
1898
+ const entry = findRowEntryByKey(focus.rowKey)
1899
+ if (!entry) return
1900
+ const column = resolveColumnByKey(focus.colKey)
1901
+ if (!column) return
1902
+
1903
+ if (event.key === 'F2' || event.key === 'Enter') {
1904
+ event.preventDefault()
1905
+ if (isBooleanToggleColumn(column)) {
1906
+ const next = !coerceBooleanFromRow(entry, column)
1907
+ onCellCommitEdit(entry, focus.colKey, next ? 'true' : 'false')
1908
+ return
1909
+ }
1910
+ onCellStartEdit(entry, focus.colKey)
1911
+ return
1912
+ }
1913
+
1914
+ if (event.key === 'Escape') {
1915
+ event.preventDefault()
1916
+ tableEdit.cancelEdit()
1917
+ return
1918
+ }
1919
+
1920
+ if (event.key === 'Delete' || event.key === 'Backspace') {
1921
+ if (!isColumnEditable(column, entry.row, entry.rowIndex, props.editable)) return
1922
+ event.preventDefault()
1923
+ const allowNull = isColumnNullable(column, props.allowNull)
1924
+ onCellCommitEdit(entry, focus.colKey, allowNull ? nullToEditText() : '')
1925
+ return
1926
+ }
1927
+
1928
+ if (event.key === 'Tab') {
1929
+ event.preventDefault()
1930
+ moveEditFocus(entry, focus.colKey, event.shiftKey ? 'prev' : 'next', false)
1931
+ return
1932
+ }
1933
+ if (event.key === 'ArrowRight') {
1934
+ event.preventDefault()
1935
+ moveEditFocus(entry, focus.colKey, 'next', false)
1936
+ return
1937
+ }
1938
+ if (event.key === 'ArrowLeft') {
1939
+ event.preventDefault()
1940
+ moveEditFocus(entry, focus.colKey, 'prev', false)
1941
+ return
1942
+ }
1943
+ if (event.key === 'ArrowDown') {
1944
+ event.preventDefault()
1945
+ moveEditFocus(entry, focus.colKey, 'down', false)
1946
+ return
1947
+ }
1948
+ if (event.key === 'ArrowUp') {
1949
+ event.preventDefault()
1950
+ moveEditFocus(entry, focus.colKey, 'up', false)
1951
+ }
1952
+ }
1953
+
1954
+ function coerceBooleanFromRow(
1955
+ entry: Extract<RsTableRowEntry<T>, { type: 'row' }>,
1956
+ column: RsTableColumn<T>,
1957
+ ): boolean {
1958
+ const raw = resolveColumnRawValue(entry.row, column, entry.rowIndex)
1959
+ if (raw == null) return false
1960
+ if (typeof raw === 'boolean') return raw
1961
+ return String(raw).toLowerCase() === 'true' || raw === 1 || raw === '1'
1962
+ }
1963
+
1964
+ function onTablePaste(event: ClipboardEvent): void {
1965
+ if (!props.editable || !props.editPaste) return
1966
+ if (tableEdit.editingCell.value) return
1967
+ const focus = tableEdit.focusCell.value
1968
+ if (!focus) return
1969
+ const text = event.clipboardData?.getData('text/plain')
1970
+ if (!text) return
1971
+ const grid = parseClipboardGrid(text)
1972
+ if (!grid.length) return
1973
+ event.preventDefault()
1974
+
1975
+ const startEntry = findRowEntryByKey(focus.rowKey)
1976
+ if (!startEntry) return
1977
+ const startRowPos = dataRows.value.findIndex((item) => rowKeyFor(item) === focus.rowKey)
1978
+ const startColIndex = displayColumns.value.findIndex((column) => column.key === focus.colKey)
1979
+ if (startRowPos < 0 || startColIndex < 0) return
1980
+
1981
+ for (let r = 0; r < grid.length; r += 1) {
1982
+ const rowEntry = dataRows.value[startRowPos + r]
1983
+ if (!rowEntry) break
1984
+ const line = grid[r] ?? []
1985
+ for (let c = 0; c < line.length; c += 1) {
1986
+ const column = displayColumns.value[startColIndex + c]
1987
+ if (!column) break
1988
+ if (!isColumnEditable(column, rowEntry.row, rowEntry.rowIndex, props.editable)) continue
1989
+ onCellCommitEdit(rowEntry, column.key, line[c] ?? '')
1990
+ }
1991
+ }
1992
+ }
1993
+
1994
+ function rejectRowEdit(rowKey: string, reason?: string): void {
1995
+ const entry = findRowEntryByKey(rowKey)
1996
+ tableEdit.rollbackRow(rowKey)
1997
+ if (entry) emit('cellEditReject', entry.row, entry.rowIndex, reason)
1998
+ }
1999
+
2000
+ async function onRowCommitEdit(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): Promise<void> {
2001
+ const rowKey = rowKeyFor(entry)
2002
+ // date/datetime 等:面板确认只改 editingDraft,不会自动 stage;✓ 前先刷入 staged
2003
+ const editing = tableEdit.editingCell.value
2004
+ if (editing?.rowKey === rowKey) {
2005
+ const ok = await onCellCommitEdit(entry, editing.colKey, tableEdit.editingDraft.value)
2006
+ if (!ok) return
2007
+ }
2008
+ const staged = tableEdit.commitRow(rowKey)
2009
+ if (!staged.length) return
2010
+ const changes = staged.map((item) => {
2011
+ const column = resolveColumnByKey(item.colKey)
2012
+ const allowNull = column ? isColumnNullable(column, props.allowNull) : props.allowNull
2013
+ const value = column
2014
+ ? parseCellEditInput(item.draft, entry.row, column, entry.rowIndex, { allowNull })
2015
+ : item.draft
2016
+ return {
2017
+ colKey: item.colKey,
2018
+ value,
2019
+ previous: item.original,
2020
+ }
2021
+ })
2022
+ emit('rowEditCommit', entry.row, entry.rowIndex, changes)
2023
+ }
2024
+
2025
+ function onRowRollbackEdit(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): void {
2026
+ if (!props.rowCommit && isExternalRowPending(entry)) {
2027
+ emit('rowEditRollback', entry.row, entry.rowIndex)
2028
+ return
2029
+ }
2030
+ tableEdit.rollbackRow(rowKeyFor(entry))
2031
+ emit('rowEditRollback', entry.row, entry.rowIndex)
2032
+ }
2033
+
2034
+ function isExternalRowPending(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): boolean {
2035
+ return Boolean(props.rowPending?.(entry.row, entry.rowIndex))
2036
+ }
2037
+
2038
+ function rowEditPending(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): boolean {
2039
+ if (isExternalRowPending(entry)) return true
2040
+ const rowKey = rowKeyFor(entry)
2041
+ return isRowEditPending(rowKey, entry.row, entry.rowIndex, {
2042
+ isRowDirty: tableEdit.isRowDirty,
2043
+ editingCell: tableEdit.editingCell.value,
2044
+ editingDraft: tableEdit.editingDraft.value,
2045
+ resolveColumn: resolveColumnByKey,
2046
+ })
2047
+ }
2048
+
2049
+ function onGutterCommit(entry: Extract<RsTableRowEntry<T>, { type: 'row' }>): void {
2050
+ if (props.rowCommit) {
2051
+ void onRowCommitEdit(entry)
2052
+ return
2053
+ }
2054
+ if (isExternalRowPending(entry)) {
2055
+ emit('rowEditCommit', entry.row, entry.rowIndex, [])
2056
+ return
2057
+ }
2058
+ const editing = tableEdit.editingCell.value
2059
+ if (editing?.rowKey !== rowKeyFor(entry)) return
2060
+ void onCellCommitEdit(entry, editing.colKey, tableEdit.editingDraft.value)
2061
+ }
2062
+
2063
+ function getRowByKey(rowKey: string): T | undefined {
2064
+ const entry = dataRows.value.find((item) => rowKeyByIndex.value.get(item.rowIndex) === rowKey)
2065
+ return entry?.row
2066
+ }
2067
+
2068
+ function getRowByIndex(index: number): T | undefined {
2069
+ return dataRows.value.find((item) => item.rowIndex === index)?.row
2070
+ }
2071
+
2072
+ function getColumnValues(colKey: string): unknown[] {
2073
+ const column = resolveColumnByKey(colKey)
2074
+ if (!column) return []
2075
+ return dataRows.value.map((entry) => resolveColumnRawValue(entry.row, column, entry.rowIndex))
2076
+ }
2077
+
2078
+ function getCellValueByKey(rowKey: string, colKey: string): unknown {
2079
+ const row = getRowByKey(rowKey)
2080
+ const column = resolveColumnByKey(colKey)
2081
+ if (!row || !column) return undefined
2082
+ const entry = dataRows.value.find((item) => rowKeyByIndex.value.get(item.rowIndex) === rowKey)
2083
+ if (!entry) return undefined
2084
+ return resolveColumnRawValue(entry.row, column, entry.rowIndex)
2085
+ }
2086
+
2087
+ defineExpose({
2088
+ getRowByKey,
2089
+ getRowByIndex,
2090
+ getColumnValues,
2091
+ getCellValue: getCellValueByKey,
2092
+ getHighlightedRowKey: () => rowHighlight.highlightedKey.value,
2093
+ setHighlightedRowKey: rowHighlight.setHighlightedKey,
2094
+ cancelCellEdit: () => tableEdit.cancelEdit(),
2095
+ cancelAllEdits: () => tableEdit.cancelAll(),
2096
+ getDirtyCellKeys: () => [...tableEdit.stagedMap.value.keys()],
2097
+ getCellError: tableEdit.getCellError,
2098
+ setCellError: tableEdit.setCellError,
2099
+ /** 弹窗等外部编辑器写回草稿(需配合 row-commit,由用户手动点行提交) */
2100
+ stageCell: tableEdit.stageCell,
2101
+ rejectRowEdit,
2102
+ undoEdit: onEditUndo,
2103
+ redoEdit: onEditRedo,
2104
+ commitRowEdits: (rowKey: string) => {
2105
+ const entry = dataRows.value.find((item) => rowKeyByIndex.value.get(item.rowIndex) === rowKey)
2106
+ if (entry) void onRowCommitEdit(entry)
2107
+ },
2108
+ rollbackRowEdits: (rowKey: string) => {
2109
+ const entry = dataRows.value.find((item) => rowKeyByIndex.value.get(item.rowIndex) === rowKey)
2110
+ if (entry) onRowRollbackEdit(entry)
2111
+ },
2112
+ })
2113
+
2114
+ /** RAF handle,用于节流滚动更新;组件卸载时取消,防止内存泄漏 */
2115
+ let scrollRafId = 0
2116
+
2117
+ function onScroll(event: Event): void {
2118
+ hideCellTooltip()
2119
+ // 保留编辑草稿:虚拟滚动卸载行后滚回仍可恢复编辑态
2120
+ const element = event.target as HTMLElement
2121
+ // 无限加载判断不节流,保证触底灵敏
2122
+ if (props.infinite && !props.loading && !props.loadingMore && props.hasMore && !loadMoreLocked.value) {
2123
+ if (isNearScrollBottom(element.scrollTop, element.scrollHeight, element.clientHeight, props.infiniteDistance)) {
2124
+ loadMoreLocked.value = true
2125
+ emit('loadMore')
2126
+ }
2127
+ }
2128
+ // 始终记录滚动(含非虚拟),供 keep-alive / layoutActive 切回后恢复
2129
+ if (scrollRafId) return
2130
+ scrollRafId = requestAnimationFrame(() => {
2131
+ // 恢复窗口内浏览器常先抛出 scrollTop=0 的伪事件,忽略以免冲掉已保存位置
2132
+ if (restoringScroll) {
2133
+ scrollRafId = 0
2134
+ return
2135
+ }
2136
+ scrollTop.value = element.scrollTop
2137
+ scrollLeft.value = element.scrollLeft
2138
+ preservedScrollTop = element.scrollTop
2139
+ preservedScrollLeft = element.scrollLeft
2140
+ scrollRafId = 0
2141
+ })
2142
+ }
2143
+
2144
+ watch(
2145
+ () => [props.loadingMore, props.hasMore] as const,
2146
+ ([loadingMore, hasMore]) => {
2147
+ if (!loadingMore || !hasMore) loadMoreLocked.value = false
2148
+ },
2149
+ )
2150
+
2151
+ function beginResizeSession(): void {
2152
+ if (!resizeState || isColumnResizing.value) return
2153
+ isColumnResizing.value = true
2154
+ activeDragWidths = { ...resizeState.domSnapshot }
2155
+ resizePaintWidths.value = { ...activeDragWidths }
2156
+ void nextTick(() => {
2157
+ if (tableRef.value && activeDragWidths) applyTableColumnWidths(tableRef.value, activeDragWidths)
2158
+ })
2159
+ }
2160
+
2161
+ function onResizeMove(event: MouseEvent): void {
2162
+ if (!resizeState) return
2163
+ latestResizeClientX = event.clientX
2164
+ if (!resizeMoved && Math.abs(latestResizeClientX - resizeState.startX) < 1) return
2165
+ if (!resizeMoved) {
2166
+ resizeMoved = true
2167
+ beginResizeSession()
2168
+ }
2169
+ if (resizeRafId !== 0) return
2170
+ resizeRafId = requestAnimationFrame(() => {
2171
+ resizeRafId = 0
2172
+ if (!resizeState || !activeDragWidths) return
2173
+ const nextWidth = clampColumnWidth(
2174
+ resizeState.startWidth + latestResizeClientX - resizeState.startX,
2175
+ props.minColumnWidth,
2176
+ props.maxColumnWidth,
2177
+ )
2178
+ resizeState.currentWidth = nextWidth
2179
+ activeDragWidths = { ...resizeState.domSnapshot, [resizeState.key]: nextWidth }
2180
+ if (tableRef.value) applyTableColumnWidths(tableRef.value, activeDragWidths)
2181
+ })
2182
+ }
2183
+
2184
+ function stopResize(): void {
2185
+ if (resizeState && resizeMoved) {
2186
+ const finalWidth = clampColumnWidth(
2187
+ resizeState.startWidth + latestResizeClientX - resizeState.startX,
2188
+ props.minColumnWidth,
2189
+ props.maxColumnWidth,
2190
+ )
2191
+ if (finalWidth !== resizeState.startWidth) {
2192
+ const merged = { ...resizeState.domSnapshot, [resizeState.key]: finalWidth }
2193
+ resolvedColumnWidths.value = merged
2194
+ emit('columnResize', resizeState.key, finalWidth)
2195
+ }
2196
+ }
2197
+ if (tableRef.value) {
2198
+ tableRef.value.style.width = ''
2199
+ tableRef.value.style.minWidth = ''
2200
+ for (const th of tableRef.value.querySelectorAll<HTMLTableCellElement>('thead th[data-col-key]')) {
2201
+ th.style.width = ''
2202
+ }
2203
+ }
2204
+ isColumnResizing.value = false
2205
+ resizePaintWidths.value = null
2206
+ activeDragWidths = null
2207
+ resizeState = null
2208
+ resizeMoved = false
2209
+ if (resizeRafId !== 0) {
2210
+ cancelAnimationFrame(resizeRafId)
2211
+ resizeRafId = 0
2212
+ }
2213
+ document.body.style.cursor = ''
2214
+ document.body.style.userSelect = ''
2215
+ document.removeEventListener('mousemove', onResizeMove)
2216
+ document.removeEventListener('mouseup', stopResize, { capture: true })
2217
+ }
2218
+
2219
+ function onResizeStart(key: string, event: MouseEvent): void {
2220
+ if (!props.resizable) return
2221
+
2222
+ const thEl = (event.currentTarget as HTMLElement).closest('th') as HTMLTableCellElement | null
2223
+ if (!thEl) return
2224
+ const tableEl = thEl.closest('table') as HTMLTableElement | null
2225
+ if (!tableEl) return
2226
+
2227
+ const dataThs = Array.from(tableEl.querySelectorAll<HTMLTableCellElement>('thead th[data-col-key]'))
2228
+ const domSnapshot: Record<string, number> = {}
2229
+ for (const th of dataThs) {
2230
+ domSnapshot[th.dataset.colKey!] = th.offsetWidth
2231
+ }
2232
+
2233
+ const startWidth = domSnapshot[key] ?? thEl.offsetWidth
2234
+ latestResizeClientX = event.clientX
2235
+ resizeMoved = false
2236
+ resizeState = {
2237
+ key,
2238
+ startX: event.clientX,
2239
+ startWidth,
2240
+ currentWidth: startWidth,
2241
+ domSnapshot,
2242
+ persistedWidths: { ...resolvedColumnWidths.value },
2243
+ }
2244
+ document.body.style.cursor = 'col-resize'
2245
+ document.body.style.userSelect = 'none'
2246
+ document.addEventListener('mousemove', onResizeMove)
2247
+ document.addEventListener('mouseup', stopResize, { capture: true })
2248
+ }
2249
+
2250
+ onUnmounted(() => {
2251
+ stopResize()
2252
+ viewportResizeObserver?.disconnect()
2253
+ viewportResizeObserver = null
2254
+ if (scrollRafId) {
2255
+ cancelAnimationFrame(scrollRafId)
2256
+ scrollRafId = 0
2257
+ }
2258
+ })
2259
+ </script>
2260
+
2261
+ <template>
2262
+ <RsContextMenu
2263
+ :disabled="!contextMenuEnabled"
2264
+ :items="ctxMenuItems"
2265
+ @select="onCtxMenuSelect"
2266
+ >
2267
+ <!-- shell 作为 ContextMenuTrigger 宿主;::before 伪元素铺满 shell 捕获空白区右键 -->
2268
+ <div
2269
+ class="rs-table-shell"
2270
+ :class="{ 'rs-table-shell--ctx': contextMenuEnabled, 'rs-table-shell--fill': fill }"
2271
+ tabindex="0"
2272
+ v-bind="$attrs"
2273
+ @contextmenu="onTableContextmenu"
2274
+ @keydown="onTableKeydown"
2275
+ @paste="onTablePaste"
2276
+ >
2277
+ <div
2278
+ ref="scrollContainerRef"
2279
+ class="rs-table rs-native-scrollbar"
2280
+ :class="{
2281
+ 'rs-table--bordered': bordered,
2282
+ 'rs-table--column-bordered': columnBordered,
2283
+ 'rs-table--rounded': rounded,
2284
+ 'rs-table--fill': fill,
2285
+ 'rs-table--compact': compact,
2286
+ [`rs-table--${resolvedSize}`]: true,
2287
+ 'rs-table--virtual': virtualScrollEnabled,
2288
+ 'rs-table--virtual-cols': virtualColumnsEnabled,
2289
+ 'rs-table--content-visibility': !virtualScrollEnabled && data.length >= 30,
2290
+ 'rs-table--infinite': infinite,
2291
+ 'rs-table--resizable': resizable,
2292
+ 'rs-table--col-auto': resizable && columnLayout === 'auto',
2293
+ 'rs-table--col-fixed': useFixedColumnLayout,
2294
+ 'rs-table--striped': striped,
2295
+ 'rs-table--selectable': selectable,
2296
+ 'rs-table--row-select': isRowSelection,
2297
+ 'rs-table--expandable': expandable,
2298
+ 'rs-table--draggable': rowDraggable || columnDraggable,
2299
+ 'rs-table--scroll-x': !!scrollX,
2300
+ 'rs-table--overflow-x': overflowXEnabled,
2301
+ 'rs-table--ctx': contextMenuEnabled,
2302
+ 'rs-table--edit-gutter': showEditGutterColumn,
2303
+ }"
2304
+ :style="scrollContainerStyle"
2305
+ @scroll.passive="onScroll"
2306
+ >
2307
+ <table ref="tableRef" class="rs-table__table" :style="tableInlineStyle">
2308
+ <colgroup v-if="useFixedColumnLayout">
2309
+ <col v-if="showRowDragHandle" :style="{ width: `${PREFIX_COL_WIDTH.drag}px` }">
2310
+ <col v-if="expandable" :style="{ width: `${PREFIX_COL_WIDTH.expand}px` }">
2311
+ <col v-if="showSelectColumn" :style="{ width: `${PREFIX_COL_WIDTH.select}px` }">
2312
+ <col v-if="showEditGutterColumn" data-col-key="gutter" :style="{ width: `${resolvedGutterWidth}px` }">
2313
+ <col v-else-if="showIndexColumn" :style="{ width: `${resolvedIndexWidth}px` }">
2314
+ <col v-if="showRowStatusColumn" :style="{ width: `${PREFIX_COL_WIDTH.status}px` }">
2315
+ <col
2316
+ v-if="columnPadLeft > 0"
2317
+ class="rs-table__col-pad"
2318
+ :style="{ width: `${columnPadLeft}px` }"
2319
+ >
2320
+ <col
2321
+ v-for="column in visibleDataColumns"
2322
+ :key="column.key"
2323
+ :data-col-key="column.key"
2324
+ :style="{ width: `${resolvedDataColumnWidth(column.key, column.width ?? column.minWidth)}px` }"
2325
+ >
2326
+ <col
2327
+ v-if="columnPadRight > 0"
2328
+ class="rs-table__col-pad"
2329
+ :style="{ width: `${columnPadRight}px` }"
2330
+ >
2331
+ </colgroup>
2332
+ <thead
2333
+ v-if="showHeader"
2334
+ class="rs-table__head"
2335
+ @pointerover.passive="onCellTipOver"
2336
+ @pointerout.passive="onCellTipOut"
2337
+ >
2338
+ <tr>
2339
+ <th
2340
+ v-if="showRowDragHandle"
2341
+ class="rs-table__th rs-table__th--drag"
2342
+ :style="dragLeadHeaderStyle"
2343
+ />
2344
+ <th
2345
+ v-if="expandable"
2346
+ class="rs-table__th rs-table__th--expand"
2347
+ :style="expandLeadHeaderStyle"
2348
+ />
2349
+ <th
2350
+ v-if="showSelectColumn"
2351
+ class="rs-table__th rs-table__th--selection"
2352
+ :style="selectLeadHeaderStyle"
2353
+ >
2354
+ <label
2355
+ v-if="!isRadioSelection"
2356
+ class="rs-table__checkbox"
2357
+ :class="{
2358
+ 'rs-table__checkbox--checked': selectAllState === 'checked',
2359
+ 'rs-table__checkbox--indeterminate': selectAllState === 'indeterminate',
2360
+ }"
2361
+ >
2362
+ <input
2363
+ type="checkbox"
2364
+ class="rs-table__checkbox-input"
2365
+ :checked="selectAllState === 'checked'"
2366
+ :aria-label="t('table.selectAll')"
2367
+ @change="onToggleSelectAll"
2368
+ >
2369
+ <span class="rs-table__checkbox-box" aria-hidden="true" />
2370
+ </label>
2371
+ </th>
2372
+ <th
2373
+ v-if="showEditGutterColumn"
2374
+ class="rs-table__th rs-table__th--gutter rs-table__cell--center"
2375
+ :style="gutterLeadHeaderStyle"
2376
+ />
2377
+ <th
2378
+ v-else-if="showIndexColumn"
2379
+ class="rs-table__th rs-table__th--index rs-table__cell--center"
2380
+ :style="indexLeadHeaderStyle"
2381
+ >
2382
+ {{ t('table.index') }}
2383
+ </th>
2384
+ <th
2385
+ v-if="showRowStatusColumn"
2386
+ class="rs-table__th rs-table__th--status rs-table__cell--center"
2387
+ >
2388
+ {{ t('table.rowStatus') }}
2389
+ </th>
2390
+ <th
2391
+ v-if="columnPadLeft > 0"
2392
+ class="rs-table__th rs-table__col-pad"
2393
+ :style="{ width: `${columnPadLeft}px`, minWidth: `${columnPadLeft}px` }"
2394
+ />
2395
+ <th
2396
+ v-for="column in visibleDataColumns"
2397
+ :key="column.key"
2398
+ :data-col-key="column.key"
2399
+ class="rs-table__th"
2400
+ :class="[
2401
+ `rs-table__cell--${column.align ?? 'left'}`,
2402
+ { 'rs-table__th--sortable': column.sortable },
2403
+ { 'rs-table__th--filterable': column.filterable },
2404
+ { 'rs-table__cell--fixed': column.fixed },
2405
+ { 'rs-table__th--dragging': dragColumnKey === column.key },
2406
+ { 'rs-table__th--drop-target': dropColumnKey === column.key },
2407
+ ]"
2408
+ :style="columnHeaderStyleMap.get(column.key)"
2409
+ @dragover="onColumnDragOver(column.key, $event)"
2410
+ @drop="onColumnDrop(column.key, $event)"
2411
+ >
2412
+ <span
2413
+ v-if="columnDraggable"
2414
+ class="rs-table__column-drag-handle"
2415
+ draggable="true"
2416
+ :aria-label="t('table.dragColumn')"
2417
+ @click.stop
2418
+ @dragstart.stop="onColumnDragStart(column.key, $event)"
2419
+ @dragend.stop="onColumnDragEnd"
2420
+ />
2421
+ <slot :name="`header-${column.key}`" :column="column">
2422
+ <span
2423
+ v-if="column.headerTip"
2424
+ class="rs-table__th-label rs-table__th-label--tip"
2425
+ :data-rs-table-header-tip="column.headerTip"
2426
+ >{{ column.title }}</span>
2427
+ <span v-else class="rs-table__th-label">{{ column.title }}</span>
2428
+ </slot>
2429
+ <span v-if="column.filterable || column.sortable" class="rs-table__header-actions">
2430
+ <RsTableHeaderFilter
2431
+ v-if="column.filterable"
2432
+ :model-value="columnFilterValue(column.key)"
2433
+ :column-title="column.title"
2434
+ :filter-label="t('table.filterColumn')"
2435
+ :placeholder="t('table.filterPlaceholder')"
2436
+ :clear-label="t('table.filterClear')"
2437
+ :apply-label="t('table.filterApply')"
2438
+ :active="isColumnFilterActive(columnFiltersState, column.key)"
2439
+ @update:model-value="updateColumnFilter(column.key, $event)"
2440
+ />
2441
+ <button
2442
+ v-if="column.sortable"
2443
+ type="button"
2444
+ class="rs-table__sort"
2445
+ :class="{ 'rs-table__sort--active': !!sortOrderFor(column.key) }"
2446
+ :title="column.title"
2447
+ :aria-label="column.title"
2448
+ @click.stop="onHeaderClick(column)"
2449
+ >
2450
+ <RsIcon :name="sortIconName(column.key)" size="sm" />
2451
+ <span v-if="multiSort && sortPriorityFor(column.key) > 1" class="rs-table__sort-priority">
2452
+ {{ sortPriorityFor(column.key) }}
2453
+ </span>
2454
+ </button>
2455
+ </span>
2456
+ <span
2457
+ v-if="resizable"
2458
+ class="rs-table__resize-handle"
2459
+ @mousedown.stop="onResizeStart(column.key, $event)"
2460
+ @click.stop
2461
+ />
2462
+ </th>
2463
+ <th
2464
+ v-if="columnPadRight > 0"
2465
+ class="rs-table__th rs-table__col-pad"
2466
+ :style="{ width: `${columnPadRight}px`, minWidth: `${columnPadRight}px` }"
2467
+ />
2468
+ </tr>
2469
+ </thead>
2470
+ <tbody
2471
+ @pointerover.passive="onCellTipOver"
2472
+ @pointerout.passive="onCellTipOut"
2473
+ >
2474
+ <tr v-if="loading">
2475
+ <td class="rs-table__empty" :colspan="bodyColspan">{{ t('table.loading') }}</td>
2476
+ </tr>
2477
+ <tr v-else-if="!hasData">
2478
+ <td class="rs-table__empty" :colspan="bodyColspan">
2479
+ <slot name="empty">{{ t('table.empty') }}</slot>
2480
+ </td>
2481
+ </tr>
2482
+ <template v-else>
2483
+ <tr
2484
+ v-if="virtualScrollEnabled && virtualSlice.paddingTop > 0"
2485
+ class="rs-table__virtual-pad"
2486
+ >
2487
+ <td :colspan="bodyColspan" :style="{ height: `${virtualSlice.paddingTop}px` }" />
2488
+ </tr>
2489
+ <template v-for="entry in visibleEntries" :key="resolveEntryKey(entry, rowKey)">
2490
+ <tr v-if="entry.type === 'group'" class="rs-table__group-row">
2491
+ <td class="rs-table__group-cell" :colspan="bodyColspan">
2492
+ <slot name="group" :key="entry.key" :label="entry.label">
2493
+ {{ entry.label }}
2494
+ </slot>
2495
+ </td>
2496
+ </tr>
2497
+ <tr
2498
+ v-else-if="entry.type === 'expand'"
2499
+ class="rs-table__expand-row"
2500
+ >
2501
+ <td class="rs-table__expand-cell" :colspan="bodyColspan">
2502
+ <slot name="expand" :row="entry.row" :index="entry.rowIndex" />
2503
+ </td>
2504
+ </tr>
2505
+ <RsTableBodyRow
2506
+ v-else
2507
+ v-memo="[
2508
+ rowKeyFor(entry),
2509
+ /* 行对象引用:父级不可变更新后必须失效,否则布尔勾选等提交不刷新 */
2510
+ entry.row,
2511
+ isRowSelected(entry),
2512
+ isRowExpanded(entry),
2513
+ rowHighlight.isHighlighted(rowKeyFor(entry)),
2514
+ rowEditPending(entry),
2515
+ tableEdit.stagedMap.value.size,
2516
+ tableEdit.editingCell.value?.rowKey === rowKeyFor(entry)
2517
+ ? tableEdit.editingDraft.value
2518
+ : '',
2519
+ activeEditCellKey,
2520
+ activeFocusCellKey,
2521
+ activeErrorMapSize,
2522
+ activeValidatingMapSize,
2523
+ dragRowKeys.join(','),
2524
+ dropRowTargetKey,
2525
+ dropRowPosition,
2526
+ columnPadLeft,
2527
+ columnPadRight,
2528
+ visibleDataColumns.map((c) => c.key).join('\0'),
2529
+ ]"
2530
+ :row="entry.row"
2531
+ :row-index="entry.rowIndex"
2532
+ :row-key="rowKeyFor(entry)"
2533
+ :columns="visibleDataColumns"
2534
+ :column-pad-left="columnPadLeft"
2535
+ :column-pad-right="columnPadRight"
2536
+ :column-td-class-map="columnTdClassMap"
2537
+ :column-style-map="columnStyleMap"
2538
+ :show-row-drag-handle="showRowDragHandle"
2539
+ :expandable="expandable"
2540
+ :selectable="showSelectColumn"
2541
+ :show-index="showIndexColumn"
2542
+ :show-edit-gutter="showEditGutterColumn"
2543
+ :striped="striped"
2544
+ :selection-type="selectionType"
2545
+ :is-radio-selection="isRadioSelection"
2546
+ :table-editable="editable"
2547
+ :edit-trigger="editTrigger"
2548
+ :row-commit="rowCommit"
2549
+ :allow-null="allowNull"
2550
+ :focus-mode="editFocusMode"
2551
+ :null-label="nullLabel"
2552
+ :show-row-status="showRowStatusColumn"
2553
+ :selected="isRowSelected(entry)"
2554
+ :highlighted="rowHighlight.isHighlighted(rowKeyFor(entry))"
2555
+ :expanded="isRowExpanded(entry)"
2556
+ :disabled="isTableRowDisabled(entry.row)"
2557
+ :row-drag-by-row="isRowDragByRow(entry)"
2558
+ :dragging="isRowDragging(entry)"
2559
+ :drop-target="isRowDropTarget(entry)"
2560
+ :drop-position="isRowDropTarget(entry) ? dropRowPosition : null"
2561
+ :drag-lead-style="dragLeadStyle"
2562
+ :expand-lead-style="expandLeadStyle"
2563
+ :select-lead-style="selectLeadStyle"
2564
+ :index-lead-style="indexLeadStyle"
2565
+ :gutter-lead-style="gutterLeadStyle"
2566
+ :can-expand="canExpandRow(entry)"
2567
+ :can-select="canSelectRow(entry)"
2568
+ :row-edit-pending="rowEditPending(entry)"
2569
+ :row-dirty="tableEdit.isRowDirty(rowKeyFor(entry))"
2570
+ :show-gutter-rollback="rowCommit || isExternalRowPending(entry)"
2571
+ :editing-col-key="tableEdit.editingCell.value?.rowKey === rowKeyFor(entry) ? tableEdit.editingCell.value.colKey : null"
2572
+ :focus-col-key="tableEdit.focusCell.value?.rowKey === rowKeyFor(entry) ? tableEdit.focusCell.value.colKey : null"
2573
+ :has-column-slot="hasColumnSlot"
2574
+ :has-edit-slot="hasEditSlot"
2575
+ :cell-tooltip-enabled="cellTooltipEnabled"
2576
+ :cell-tooltip-mode="cellTooltipMode"
2577
+ :cell-tooltip-text="cellTooltipText"
2578
+ :cell-tooltip-fallback-title="cellTooltipFallbackTitle"
2579
+ :get-cell-draft="tableEdit.getDraft"
2580
+ :is-cell-dirty="tableEdit.isDirty"
2581
+ :get-cell-error="tableEdit.getCellError"
2582
+ :is-cell-validating="tableEdit.isValidating"
2583
+ :drag-row-label="t('table.dragRow')"
2584
+ :expand-row-label="t('table.expandRow')"
2585
+ :collapse-row-label="t('table.collapseRow')"
2586
+ :select-row-label="t('table.selectRow')"
2587
+ :row-commit-label="t('table.rowCommit')"
2588
+ :row-rollback-label="t('table.rowRollback')"
2589
+ :gutter-commit-hint="t('table.gutterCommit')"
2590
+ @click="onRowClick(entry, $event)"
2591
+ @mousedown="onRowSelectMouseDown"
2592
+ @cell-click="(colKey, event) => onCellClick(entry, colKey, event)"
2593
+ @cell-dblclick="(colKey, event) => onCellDblclick(entry, colKey, event)"
2594
+ @cell-contextmenu="(colKey, event) => onCellContextmenu(entry, colKey, event)"
2595
+ @dblclick="onRowDblclick(entry, $event)"
2596
+ @contextmenu="onRowContextmenu(entry, $event)"
2597
+ @dragover="onRowDragOver(entry, $event)"
2598
+ @dragleave="onRowDragLeave"
2599
+ @drop="onRowDrop(entry, $event)"
2600
+ @row-drag-start="onRowDragStart(entry, $event)"
2601
+ @row-drag-end="onRowDragEnd"
2602
+ @toggle-expand="onToggleExpand(entry)"
2603
+ @toggle-select="onToggleRow(entry)"
2604
+ @cell-start-edit="onCellStartEdit(entry, $event)"
2605
+ @cell-commit="(colKey, value) => onCellCommitEdit(entry, colKey, value)"
2606
+ @cell-cancel="onCellCancelEdit(entry, $event)"
2607
+ @cell-update-draft="(colKey, value) => onCellUpdateDraft(entry, colKey, value)"
2608
+ @cell-navigate="(colKey, direction) => onCellNavigate(entry, colKey, direction)"
2609
+ @row-commit="onRowCommitEdit(entry)"
2610
+ @row-rollback="onRowRollbackEdit(entry)"
2611
+ @gutter-commit="onGutterCommit(entry)"
2612
+ >
2613
+ <template v-for="column in visibleDataColumns" #[column.key]="slotProps">
2614
+ <slot :name="column.key" v-bind="slotProps" />
2615
+ </template>
2616
+ <template
2617
+ v-for="column in visibleDataColumns"
2618
+ :key="`edit-${column.key}`"
2619
+ #[`edit-${column.key}`]="slotProps"
2620
+ >
2621
+ <slot :name="`edit-${column.key}`" v-bind="slotProps" />
2622
+ </template>
2623
+ </RsTableBodyRow>
2624
+ </template>
2625
+ <tr
2626
+ v-if="virtualScrollEnabled && virtualSlice.paddingBottom > 0"
2627
+ class="rs-table__virtual-pad"
2628
+ >
2629
+ <td :colspan="bodyColspan" :style="{ height: `${virtualSlice.paddingBottom}px` }" />
2630
+ </tr>
2631
+ <tr v-if="infinite && loadingMore">
2632
+ <td class="rs-table__empty rs-table__empty--more" :colspan="bodyColspan">
2633
+ {{ t('table.loadingMore') }}
2634
+ </td>
2635
+ </tr>
2636
+ </template>
2637
+ </tbody>
2638
+ <tfoot v-if="$slots.summary" class="rs-table__foot">
2639
+ <tr>
2640
+ <td class="rs-table__summary" :colspan="bodyColspan">
2641
+ <slot name="summary" />
2642
+ </td>
2643
+ </tr>
2644
+ </tfoot>
2645
+ </table>
2646
+ </div>
2647
+ <Teleport to="body">
2648
+ <div
2649
+ v-if="cellTipState.visible"
2650
+ ref="sharedTipRef"
2651
+ class="rs-table__shared-tip"
2652
+ :class="{
2653
+ 'rs-table__shared-tip--ready': cellTipState.ready,
2654
+ 'rs-table__shared-tip--header': cellTipState.kind === 'header',
2655
+ }"
2656
+ :style="cellTipState.style"
2657
+ role="tooltip"
2658
+ >
2659
+ {{ cellTipState.text }}
2660
+ </div>
2661
+ </Teleport>
2662
+ </div>
2663
+ </RsContextMenu>
2664
+ </template>