@h-ai/ui 0.1.0-alpha.11 → 0.1.0-alpha.12

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.
@@ -0,0 +1,1640 @@
1
+ <!--
2
+ =============================================================================
3
+ @h-ai/ui - AiTableEditor 组件
4
+ =============================================================================
5
+ 面向 AI 场景的可编辑表格查看器,支持:
6
+ - 从结构化数据或 JSON 文本渲染表格
7
+ - 流式残缺 JSON 的增量容错解析(列/行可分段出现)
8
+ - 单元格编辑、行新增、行删除、拖拽排序
9
+ - 复制表格(TSV)与下载表格(CSV)
10
+
11
+ 使用 Svelte 5 Runes ($props, $state, $derived, $effect)
12
+ =============================================================================
13
+ -->
14
+ <script lang='ts'>
15
+ import type { MarkdownToolbarDownloadAction } from '../document-types.js'
16
+ import type {
17
+ AiTableColumn,
18
+ AiTableColumnType,
19
+ AiTableData,
20
+ AiTableDownloadPayload,
21
+ AiTableEditorChangePayload,
22
+ AiTableEditorProps,
23
+ } from './table-types.js'
24
+ import { uiM } from '../../../../messages.js'
25
+ import { cn } from '../../../../utils.js'
26
+ import AiDocumentDownloadMenu from '../AiDocumentDownloadMenu.svelte'
27
+
28
+ interface ExtractedArrayChunk {
29
+ /** 从原始字符串里截取到的数组片段(可能是不完整数组)。 */
30
+ source: string
31
+ /** 是否已经拿到完整闭合的 `[...]`。 */
32
+ complete: boolean
33
+ }
34
+
35
+ type AiTableRowDropPosition = 'before' | 'after'
36
+
37
+ interface ReorderedTableResult {
38
+ /** 重排后的完整表格快照;未真正变更顺序时为 `null`。 */
39
+ nextData: AiTableData | null
40
+ /** 被拖动行原本所在的索引。 */
41
+ fromIndex: number
42
+ /** 被拖动行落位后的索引;无变化时与 `fromIndex` 相同。 */
43
+ toIndex: number
44
+ }
45
+
46
+ // 顶部复制按钮在“复制成功”态和默认态之间切换时复用内联 SVG,避免重复拼接 DOM 片段。
47
+ const COPY_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 9a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2v-9Zm2 0v9h9v-9h-9Z"></path><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1h-2V4H4v9h1v2Z"></path></svg>`
48
+ const CHECK_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9.55 18.35-5.2-5.2 1.42-1.41 3.78 3.78 8.68-8.68 1.42 1.41-10.1 10.1Z"></path></svg>`
49
+ const CLOSE_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18.3 5.7 12 12l6.3 6.3-1.4 1.4L10.6 13.4 4.3 19.7l-1.4-1.4L9.2 12 2.9 5.7l1.4-1.4 6.3 6.3 6.3-6.3 1.4 1.4Z"></path></svg>`
50
+ const DELETE_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.72 6.72a.75.75 0 0 1 1.06 0L12 10.94l4.22-4.22a.75.75 0 1 1 1.06 1.06L13.06 12l4.22 4.22a.75.75 0 1 1-1.06 1.06L12 13.06l-4.22 4.22a.75.75 0 1 1-1.06-1.06L10.94 12 6.72 7.78a.75.75 0 0 1 0-1.06Z"></path></svg>`
51
+ const PLUS_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5Z"></path></svg>`
52
+ const DRAG_HANDLE_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.75a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm0 5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm0 5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5ZM16 5.75a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm0 5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm0 5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Z"></path></svg>`
53
+ const TABLE_DOWNLOAD_ACTION_ID = 'table-csv'
54
+ // 文件名过滤正则,避免下载时包含平台不兼容字符。
55
+ const INVALID_FILENAME_CHARS_REGEX = /[<>:"/\\|?*]+/g
56
+ // 单元格值中的双引号需要按 CSV 规范转义为两个双引号。
57
+ const CSV_DOUBLE_QUOTE_REGEX = /"/g
58
+ const CANONICAL_TABLE_CONTENT_HINTS = ['table/v1', '"columns"', '"rows"'] as const
59
+ const EDITOR_TABLE_CONTENT_HINTS = ['"table_columns"', '"table_rows"'] as const
60
+ // 单元格输入对外回调统一防抖 2 秒,避免父层每次敲字都触发保存链路。
61
+ const TABLE_CELL_CHANGE_DEBOUNCE_MS = 2000
62
+
63
+ let {
64
+ // 暴露给外层的滚动容器引用,用于流式自动滚动。
65
+ editorScrollHost = $bindable<HTMLDivElement | null>(null),
66
+ // 原始表格内容,通常是 JSON 字符串,可能是流式中间态。
67
+ content = '',
68
+ // 结构化表格数据;存在时优先用于渲染和编辑。
69
+ tableData,
70
+ // 头部标题。
71
+ title = '',
72
+ // 外层自定义类名。
73
+ class: className = '',
74
+ // 头部状态文案。
75
+ statusText = '',
76
+ // 头部辅助文案。
77
+ metaText = '',
78
+ // 头部保存状态文案。
79
+ saveState = '',
80
+ // 是否允许编辑表格。
81
+ editable = true,
82
+ // 关闭按钮是否禁用。
83
+ closeDisabled = false,
84
+ // 关闭回调。
85
+ onclose,
86
+ // 表格编辑回调。
87
+ ontablechange,
88
+ // 表格滚动回调。
89
+ ondocumentscroll,
90
+ // 表格复制回调。
91
+ oncopytable,
92
+ // 表格下载回调。
93
+ ondownloadtable,
94
+ }: AiTableEditorProps = $props()
95
+
96
+ // 复制反馈只影响右上角复制按钮,不和业务数据混用。
97
+ let copied = $state(false)
98
+ // 连续复制时按最后一次点击重新计时。
99
+ let copyFeedbackTimer: number | undefined = $state()
100
+ // draggingRowId 只在原生拖拽生命周期内存在,用来确定当前正在移动哪一行。
101
+ let draggingRowId = $state<string | null>(null)
102
+ // dragTargetRowId 记录当前悬停的参考行,配合插入方向绘制拖拽指示线。
103
+ let dragTargetRowId = $state<string | null>(null)
104
+ // dragTargetPosition 标识应插入到参考行前面还是后面。
105
+ let dragTargetPosition = $state<AiTableRowDropPosition | null>(null)
106
+ // 本地表格草稿让输入中的单元格先在组件内即时生效,再按防抖节奏同步给外层。
107
+ let localDraftTableData = $state<AiTableData | null>(null)
108
+ // pendingCellChangePayload 保存尚未对外发出的最后一次单元格修改。
109
+ let pendingCellChangePayload = $state<AiTableEditorChangePayload | null>(null)
110
+ // 单元格修改回调的防抖计时器。
111
+ let cellChangeTimer: number | undefined = $state()
112
+ // 用上一版入参快照判断当前是“父层追平本地草稿”还是“切到了另一份表格”。
113
+ let lastIncomingTableSignature = $state('')
114
+
115
+ // 当上层没传结构化表格时,尝试从 `content` 里做容错解析。
116
+ const parsedTableData = $derived(parseTableDataFromSource(content))
117
+ // 输入源统一先归一化,方便后续比较父层快照与本地草稿是否已对齐。
118
+ const sourceTableData = $derived(
119
+ normalizeTableData(
120
+ tableData ?? parsedTableData ?? {
121
+ table_columns: [],
122
+ table_rows: [],
123
+ },
124
+ ),
125
+ )
126
+ // 渲染时优先展示本地草稿;父层追平后再回退到受控值。
127
+ const resolvedTableData = $derived(localDraftTableData ?? sourceTableData)
128
+ // 表格列定义,保持单独派生避免模板重复读取深层对象。
129
+ const tableColumns = $derived(resolvedTableData.table_columns)
130
+ // 表格行定义,保持单独派生避免模板重复读取深层对象。
131
+ const tableRows = $derived(resolvedTableData.table_rows)
132
+ // 表格主体容器类名,避免样式拼接散落在模板中。
133
+ const tablePaneClass = $derived(cn('hai-ai-table-pane', className))
134
+
135
+ $effect(() => {
136
+ return () => {
137
+ clearScheduledCellChange()
138
+ if (copyFeedbackTimer) {
139
+ window.clearTimeout(copyFeedbackTimer)
140
+ }
141
+ }
142
+ })
143
+
144
+ $effect(() => {
145
+ const incomingSignature = serializeTableDataForComparison(sourceTableData)
146
+ const previousIncomingSignature = lastIncomingTableSignature
147
+ lastIncomingTableSignature = incomingSignature
148
+
149
+ if (!localDraftTableData) {
150
+ return
151
+ }
152
+
153
+ const localSignature = serializeTableDataForComparison(localDraftTableData)
154
+ if (incomingSignature === localSignature) {
155
+ localDraftTableData = null
156
+ pendingCellChangePayload = null
157
+ clearScheduledCellChange()
158
+ return
159
+ }
160
+
161
+ // 父层切到了另一份表格时,本地未提交的输入不能继续覆盖新表格。
162
+ if (previousIncomingSignature && incomingSignature !== previousIncomingSignature) {
163
+ localDraftTableData = null
164
+ pendingCellChangePayload = null
165
+ clearScheduledCellChange()
166
+ }
167
+ })
168
+
169
+ $effect(() => {
170
+ const currentTableRows = tableRows
171
+
172
+ if (!editable || currentTableRows.length < 2) {
173
+ resetRowDragState()
174
+ return
175
+ }
176
+
177
+ if (draggingRowId && !currentTableRows.some(row => row.row_id === draggingRowId)) {
178
+ resetRowDragState()
179
+ return
180
+ }
181
+
182
+ if (dragTargetRowId && !currentTableRows.some(row => row.row_id === dragTargetRowId)) {
183
+ dragTargetRowId = null
184
+ dragTargetPosition = null
185
+ }
186
+ })
187
+
188
+ function isRecord(value: unknown): value is Record<string, unknown> {
189
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
190
+ }
191
+
192
+ function normalizeColumnType(value: unknown): AiTableColumnType {
193
+ return value === 'number' || value === 'tag' ? value : 'text'
194
+ }
195
+
196
+ function serializeTableDataForComparison(
197
+ data: AiTableData | null | undefined,
198
+ ): string {
199
+ if (!data) {
200
+ return ''
201
+ }
202
+
203
+ try {
204
+ return JSON.stringify(data)
205
+ }
206
+ catch {
207
+ return ''
208
+ }
209
+ }
210
+
211
+ /**
212
+ * 列定义归一化:
213
+ * - 过滤非对象项和空 key
214
+ * - 自动填充 label/type 缺省值
215
+ * - 基于 `key` 去重,避免流式增量里重复列导致渲染抖动
216
+ */
217
+ function normalizeTableColumns(items: unknown[]): AiTableColumn[] {
218
+ const usedKeys = new Set<string>()
219
+ const columns: AiTableColumn[] = []
220
+
221
+ for (const item of items) {
222
+ if (!isRecord(item)) {
223
+ continue
224
+ }
225
+
226
+ const key = typeof item.key === 'string' && item.key.trim()
227
+ ? item.key.trim()
228
+ : (typeof item.name === 'string' ? item.name.trim() : '')
229
+ if (!key || usedKeys.has(key)) {
230
+ continue
231
+ }
232
+
233
+ const label = typeof item.label === 'string' && item.label.trim()
234
+ ? item.label.trim()
235
+ : (typeof item.name === 'string' && item.name.trim()
236
+ ? item.name.trim()
237
+ : key)
238
+
239
+ columns.push({
240
+ key,
241
+ label,
242
+ type: normalizeColumnType(item.type),
243
+ })
244
+ usedKeys.add(key)
245
+ }
246
+
247
+ return columns
248
+ }
249
+
250
+ /**
251
+ * 行定义归一化:
252
+ * - 过滤非对象项
253
+ * - 兜底生成 row_id,保证模板 key 稳定
254
+ */
255
+ function normalizeTableRows(items: unknown[]): AiTableData['table_rows'] {
256
+ const rows: AiTableData['table_rows'] = []
257
+
258
+ for (const [index, item] of items.entries()) {
259
+ if (!isRecord(item)) {
260
+ continue
261
+ }
262
+
263
+ const rowId = typeof item.row_id === 'string' && item.row_id.trim()
264
+ ? item.row_id.trim()
265
+ : `row_${index + 1}`
266
+
267
+ rows.push({
268
+ ...item,
269
+ row_id: rowId,
270
+ })
271
+ }
272
+
273
+ return rows
274
+ }
275
+
276
+ /**
277
+ * 表格对象统一归一,避免模板层处理结构健壮性。
278
+ */
279
+ function normalizeTableData(data: AiTableData): AiTableData {
280
+ return {
281
+ table_columns: normalizeTableColumns(data.table_columns),
282
+ table_rows: normalizeTableRows(data.table_rows),
283
+ }
284
+ }
285
+
286
+ function tryParseJson(value: string): unknown {
287
+ try {
288
+ return JSON.parse(value)
289
+ }
290
+ catch {
291
+ return undefined
292
+ }
293
+ }
294
+
295
+ /**
296
+ * 从 JSON 字符串中提取指定 key 的数组片段。
297
+ * 该函数支持“流式未闭合数组”场景:拿不到 `]` 时也会返回已截取片段并标记 `complete=false`。
298
+ */
299
+ function extractArrayChunkByKey(
300
+ source: string,
301
+ key: 'columns' | 'rows' | 'table_columns' | 'table_rows',
302
+ ): ExtractedArrayChunk | null {
303
+ const keyIndex = source.indexOf(`"${key}"`)
304
+ if (keyIndex < 0) {
305
+ return null
306
+ }
307
+
308
+ const arrayStart = source.indexOf('[', keyIndex)
309
+ if (arrayStart < 0) {
310
+ return null
311
+ }
312
+
313
+ let depth = 0
314
+ let escaped = false
315
+ let inString = false
316
+
317
+ for (let index = arrayStart; index < source.length; index += 1) {
318
+ const char = source[index]
319
+
320
+ if (inString) {
321
+ if (escaped) {
322
+ escaped = false
323
+ continue
324
+ }
325
+
326
+ if (char === '\\') {
327
+ escaped = true
328
+ continue
329
+ }
330
+
331
+ if (char === '"') {
332
+ inString = false
333
+ }
334
+ continue
335
+ }
336
+
337
+ if (char === '"') {
338
+ inString = true
339
+ continue
340
+ }
341
+
342
+ if (char === '[') {
343
+ depth += 1
344
+ continue
345
+ }
346
+
347
+ if (char === ']') {
348
+ depth -= 1
349
+ if (depth === 0) {
350
+ return {
351
+ source: source.slice(arrayStart, index + 1),
352
+ complete: true,
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ return {
359
+ source: source.slice(arrayStart),
360
+ complete: false,
361
+ }
362
+ }
363
+
364
+ /**
365
+ * 当数组尚未闭合时,从片段中逐个提取已闭合对象。
366
+ * 这样能保证“行一条条流出来”时,已完成的行可以先展示。
367
+ */
368
+ function parseObjectListFromArrayChunk(
369
+ source: string,
370
+ ): Array<Record<string, unknown>> {
371
+ const objects: Array<Record<string, unknown>> = []
372
+ let objectDepth = 0
373
+ let objectStart = -1
374
+ let escaped = false
375
+ let inString = false
376
+
377
+ for (let index = 0; index < source.length; index += 1) {
378
+ const char = source[index]
379
+
380
+ if (inString) {
381
+ if (escaped) {
382
+ escaped = false
383
+ continue
384
+ }
385
+
386
+ if (char === '\\') {
387
+ escaped = true
388
+ continue
389
+ }
390
+
391
+ if (char === '"') {
392
+ inString = false
393
+ }
394
+ continue
395
+ }
396
+
397
+ if (char === '"') {
398
+ inString = true
399
+ continue
400
+ }
401
+
402
+ if (char === '{') {
403
+ if (objectDepth === 0) {
404
+ objectStart = index
405
+ }
406
+ objectDepth += 1
407
+ continue
408
+ }
409
+
410
+ if (char === '}') {
411
+ objectDepth -= 1
412
+ if (objectDepth === 0 && objectStart >= 0) {
413
+ const chunk = source.slice(objectStart, index + 1)
414
+ const parsed = tryParseJson(chunk)
415
+ if (isRecord(parsed)) {
416
+ objects.push(parsed)
417
+ }
418
+ objectStart = -1
419
+ }
420
+ }
421
+ }
422
+
423
+ return objects
424
+ }
425
+
426
+ function parseTableColumnsFromChunk(
427
+ chunk: ExtractedArrayChunk,
428
+ ): AiTableColumn[] {
429
+ if (chunk.complete) {
430
+ const parsed = tryParseJson(chunk.source)
431
+ if (Array.isArray(parsed)) {
432
+ return normalizeTableColumns(parsed)
433
+ }
434
+ }
435
+
436
+ return normalizeTableColumns(parseObjectListFromArrayChunk(chunk.source))
437
+ }
438
+
439
+ function parseTableRowsFromChunk(
440
+ chunk: ExtractedArrayChunk,
441
+ ): AiTableData['table_rows'] {
442
+ if (chunk.complete) {
443
+ const parsed = tryParseJson(chunk.source)
444
+ if (Array.isArray(parsed)) {
445
+ return normalizeTableRows(parsed)
446
+ }
447
+ }
448
+
449
+ return normalizeTableRows(parseObjectListFromArrayChunk(chunk.source))
450
+ }
451
+
452
+ /**
453
+ * 容错解析表格数据:
454
+ * 1. 优先尝试完整 JSON;
455
+ * 2. 失败后按 key 提取数组片段,支持流式中间态;
456
+ * 3. 仅当列和行都无法解析时返回 `null`。
457
+ */
458
+ function parseTableDataFromSource(source: string): AiTableData | null {
459
+ const trimmed = source.trim()
460
+ if (!trimmed) {
461
+ return null
462
+ }
463
+
464
+ const matchesCanonicalContent = CANONICAL_TABLE_CONTENT_HINTS.some(
465
+ marker => trimmed.includes(marker),
466
+ )
467
+ const matchesEditorContent = EDITOR_TABLE_CONTENT_HINTS.some(
468
+ marker => trimmed.includes(marker),
469
+ )
470
+ if (!matchesCanonicalContent && !matchesEditorContent) {
471
+ return null
472
+ }
473
+
474
+ const parsedWhole = tryParseJson(trimmed)
475
+ if (isRecord(parsedWhole)) {
476
+ const columns = Array.isArray(parsedWhole.table_columns)
477
+ ? normalizeTableColumns(parsedWhole.table_columns)
478
+ : (Array.isArray(parsedWhole.columns) ? normalizeTableColumns(parsedWhole.columns) : [])
479
+ const rows = Array.isArray(parsedWhole.table_rows)
480
+ ? normalizeTableRows(parsedWhole.table_rows)
481
+ : (Array.isArray(parsedWhole.rows) ? normalizeTableRows(parsedWhole.rows) : [])
482
+
483
+ if (columns.length > 0 || rows.length > 0) {
484
+ return {
485
+ table_columns: columns,
486
+ table_rows: rows,
487
+ }
488
+ }
489
+ }
490
+
491
+ const columnsChunk = extractArrayChunkByKey(
492
+ trimmed,
493
+ matchesEditorContent ? 'table_columns' : 'columns',
494
+ )
495
+ const rowsChunk = extractArrayChunkByKey(
496
+ trimmed,
497
+ matchesEditorContent ? 'table_rows' : 'rows',
498
+ )
499
+ if (!columnsChunk && !rowsChunk) {
500
+ return null
501
+ }
502
+
503
+ const columns = columnsChunk ? parseTableColumnsFromChunk(columnsChunk) : []
504
+ const rows = rowsChunk ? parseTableRowsFromChunk(rowsChunk) : []
505
+ if (columns.length === 0 && rows.length === 0) {
506
+ return null
507
+ }
508
+
509
+ return {
510
+ table_columns: columns,
511
+ table_rows: rows,
512
+ }
513
+ }
514
+
515
+ function getCellValue(row: AiTableData['table_rows'][number], key: string): string {
516
+ const value = row[key]
517
+ if (typeof value === 'number') {
518
+ return `${value}`
519
+ }
520
+
521
+ if (typeof value === 'string') {
522
+ return value
523
+ }
524
+
525
+ if (typeof value === 'boolean') {
526
+ return `${value}`
527
+ }
528
+
529
+ if (value === null || value === undefined) {
530
+ return ''
531
+ }
532
+
533
+ try {
534
+ return JSON.stringify(value)
535
+ }
536
+ catch {
537
+ return ''
538
+ }
539
+ }
540
+
541
+ function getCellInputValue(row: AiTableData['table_rows'][number], key: string): string {
542
+ return getCellValue(row, key)
543
+ }
544
+
545
+ function getCellExportValue(row: AiTableData['table_rows'][number], key: string): string {
546
+ return getCellValue(row, key)
547
+ }
548
+
549
+ function createGeneratedRowId(): string {
550
+ return `row_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
551
+ }
552
+
553
+ function buildNextTableDataWithCellUpdate(
554
+ data: AiTableData,
555
+ rowId: string,
556
+ columnKey: string,
557
+ value: string,
558
+ ): AiTableData {
559
+ return {
560
+ table_columns: data.table_columns,
561
+ table_rows: data.table_rows.map((row) => {
562
+ if (row.row_id !== rowId) {
563
+ return row
564
+ }
565
+
566
+ return {
567
+ ...row,
568
+ [columnKey]: value,
569
+ }
570
+ }),
571
+ }
572
+ }
573
+
574
+ function buildNextTableDataWithRowAdd(data: AiTableData): AiTableData {
575
+ const newRow: AiTableData['table_rows'][number] = {
576
+ row_id: createGeneratedRowId(),
577
+ }
578
+
579
+ for (const column of data.table_columns) {
580
+ newRow[column.key] = ''
581
+ }
582
+
583
+ return {
584
+ table_columns: data.table_columns,
585
+ table_rows: [...data.table_rows, newRow],
586
+ }
587
+ }
588
+
589
+ function buildNextTableDataWithRowDelete(
590
+ data: AiTableData,
591
+ rowId: string,
592
+ ): AiTableData {
593
+ return {
594
+ table_columns: data.table_columns,
595
+ table_rows: data.table_rows.filter(row => row.row_id !== rowId),
596
+ }
597
+ }
598
+
599
+ /**
600
+ * 行重排时先把拖动行临时移出数组,再按悬停位置重新插回。
601
+ * 这样可以统一处理“向上拖”和“向下拖”,避免索引在删除后发生偏移。
602
+ */
603
+ function buildNextTableDataWithRowReorder(
604
+ data: AiTableData,
605
+ rowId: string,
606
+ targetRowId: string,
607
+ position: AiTableRowDropPosition,
608
+ ): ReorderedTableResult {
609
+ const fromIndex = data.table_rows.findIndex(row => row.row_id === rowId)
610
+ const targetIndex = data.table_rows.findIndex(row => row.row_id === targetRowId)
611
+
612
+ if (fromIndex < 0 || targetIndex < 0 || fromIndex === targetIndex) {
613
+ return {
614
+ nextData: null,
615
+ fromIndex,
616
+ toIndex: fromIndex,
617
+ }
618
+ }
619
+
620
+ const nextRows = [...data.table_rows]
621
+ const [movedRow] = nextRows.splice(fromIndex, 1)
622
+ if (!movedRow) {
623
+ return {
624
+ nextData: null,
625
+ fromIndex,
626
+ toIndex: fromIndex,
627
+ }
628
+ }
629
+
630
+ const targetIndexAfterRemoval = nextRows.findIndex(row => row.row_id === targetRowId)
631
+ if (targetIndexAfterRemoval < 0) {
632
+ return {
633
+ nextData: null,
634
+ fromIndex,
635
+ toIndex: fromIndex,
636
+ }
637
+ }
638
+
639
+ const insertionIndex = position === 'before'
640
+ ? targetIndexAfterRemoval
641
+ : targetIndexAfterRemoval + 1
642
+
643
+ nextRows.splice(insertionIndex, 0, movedRow)
644
+ const toIndex = nextRows.findIndex(row => row.row_id === rowId)
645
+ if (toIndex === fromIndex) {
646
+ return {
647
+ nextData: null,
648
+ fromIndex,
649
+ toIndex,
650
+ }
651
+ }
652
+
653
+ return {
654
+ nextData: {
655
+ table_columns: data.table_columns,
656
+ table_rows: nextRows,
657
+ },
658
+ fromIndex,
659
+ toIndex,
660
+ }
661
+ }
662
+
663
+ function clearScheduledCellChange(): void {
664
+ if (typeof window === 'undefined' || !cellChangeTimer) {
665
+ return
666
+ }
667
+
668
+ window.clearTimeout(cellChangeTimer)
669
+ cellChangeTimer = undefined
670
+ }
671
+
672
+ function emitTableChange(payload: AiTableEditorChangePayload): void {
673
+ void ontablechange?.(payload)
674
+ }
675
+
676
+ function discardPendingCellChange(): void {
677
+ pendingCellChangePayload = null
678
+ clearScheduledCellChange()
679
+ }
680
+
681
+ export function flushPendingTableChange(): void {
682
+ const pendingPayload = pendingCellChangePayload
683
+ if (!pendingPayload) {
684
+ return
685
+ }
686
+
687
+ discardPendingCellChange()
688
+ emitTableChange(pendingPayload)
689
+ }
690
+
691
+ function queueCellChange(payload: AiTableEditorChangePayload): void {
692
+ localDraftTableData = payload.nextData
693
+ pendingCellChangePayload = payload
694
+
695
+ if (!ontablechange) {
696
+ clearScheduledCellChange()
697
+ return
698
+ }
699
+
700
+ if (typeof window === 'undefined') {
701
+ flushPendingTableChange()
702
+ return
703
+ }
704
+
705
+ clearScheduledCellChange()
706
+ cellChangeTimer = window.setTimeout(() => {
707
+ cellChangeTimer = undefined
708
+ flushPendingTableChange()
709
+ }, TABLE_CELL_CHANGE_DEBOUNCE_MS)
710
+ }
711
+
712
+ function emitImmediateTableChange(payload: AiTableEditorChangePayload): void {
713
+ localDraftTableData = payload.nextData
714
+ discardPendingCellChange()
715
+ emitTableChange(payload)
716
+ }
717
+
718
+ function resetRowDragState(): void {
719
+ draggingRowId = null
720
+ dragTargetRowId = null
721
+ dragTargetPosition = null
722
+ }
723
+
724
+ function resolveRowDropPosition(event: DragEvent): AiTableRowDropPosition | null {
725
+ const currentTarget = event.currentTarget
726
+ if (!(currentTarget instanceof HTMLTableRowElement)) {
727
+ return null
728
+ }
729
+
730
+ const rowBounds = currentTarget.getBoundingClientRect()
731
+ return event.clientY < rowBounds.top + rowBounds.height / 2 ? 'before' : 'after'
732
+ }
733
+
734
+ function updateRowDropHint(rowId: string, event: DragEvent): void {
735
+ if (!draggingRowId || draggingRowId === rowId) {
736
+ dragTargetRowId = null
737
+ dragTargetPosition = null
738
+ return
739
+ }
740
+
741
+ const position = resolveRowDropPosition(event)
742
+ if (!position) {
743
+ return
744
+ }
745
+
746
+ dragTargetRowId = rowId
747
+ dragTargetPosition = position
748
+ }
749
+
750
+ function handleCellInput(
751
+ rowId: string,
752
+ column: AiTableColumn,
753
+ event: Event,
754
+ ): void {
755
+ const target = event.currentTarget
756
+ if (!(target instanceof HTMLInputElement)) {
757
+ return
758
+ }
759
+
760
+ const value = target.value
761
+ const nextData = buildNextTableDataWithCellUpdate(
762
+ resolvedTableData,
763
+ rowId,
764
+ column.key,
765
+ value,
766
+ )
767
+
768
+ queueCellChange({
769
+ action: 'cell-update',
770
+ rowId,
771
+ columnKey: column.key,
772
+ value,
773
+ nextData,
774
+ })
775
+ }
776
+
777
+ function handleAddRow(): void {
778
+ const nextData = buildNextTableDataWithRowAdd(resolvedTableData)
779
+ const rowId = nextData.table_rows.at(-1)?.row_id
780
+
781
+ emitImmediateTableChange({
782
+ action: 'row-add',
783
+ rowId,
784
+ nextData,
785
+ })
786
+ }
787
+
788
+ function handleDeleteRow(rowId: string): void {
789
+ const nextData = buildNextTableDataWithRowDelete(resolvedTableData, rowId)
790
+
791
+ emitImmediateTableChange({
792
+ action: 'row-delete',
793
+ rowId,
794
+ nextData,
795
+ })
796
+ }
797
+
798
+ function handleRowDragStart(rowId: string, event: DragEvent): void {
799
+ if (!editable || tableRows.length < 2) {
800
+ event.preventDefault()
801
+ return
802
+ }
803
+
804
+ draggingRowId = rowId
805
+ dragTargetRowId = null
806
+ dragTargetPosition = null
807
+
808
+ if (event.dataTransfer) {
809
+ event.dataTransfer.effectAllowed = 'move'
810
+ // Firefox 需要至少写入一份文本数据,拖拽事件链才能正常继续。
811
+ event.dataTransfer.setData('text/plain', rowId)
812
+ }
813
+ }
814
+
815
+ function handleRowDragOver(rowId: string, event: DragEvent): void {
816
+ if (!draggingRowId) {
817
+ return
818
+ }
819
+
820
+ event.preventDefault()
821
+ updateRowDropHint(rowId, event)
822
+
823
+ if (event.dataTransfer) {
824
+ event.dataTransfer.dropEffect = 'move'
825
+ }
826
+ }
827
+
828
+ function handleRowDrop(rowId: string, event: DragEvent): void {
829
+ if (!draggingRowId) {
830
+ return
831
+ }
832
+
833
+ event.preventDefault()
834
+ const position = resolveRowDropPosition(event)
835
+ if (!position) {
836
+ resetRowDragState()
837
+ return
838
+ }
839
+
840
+ const draggedRowId = draggingRowId
841
+ const result = buildNextTableDataWithRowReorder(
842
+ resolvedTableData,
843
+ draggedRowId,
844
+ rowId,
845
+ position,
846
+ )
847
+
848
+ resetRowDragState()
849
+ if (!result.nextData) {
850
+ return
851
+ }
852
+
853
+ emitImmediateTableChange({
854
+ action: 'row-reorder',
855
+ rowId: draggedRowId,
856
+ targetRowId: rowId,
857
+ fromIndex: result.fromIndex,
858
+ toIndex: result.toIndex,
859
+ nextData: result.nextData,
860
+ })
861
+ }
862
+
863
+ function isDropIndicatorVisible(
864
+ rowId: string,
865
+ position: AiTableRowDropPosition,
866
+ ): boolean {
867
+ return dragTargetRowId === rowId && dragTargetPosition === position
868
+ }
869
+
870
+ function serializeTableAsTsv(data: AiTableData): string {
871
+ const header = data.table_columns.map(column => column.label).join('\t')
872
+ const body = data.table_rows.map((row) => {
873
+ return data.table_columns
874
+ .map(column => getCellExportValue(row, column.key).replace(/\t/g, ' '))
875
+ .join('\t')
876
+ })
877
+
878
+ return [header, ...body].filter(Boolean).join('\n')
879
+ }
880
+
881
+ function escapeCsvCell(value: string): string {
882
+ return `"${value.replace(CSV_DOUBLE_QUOTE_REGEX, '""')}"`
883
+ }
884
+
885
+ function serializeTableAsCsv(data: AiTableData): string {
886
+ const header = data.table_columns
887
+ .map(column => escapeCsvCell(column.label))
888
+ .join(',')
889
+
890
+ const body = data.table_rows.map((row) => {
891
+ return data.table_columns
892
+ .map(column => escapeCsvCell(getCellExportValue(row, column.key)))
893
+ .join(',')
894
+ })
895
+
896
+ return [header, ...body].filter(Boolean).join('\n')
897
+ }
898
+
899
+ function sanitizeFilename(value: string): string {
900
+ const normalized = value
901
+ .trim()
902
+ .replace(INVALID_FILENAME_CHARS_REGEX, ' ')
903
+ .replace(/\s+/g, ' ')
904
+ .replace(/[. ]+$/g, '')
905
+ .trim()
906
+
907
+ return normalized || 'ai-table'
908
+ }
909
+
910
+ function resolveDownloadFilename(): string {
911
+ return `${sanitizeFilename(title || 'ai-table')}.csv`
912
+ }
913
+
914
+ function resolveTableDownloadActions(): MarkdownToolbarDownloadAction[] {
915
+ return [
916
+ {
917
+ id: TABLE_DOWNLOAD_ACTION_ID,
918
+ label: uiM('table_download'),
919
+ badgeLabel: 'CSV',
920
+ },
921
+ ]
922
+ }
923
+
924
+ function triggerCopiedFeedback(): void {
925
+ copied = true
926
+ if (copyFeedbackTimer) {
927
+ window.clearTimeout(copyFeedbackTimer)
928
+ }
929
+
930
+ copyFeedbackTimer = window.setTimeout(() => {
931
+ copied = false
932
+ copyFeedbackTimer = undefined
933
+ }, 1800)
934
+ }
935
+
936
+ function downloadBlob(content: string, filename: string): void {
937
+ if (typeof document === 'undefined') {
938
+ return
939
+ }
940
+
941
+ const blob = new Blob([content], { type: 'text/csv;charset=utf-8' })
942
+ const url = URL.createObjectURL(blob)
943
+ const anchor = document.createElement('a')
944
+ anchor.href = url
945
+ anchor.download = filename
946
+ anchor.rel = 'noopener'
947
+ anchor.click()
948
+ window.setTimeout(() => {
949
+ URL.revokeObjectURL(url)
950
+ }, 1000)
951
+ }
952
+
953
+ async function handleCopyTable(): Promise<void> {
954
+ const text = serializeTableAsTsv(resolvedTableData)
955
+ if (!text) {
956
+ return
957
+ }
958
+
959
+ if (oncopytable) {
960
+ await oncopytable({
961
+ data: resolvedTableData,
962
+ text,
963
+ })
964
+ triggerCopiedFeedback()
965
+ return
966
+ }
967
+
968
+ if (typeof navigator === 'undefined' || !navigator.clipboard) {
969
+ return
970
+ }
971
+
972
+ await navigator.clipboard.writeText(text)
973
+ triggerCopiedFeedback()
974
+ }
975
+
976
+ async function handleDownloadTable(): Promise<void> {
977
+ const csv = serializeTableAsCsv(resolvedTableData)
978
+ if (!csv) {
979
+ return
980
+ }
981
+
982
+ const filename = resolveDownloadFilename()
983
+ const payload: AiTableDownloadPayload = {
984
+ data: resolvedTableData,
985
+ csv,
986
+ filename,
987
+ }
988
+
989
+ if (ondownloadtable) {
990
+ await ondownloadtable(payload)
991
+ return
992
+ }
993
+
994
+ downloadBlob(csv, filename)
995
+ }
996
+
997
+ async function handleDownloadAction(actionId: string): Promise<void> {
998
+ if (actionId !== TABLE_DOWNLOAD_ACTION_ID) {
999
+ return
1000
+ }
1001
+
1002
+ await handleDownloadTable()
1003
+ }
1004
+
1005
+ function resolveCellInputClass(type: AiTableColumnType): string {
1006
+ if (type === 'tag') {
1007
+ return 'hai-ai-table-cell-input hai-ai-table-cell-input--tag'
1008
+ }
1009
+
1010
+ if (type === 'number') {
1011
+ return 'hai-ai-table-cell-input hai-ai-table-cell-input--number'
1012
+ }
1013
+
1014
+ return 'hai-ai-table-cell-input'
1015
+ }
1016
+
1017
+ function resolveBodyRowClass(rowId: string): string {
1018
+ return cn(
1019
+ 'hai-ai-table-body-row',
1020
+ draggingRowId === rowId ? 'hai-ai-table-body-row--dragging' : '',
1021
+ isDropIndicatorVisible(rowId, 'before')
1022
+ ? 'hai-ai-table-body-row--drop-before'
1023
+ : '',
1024
+ isDropIndicatorVisible(rowId, 'after')
1025
+ ? 'hai-ai-table-body-row--drop-after'
1026
+ : '',
1027
+ )
1028
+ }
1029
+ </script>
1030
+
1031
+ <section class={tablePaneClass}>
1032
+ <header class='hai-ai-table-header'>
1033
+ <div class='hai-ai-table-heading'>
1034
+ <div class='hai-ai-table-eyebrow'>
1035
+ {metaText || uiM('table_editor_eyebrow')}
1036
+ </div>
1037
+ <h2 class='hai-ai-table-title' title={title || uiM('data_table_empty')}>
1038
+ {title || uiM('data_table_empty')}
1039
+ </h2>
1040
+ {#if statusText}
1041
+ <p class='hai-ai-table-status'>{statusText}</p>
1042
+ {/if}
1043
+ </div>
1044
+
1045
+ <div class='hai-ai-table-toolbar'>
1046
+ {#if saveState}
1047
+ <span class='hai-ai-table-save-state'>{saveState}</span>
1048
+ {/if}
1049
+
1050
+ <button
1051
+ type='button'
1052
+ class='hai-ai-table-toolbar-btn'
1053
+ title={uiM('table_copy')}
1054
+ aria-label={uiM('table_copy')}
1055
+ onclick={() => {
1056
+ void handleCopyTable()
1057
+ }}
1058
+ >
1059
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- 受控 SVG 图标渲染 -->
1060
+ {@html copied ? CHECK_ICON : COPY_ICON}
1061
+ </button>
1062
+
1063
+ <AiDocumentDownloadMenu
1064
+ actions={resolveTableDownloadActions()}
1065
+ ondownload={handleDownloadAction}
1066
+ triggerTitle={uiM('table_download')}
1067
+ triggerClass='hai-ai-table-toolbar-btn hai-ai-table-toolbar-btn--download'
1068
+ menuClass='hai-ai-table-download-menu-panel'
1069
+ />
1070
+
1071
+ {#if onclose}
1072
+ <button
1073
+ type='button'
1074
+ class='hai-ai-table-toolbar-btn'
1075
+ title={uiM('table_close')}
1076
+ aria-label={uiM('table_close')}
1077
+ disabled={closeDisabled}
1078
+ onclick={() => {
1079
+ flushPendingTableChange()
1080
+ onclose?.()
1081
+ }}
1082
+ >
1083
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- 受控 SVG 图标渲染 -->
1084
+ {@html CLOSE_ICON}
1085
+ </button>
1086
+ {/if}
1087
+ </div>
1088
+ </header>
1089
+
1090
+ <div
1091
+ class='hai-ai-table-scroll'
1092
+ bind:this={editorScrollHost}
1093
+ onscroll={ondocumentscroll}
1094
+ >
1095
+ <table class='hai-ai-table-grid'>
1096
+ <thead>
1097
+ <tr>
1098
+ {#each tableColumns as column}
1099
+ <th>{column.label}</th>
1100
+ {/each}
1101
+ {#if editable}
1102
+ <th class='hai-ai-table-action-head'>{uiM('data_table_actions')}</th>
1103
+ {/if}
1104
+ </tr>
1105
+ </thead>
1106
+ <tbody>
1107
+ {#if tableRows.length === 0}
1108
+ <tr>
1109
+ <td
1110
+ class='hai-ai-table-empty'
1111
+ colspan={tableColumns.length + (editable ? 1 : 0)}
1112
+ >
1113
+ {uiM('data_table_empty')}
1114
+ </td>
1115
+ </tr>
1116
+ {:else}
1117
+ {#each tableRows as row (row.row_id)}
1118
+ <tr
1119
+ class={resolveBodyRowClass(row.row_id)}
1120
+ ondragover={event => handleRowDragOver(row.row_id, event)}
1121
+ ondrop={event => handleRowDrop(row.row_id, event)}
1122
+ >
1123
+ {#each tableColumns as column}
1124
+ <td>
1125
+ <input
1126
+ class={resolveCellInputClass(column.type)}
1127
+ type={column.type === 'number' ? 'number' : 'text'}
1128
+ value={getCellInputValue(row, column.key)}
1129
+ disabled={!editable}
1130
+ oninput={event => handleCellInput(row.row_id, column, event)}
1131
+ />
1132
+ </td>
1133
+ {/each}
1134
+ {#if editable}
1135
+ <td class='hai-ai-table-row-action'>
1136
+ <div class='hai-ai-table-row-actions'>
1137
+ <button
1138
+ type='button'
1139
+ class='hai-ai-table-row-handle'
1140
+ title={uiM('table_reorder_row')}
1141
+ aria-label={uiM('table_reorder_row')}
1142
+ aria-grabbed={draggingRowId === row.row_id}
1143
+ disabled={tableRows.length < 2}
1144
+ draggable={tableRows.length > 1}
1145
+ ondragstart={event => handleRowDragStart(row.row_id, event)}
1146
+ ondragend={resetRowDragState}
1147
+ >
1148
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- 受控 SVG 图标渲染 -->
1149
+ {@html DRAG_HANDLE_ICON}
1150
+ </button>
1151
+
1152
+ <button
1153
+ type='button'
1154
+ class='hai-ai-table-row-delete'
1155
+ title={uiM('table_delete_row')}
1156
+ aria-label={uiM('table_delete_row')}
1157
+ onclick={() => handleDeleteRow(row.row_id)}
1158
+ >
1159
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- 受控 SVG 图标渲染 -->
1160
+ {@html DELETE_ICON}
1161
+ </button>
1162
+ </div>
1163
+ </td>
1164
+ {/if}
1165
+ </tr>
1166
+ {/each}
1167
+ {/if}
1168
+
1169
+ {#if editable}
1170
+ <tr class='hai-ai-table-add-row'>
1171
+ <td colspan={tableColumns.length + 1}>
1172
+ <button
1173
+ type='button'
1174
+ class='hai-ai-table-add-btn'
1175
+ title={uiM('table_add_row')}
1176
+ aria-label={uiM('table_add_row')}
1177
+ onclick={handleAddRow}
1178
+ >
1179
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- 受控 SVG 图标渲染 -->
1180
+ {@html PLUS_ICON}
1181
+ <span>{uiM('table_add_row')}</span>
1182
+ </button>
1183
+ </td>
1184
+ </tr>
1185
+ {/if}
1186
+ </tbody>
1187
+ </table>
1188
+ </div>
1189
+ </section>
1190
+
1191
+ <style>
1192
+ .hai-ai-table-pane {
1193
+ /*
1194
+ 主题变量兼容层:
1195
+ - hai-mate Web 端主要使用 daisyUI v5 的 `--color-*` 语义变量;
1196
+ - 组件库内部分场景仍可能提供旧版 `--b1/--bc/--p` 通道变量;
1197
+ - 这里统一抽象为 AiTableEditor 私有变量,确保在两套主题体系下都能稳定着色。
1198
+ */
1199
+ --hai-ai-table-bg: var(--color-base-100, oklch(var(--b1, 1 0 0)));
1200
+ --hai-ai-table-bg-soft: var(--color-base-200, oklch(var(--b2, 0.97 0 0)));
1201
+ --hai-ai-table-fg: var(--color-base-content, oklch(var(--bc, 0.22 0 0)));
1202
+ --hai-ai-table-primary: var(--color-primary, oklch(var(--p, 0.62 0.2 258)));
1203
+ --hai-ai-table-error: var(--color-error, oklch(var(--er, 0.64 0.2 25)));
1204
+
1205
+ display: flex;
1206
+ min-height: 0;
1207
+ height: 100%;
1208
+ flex-direction: column;
1209
+ border: 1px solid color-mix(in srgb, var(--hai-ai-table-fg) 8%, transparent);
1210
+ border-radius: 1.5rem;
1211
+ background: linear-gradient(
1212
+ 180deg,
1213
+ var(--hai-ai-table-bg) 0%,
1214
+ color-mix(in srgb, var(--hai-ai-table-bg) 92%, white 8%) 100%
1215
+ );
1216
+ overflow: hidden;
1217
+ box-shadow: 0 14px 40px color-mix(in srgb, var(--hai-ai-table-fg) 6%, transparent);
1218
+ }
1219
+
1220
+ .hai-ai-table-header {
1221
+ display: flex;
1222
+ align-items: center;
1223
+ justify-content: space-between;
1224
+ gap: 1rem;
1225
+ padding: 1rem 1.25rem;
1226
+ border-bottom: 1px solid color-mix(in srgb, var(--hai-ai-table-fg) 8%, transparent);
1227
+ background: color-mix(in srgb, var(--hai-ai-table-bg) 90%, white 10%);
1228
+ }
1229
+
1230
+ .hai-ai-table-heading {
1231
+ min-width: 0;
1232
+ }
1233
+
1234
+ .hai-ai-table-eyebrow {
1235
+ font-size: 0.73rem;
1236
+ font-weight: 600;
1237
+ color: color-mix(in srgb, var(--hai-ai-table-fg) 58%, transparent);
1238
+ letter-spacing: 0.06em;
1239
+ text-transform: uppercase;
1240
+ }
1241
+
1242
+ .hai-ai-table-title {
1243
+ margin: 0.25rem 0 0;
1244
+ font-size: 1.05rem;
1245
+ font-weight: 700;
1246
+ color: var(--hai-ai-table-fg);
1247
+ line-height: 1.35;
1248
+ max-width: min(52vw, 36rem);
1249
+ overflow: hidden;
1250
+ text-overflow: ellipsis;
1251
+ white-space: nowrap;
1252
+ }
1253
+
1254
+ .hai-ai-table-status {
1255
+ margin: 0.2rem 0 0;
1256
+ font-size: 0.78rem;
1257
+ color: color-mix(in srgb, var(--hai-ai-table-fg) 58%, transparent);
1258
+ white-space: nowrap;
1259
+ overflow: hidden;
1260
+ text-overflow: ellipsis;
1261
+ }
1262
+
1263
+ .hai-ai-table-toolbar {
1264
+ display: flex;
1265
+ align-items: center;
1266
+ gap: 0.55rem;
1267
+ flex-shrink: 0;
1268
+ }
1269
+
1270
+ .hai-ai-table-save-state {
1271
+ display: inline-flex;
1272
+ align-items: center;
1273
+ min-height: 2.2rem;
1274
+ padding: 0 0.82rem;
1275
+ border-radius: 9999px;
1276
+ border: 1px solid color-mix(in srgb, var(--hai-ai-table-primary) 16%, var(--hai-ai-table-bg) 84%);
1277
+ background: color-mix(in srgb, var(--hai-ai-table-primary) 10%, var(--hai-ai-table-bg) 90%);
1278
+ color: var(--hai-ai-table-primary);
1279
+ font-size: 0.78rem;
1280
+ font-weight: 600;
1281
+ white-space: nowrap;
1282
+ }
1283
+
1284
+ .hai-ai-table-toolbar-btn {
1285
+ width: 2.2rem;
1286
+ height: 2.2rem;
1287
+ display: inline-flex;
1288
+ align-items: center;
1289
+ justify-content: center;
1290
+ border-radius: 0.82rem;
1291
+ border: 1px solid color-mix(in srgb, var(--hai-ai-table-fg) 10%, transparent);
1292
+ background: var(--hai-ai-table-bg);
1293
+ color: color-mix(in srgb, var(--hai-ai-table-fg) 82%, transparent);
1294
+ cursor: pointer;
1295
+ transition:
1296
+ transform 0.15s ease,
1297
+ box-shadow 0.15s ease,
1298
+ border-color 0.15s ease,
1299
+ background-color 0.15s ease,
1300
+ color 0.15s ease;
1301
+ }
1302
+
1303
+ .hai-ai-table-toolbar-btn :global(svg) {
1304
+ width: 1.02rem;
1305
+ height: 1.02rem;
1306
+ fill: currentColor;
1307
+ }
1308
+
1309
+ .hai-ai-table-toolbar-btn:hover:not(:disabled) {
1310
+ transform: translateY(-1px);
1311
+ color: var(--hai-ai-table-fg);
1312
+ border-color: color-mix(in srgb, var(--hai-ai-table-fg) 18%, transparent);
1313
+ background: color-mix(in srgb, var(--hai-ai-table-bg-soft) 70%, var(--hai-ai-table-bg) 30%);
1314
+ box-shadow: 0 14px 24px -20px color-mix(in srgb, var(--hai-ai-table-fg) 42%, transparent);
1315
+ }
1316
+
1317
+ .hai-ai-table-toolbar-btn:disabled {
1318
+ cursor: not-allowed;
1319
+ opacity: 0.6;
1320
+ }
1321
+
1322
+ :global(.hai-ai-download-menu__trigger.hai-ai-table-toolbar-btn) {
1323
+ width: 2.2rem;
1324
+ min-width: 2.2rem;
1325
+ height: 2.2rem;
1326
+ min-height: 0;
1327
+ padding: 0;
1328
+ display: inline-flex;
1329
+ align-items: center;
1330
+ justify-content: center;
1331
+ border-radius: 0.82rem;
1332
+ border-color: color-mix(in srgb, var(--hai-ai-table-fg) 10%, transparent);
1333
+ background: var(--hai-ai-table-bg);
1334
+ color: color-mix(in srgb, var(--hai-ai-table-fg) 82%, transparent);
1335
+ box-shadow: none;
1336
+ box-sizing: border-box;
1337
+ }
1338
+
1339
+ :global(.hai-ai-download-menu__trigger.hai-ai-table-toolbar-btn:hover) {
1340
+ transform: translateY(-1px);
1341
+ color: var(--hai-ai-table-fg);
1342
+ border-color: color-mix(in srgb, var(--hai-ai-table-fg) 18%, transparent);
1343
+ background: color-mix(in srgb, var(--hai-ai-table-bg-soft) 70%, var(--hai-ai-table-bg) 30%);
1344
+ box-shadow: 0 14px 24px -20px color-mix(in srgb, var(--hai-ai-table-fg) 42%, transparent);
1345
+ }
1346
+
1347
+ :global(.hai-ai-download-menu__trigger.hai-ai-table-toolbar-btn:focus-visible) {
1348
+ outline: 2px solid color-mix(in srgb, var(--hai-ai-table-primary) 28%, transparent);
1349
+ outline-offset: 2px;
1350
+ }
1351
+
1352
+ :global(.hai-ai-download-menu__trigger.hai-ai-table-toolbar-btn .hai-ai-download-menu__trigger-icon) {
1353
+ width: 1.02rem;
1354
+ height: 1.02rem;
1355
+ }
1356
+
1357
+ :global(.hai-ai-table-download-menu-panel) {
1358
+ border-color: color-mix(in srgb, var(--hai-ai-table-fg) 10%, var(--hai-ai-table-bg) 90%);
1359
+ border-radius: 1.2rem;
1360
+ }
1361
+
1362
+ .hai-ai-table-scroll {
1363
+ min-height: 0;
1364
+ flex: 1;
1365
+ overflow: auto;
1366
+ padding: 1rem 1.15rem 1.25rem;
1367
+ background:
1368
+ radial-gradient(
1369
+ circle at 12% 6%,
1370
+ color-mix(in srgb, var(--hai-ai-table-primary) 10%, transparent) 0%,
1371
+ transparent 46%
1372
+ ),
1373
+ var(--hai-ai-table-bg);
1374
+ }
1375
+
1376
+ .hai-ai-table-grid {
1377
+ width: 100%;
1378
+ min-width: 46rem;
1379
+ border-collapse: collapse;
1380
+ border-spacing: 0;
1381
+ /* 先提供稳定回退色,再叠加 color-mix,避免低版本浏览器整条规则失效。 */
1382
+ border: 1px solid color-mix(in srgb, var(--hai-ai-table-fg) 20%, transparent);
1383
+ border: 1px solid color-mix(in srgb, var(--hai-ai-table-fg) 14%, var(--hai-ai-table-bg) 86%);
1384
+ border-radius: 1rem;
1385
+ overflow: hidden;
1386
+ background: var(--hai-ai-table-bg);
1387
+ box-shadow: 0 1px 0 color-mix(in srgb, var(--hai-ai-table-fg) 6%, transparent);
1388
+ }
1389
+
1390
+ .hai-ai-table-grid thead th {
1391
+ position: sticky;
1392
+ top: 0;
1393
+ z-index: 1;
1394
+ text-align: left;
1395
+ font-size: 0.82rem;
1396
+ font-weight: 700;
1397
+ color: color-mix(in srgb, var(--hai-ai-table-fg) 84%, transparent);
1398
+ background: var(--hai-ai-table-bg-soft);
1399
+ background: color-mix(in srgb, var(--hai-ai-table-primary) 14%, var(--hai-ai-table-bg-soft) 86%);
1400
+ padding: 0.76rem 0.78rem;
1401
+ border-bottom: 1px solid color-mix(in srgb, var(--hai-ai-table-fg) 18%, transparent);
1402
+ border-bottom: 1px solid color-mix(in srgb, var(--hai-ai-table-fg) 14%, var(--hai-ai-table-bg) 86%);
1403
+ }
1404
+
1405
+ .hai-ai-table-grid thead th + th {
1406
+ border-left: 1px solid color-mix(in srgb, var(--hai-ai-table-fg) 15%, transparent);
1407
+ border-left: 1px solid color-mix(in srgb, var(--hai-ai-table-fg) 11%, var(--hai-ai-table-bg) 89%);
1408
+ }
1409
+
1410
+ .hai-ai-table-grid tbody td {
1411
+ border-bottom: 1px solid color-mix(in srgb, var(--hai-ai-table-fg) 14%, transparent);
1412
+ border-bottom: 1px solid color-mix(in srgb, var(--hai-ai-table-fg) 11%, var(--hai-ai-table-bg) 89%);
1413
+ border-left: 1px solid color-mix(in srgb, var(--hai-ai-table-fg) 14%, transparent);
1414
+ border-left: 1px solid color-mix(in srgb, var(--hai-ai-table-fg) 11%, var(--hai-ai-table-bg) 89%);
1415
+ padding: 0.48rem 0.56rem;
1416
+ background: var(--hai-ai-table-bg);
1417
+ }
1418
+
1419
+ .hai-ai-table-grid tbody td:first-child {
1420
+ border-left: none;
1421
+ }
1422
+
1423
+ .hai-ai-table-grid tbody tr:last-child td {
1424
+ border-bottom: none;
1425
+ }
1426
+
1427
+ .hai-ai-table-cell-input {
1428
+ width: 100%;
1429
+ border: 1px solid transparent;
1430
+ border-radius: 0.65rem;
1431
+ padding: 0.42rem 0.5rem;
1432
+ font-size: 0.88rem;
1433
+ line-height: 1.4;
1434
+ color: color-mix(in srgb, var(--hai-ai-table-fg) 90%, transparent);
1435
+ background: transparent;
1436
+ transition:
1437
+ border-color 0.15s ease,
1438
+ background-color 0.15s ease,
1439
+ box-shadow 0.15s ease;
1440
+ }
1441
+
1442
+ .hai-ai-table-cell-input:hover:enabled {
1443
+ border-color: color-mix(in srgb, var(--hai-ai-table-fg) 12%, var(--hai-ai-table-bg) 88%);
1444
+ background: color-mix(in srgb, var(--hai-ai-table-bg-soft) 70%, var(--hai-ai-table-bg) 30%);
1445
+ }
1446
+
1447
+ .hai-ai-table-cell-input:focus-visible {
1448
+ outline: none;
1449
+ border-color: color-mix(in srgb, var(--hai-ai-table-primary) 58%, var(--hai-ai-table-bg) 42%);
1450
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--hai-ai-table-primary) 18%, transparent);
1451
+ background: color-mix(in srgb, var(--hai-ai-table-bg) 88%, white 12%);
1452
+ }
1453
+
1454
+ .hai-ai-table-cell-input:disabled {
1455
+ cursor: default;
1456
+ color: color-mix(in srgb, var(--hai-ai-table-fg) 72%, transparent);
1457
+ }
1458
+
1459
+ .hai-ai-table-cell-input--tag {
1460
+ background: color-mix(in srgb, var(--hai-ai-table-primary) 10%, var(--hai-ai-table-bg) 90%);
1461
+ color: var(--hai-ai-table-primary);
1462
+ font-weight: 600;
1463
+ }
1464
+
1465
+ .hai-ai-table-cell-input--number {
1466
+ text-align: right;
1467
+ font-variant-numeric: tabular-nums;
1468
+ }
1469
+
1470
+ .hai-ai-table-body-row {
1471
+ transition: background-color 0.15s ease, opacity 0.15s ease;
1472
+ }
1473
+
1474
+ .hai-ai-table-body-row--dragging td {
1475
+ background: color-mix(in srgb, var(--hai-ai-table-bg-soft) 72%, var(--hai-ai-table-bg) 28%);
1476
+ opacity: 0.7;
1477
+ }
1478
+
1479
+ .hai-ai-table-body-row--drop-before td {
1480
+ box-shadow: inset 0 3px 0 color-mix(in srgb, var(--hai-ai-table-primary) 50%, transparent);
1481
+ }
1482
+
1483
+ .hai-ai-table-body-row--drop-after td {
1484
+ box-shadow: inset 0 -3px 0 color-mix(in srgb, var(--hai-ai-table-primary) 50%, transparent);
1485
+ }
1486
+
1487
+ .hai-ai-table-action-head,
1488
+ .hai-ai-table-row-action {
1489
+ width: 5.2rem;
1490
+ min-width: 5.2rem;
1491
+ text-align: center;
1492
+ }
1493
+
1494
+ .hai-ai-table-row-actions {
1495
+ display: inline-flex;
1496
+ align-items: center;
1497
+ justify-content: center;
1498
+ gap: 0.38rem;
1499
+ }
1500
+
1501
+ .hai-ai-table-row-handle,
1502
+ .hai-ai-table-row-delete {
1503
+ width: 1.95rem;
1504
+ height: 1.95rem;
1505
+ display: inline-flex;
1506
+ align-items: center;
1507
+ justify-content: center;
1508
+ border-radius: 9999px;
1509
+ border: 1px solid color-mix(in srgb, var(--hai-ai-table-fg) 18%, var(--hai-ai-table-bg) 82%);
1510
+ color: color-mix(in srgb, var(--hai-ai-table-fg) 66%, transparent);
1511
+ background: color-mix(in srgb, var(--hai-ai-table-bg) 80%, var(--hai-ai-table-bg-soft) 20%);
1512
+ cursor: pointer;
1513
+ transition:
1514
+ opacity 0.12s ease,
1515
+ transform 0.12s ease,
1516
+ color 0.15s ease,
1517
+ border-color 0.15s ease,
1518
+ background-color 0.15s ease;
1519
+ }
1520
+
1521
+ .hai-ai-table-row-handle {
1522
+ cursor: grab;
1523
+ opacity: 0.82;
1524
+ }
1525
+
1526
+ .hai-ai-table-row-handle:active {
1527
+ cursor: grabbing;
1528
+ }
1529
+
1530
+ .hai-ai-table-row-handle:disabled {
1531
+ cursor: not-allowed;
1532
+ opacity: 0.45;
1533
+ }
1534
+
1535
+ .hai-ai-table-row-handle :global(svg),
1536
+ .hai-ai-table-row-delete :global(svg) {
1537
+ width: 0.86rem;
1538
+ height: 0.86rem;
1539
+ fill: currentColor;
1540
+ }
1541
+
1542
+ .hai-ai-table-body-row:hover .hai-ai-table-row-handle:not(:disabled) {
1543
+ opacity: 1;
1544
+ }
1545
+
1546
+ .hai-ai-table-body-row:hover .hai-ai-table-row-delete {
1547
+ opacity: 1;
1548
+ }
1549
+
1550
+ .hai-ai-table-row-actions:focus-within .hai-ai-table-row-delete {
1551
+ opacity: 1;
1552
+ }
1553
+
1554
+ .hai-ai-table-row-handle:hover:not(:disabled),
1555
+ .hai-ai-table-row-delete:hover {
1556
+ transform: translateY(-1px);
1557
+ }
1558
+
1559
+ .hai-ai-table-row-handle:focus-visible,
1560
+ .hai-ai-table-row-delete:focus-visible {
1561
+ outline: 2px solid color-mix(in srgb, var(--hai-ai-table-primary) 26%, transparent);
1562
+ outline-offset: 2px;
1563
+ }
1564
+
1565
+ .hai-ai-table-row-handle:hover:not(:disabled) {
1566
+ color: var(--hai-ai-table-fg);
1567
+ border-color: color-mix(in srgb, var(--hai-ai-table-primary) 24%, var(--hai-ai-table-bg) 76%);
1568
+ background: color-mix(in srgb, var(--hai-ai-table-primary) 8%, var(--hai-ai-table-bg) 92%);
1569
+ }
1570
+
1571
+ .hai-ai-table-row-delete {
1572
+ opacity: 0.38;
1573
+ }
1574
+
1575
+ .hai-ai-table-row-delete:hover {
1576
+ color: var(--hai-ai-table-error);
1577
+ border-color: color-mix(in srgb, var(--hai-ai-table-error) 42%, var(--hai-ai-table-bg) 58%);
1578
+ background: color-mix(in srgb, var(--hai-ai-table-error) 9%, var(--hai-ai-table-bg) 91%);
1579
+ }
1580
+
1581
+ .hai-ai-table-empty {
1582
+ text-align: center;
1583
+ color: color-mix(in srgb, var(--hai-ai-table-fg) 52%, transparent);
1584
+ font-size: 0.84rem;
1585
+ padding: 1.15rem 0.75rem;
1586
+ background: color-mix(in srgb, var(--hai-ai-table-bg-soft) 52%, var(--hai-ai-table-bg) 48%);
1587
+ }
1588
+
1589
+ .hai-ai-table-add-row td {
1590
+ padding: 0.58rem 0.62rem;
1591
+ background: color-mix(in srgb, var(--hai-ai-table-bg-soft) 45%, var(--hai-ai-table-bg) 55%);
1592
+ }
1593
+
1594
+ .hai-ai-table-add-btn {
1595
+ width: 100%;
1596
+ height: 2rem;
1597
+ display: inline-flex;
1598
+ align-items: center;
1599
+ justify-content: center;
1600
+ gap: 0.45rem;
1601
+ border: 1px dashed color-mix(in srgb, var(--hai-ai-table-primary) 35%, var(--hai-ai-table-bg) 65%);
1602
+ border-radius: 0.66rem;
1603
+ color: var(--hai-ai-table-primary);
1604
+ font-size: 0.82rem;
1605
+ font-weight: 600;
1606
+ background: color-mix(in srgb, var(--hai-ai-table-primary) 7%, var(--hai-ai-table-bg) 93%);
1607
+ cursor: pointer;
1608
+ transition:
1609
+ border-color 0.15s ease,
1610
+ background-color 0.15s ease,
1611
+ transform 0.15s ease;
1612
+ }
1613
+
1614
+ .hai-ai-table-add-btn :global(svg) {
1615
+ width: 0.92rem;
1616
+ height: 0.92rem;
1617
+ fill: currentColor;
1618
+ }
1619
+
1620
+ .hai-ai-table-add-btn:hover {
1621
+ transform: translateY(-1px);
1622
+ border-color: color-mix(in srgb, var(--hai-ai-table-primary) 52%, var(--hai-ai-table-bg) 48%);
1623
+ background: color-mix(in srgb, var(--hai-ai-table-primary) 11%, var(--hai-ai-table-bg) 89%);
1624
+ }
1625
+
1626
+ @media (max-width: 768px) {
1627
+ .hai-ai-table-header {
1628
+ flex-wrap: wrap;
1629
+ align-items: flex-start;
1630
+ }
1631
+
1632
+ .hai-ai-table-title {
1633
+ max-width: min(76vw, 22rem);
1634
+ }
1635
+
1636
+ .hai-ai-table-grid {
1637
+ min-width: 36rem;
1638
+ }
1639
+ }
1640
+ </style>