@h-ai/ui 0.1.0-alpha.40 → 0.1.0-alpha.41

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.
package/README.md CHANGED
@@ -181,7 +181,7 @@ components/
181
181
  | 组件 | 描述 | 主要属性 |
182
182
  | ----------- | -------- | ---------------------------------------- |
183
183
  | `Card` | 卡片容器 | `title`, `bordered`, `shadow`, `padding` |
184
- | `DataTable` | 数据表格 | `data`, `columns`, `keyField`, `loading`, `sortKey`, `sortDir`, `onsort`(列 `sortable` 可排序) |
184
+ | `DataTable` | 数据表格 | `data`, `columns`, `keyField`, `loading`, `sortKey`, `sortDir`, `onsort`,`cell` 单元格插槽、`onrowclick`(列 `sortable` 可排序) |
185
185
  | `Accordion` | 手风琴 | `items: AccordionItem[]` |
186
186
  | `Timeline` | 时间线 | `items: TimelineItem[]` |
187
187
 
@@ -334,13 +334,17 @@ components/
334
334
 
335
335
  | 组件 | 描述 | 主要属性 / 能力 |
336
336
  | -------------------- | ------------ | ---------------- |
337
- | `CrudPage` | CRUD 主页面 | `crud`, `data`, `permissions`, `form`, `pagination`, `density`, `nav` |
337
+ | `CrudPage` | CRUD 主页面 | `crud`, `data`, `permissions`, `form`, `pagination`, `density`, `showHeader`, `toolbarStyle`, `toolbarLeading`, `toolbarActions`, `card`, `tableCell`, `onrowclick`, `sortableColumns`, `nav` |
338
338
  | `CrudFilterBar` | 过滤工具栏(搜索 + 多类型过滤 + 重置) | `filterFields`, `searchValue`, `onsearch`, `onfilterchange`, `onreset` |
339
339
  | `CrudDetailPanel` | 详情面板(抽屉/弹窗) | `open`, `item`, `fields`, `variant`, `drawerWidth`, `modalSize` |
340
340
  | `CrudEditPanel` | 编辑面板(抽屉/弹窗) | `open`, `fields`, `variant`, `drawerWidth`, `modalSize`, `onsubmit` |
341
341
  | `CrudDeleteConfirm` | 删除确认框 | `open`, `loading`, `onconfirm` |
342
342
 
343
- > 列表列头默认支持点击排序(`DataTable` 内置客户端排序,受控模式可通过 `sortKey`/`sortDir`/`onsort` 接管为服务端排序);存在搜索/过滤条件时筛选栏显示「重置」按钮。过滤工具栏支持常用控件:`select`、`boolean`、`number`、`date`、`date-range`、`text`。分页栏统一为 shadcn 风格(总数 / 每页 / 第 X / Y 页 / 首末翻页 / 跳转到)。
343
+ > `CrudPage` 默认使用 `TableToolbar` 图标弹层工具栏,也可通过 `toolbarStyle='filter-bar'` 切回平铺筛选栏;`toolbarLeading` 用于注入左侧标题或说明,配合 `showHeader={false}` 可避免重复标题;`toolbarActions` 注入右侧业务操作,`card` 插槽可将列表项替换为卡片视图,`tableCell` 可按行和列 key 渲染交互式单元格内容(例如 Tooltip),`sortableColumns` 控制服务端排序字段。过滤工具栏支持 `select`、`boolean`、`number`、`date`、`date-range`、`text`。
344
+ >
345
+ > 页面根容器会占满父级可用高度。表格无论是否有数据都会撑满剩余空间,仅数据区域滚动;表头固定在数据区顶部,分页栏固定在列表卡片底部。
346
+ >
347
+ > 表格列会优先压缩到 7rem 并截断超长文本;列总宽度仍超过容器时才显示横向滚动条。操作列固定在右侧,横向滚动时保持可见。
344
348
 
345
349
  #### 错误页场景(1 个)
346
350
 
@@ -484,6 +488,7 @@ components/
484
488
  form={{ variant: 'modal', modalSize: 'lg' }}
485
489
  pagination={{ showSizeChanger: true, showJumper: true, pageSizeOptions: [10, 20, 50] }}
486
490
  density='compact'
491
+ sortableColumns={[{ key: 'createdAt', label: '创建时间' }]}
487
492
  {nav}
488
493
  />
489
494
  ```
package/auto-import.d.ts CHANGED
@@ -70,6 +70,7 @@ declare global {
70
70
  const Skeleton: typeof import('@h-ai/ui')['Skeleton']
71
71
  const Steps: typeof import('@h-ai/ui')['Steps']
72
72
  const SwipeCell: typeof import('@h-ai/ui')['SwipeCell']
73
+ const TableToolbar: typeof import('@h-ai/ui')['TableToolbar']
73
74
  const Tabs: typeof import('@h-ai/ui')['Tabs']
74
75
  const TagInput: typeof import('@h-ai/ui')['TagInput']
75
76
  const Timeline: typeof import('@h-ai/ui')['Timeline']
package/auto-import.js CHANGED
@@ -61,6 +61,7 @@ const UI_COMPONENTS = new Set([
61
61
  'Skeleton',
62
62
  'Steps',
63
63
  'SwipeCell',
64
+ 'TableToolbar',
64
65
  'Tabs',
65
66
  'TagInput',
66
67
  'Timeline',
@@ -5,7 +5,9 @@
5
5
  @prop {T[]} data - 表格数据
6
6
  @prop {Column<T>[]} columns - 列配置
7
7
  @prop {keyof T} keyField - 用于唯一标识行的字段
8
+ @prop {Snippet<[T, string]>} cell - 单元格内容插槽
8
9
  @prop {Snippet<[T]>} actions - 操作按钮插槽
10
+ @prop {(item: T) => void} onrowclick - 行点击回调
9
11
  @prop {Snippet} empty - 空状态插槽
10
12
  @prop {boolean} loading - 是否加载中
11
13
  @prop {boolean} striped - 是否斑马纹
@@ -37,7 +39,9 @@
37
39
  data,
38
40
  columns,
39
41
  keyField,
42
+ cell,
40
43
  actions,
44
+ onrowclick,
41
45
  empty,
42
46
  loading = false,
43
47
  striped = true,
@@ -52,7 +56,11 @@
52
56
  data: T[]
53
57
  columns: { key: keyof T | string, label: string, width?: string, align?: 'left' | 'center' | 'right', sortable?: boolean, render?: (item: T) => string }[]
54
58
  keyField: keyof T
59
+ /** 自定义单元格内容;参数依次为行记录和列 key。 */
60
+ cell?: Snippet<[T, string]>
55
61
  actions?: Snippet<[T]>
62
+ /** 点击数据行时的回调。 */
63
+ onrowclick?: (item: T) => void
56
64
  empty?: Snippet
57
65
  loading?: boolean
58
66
  striped?: boolean
@@ -67,22 +75,45 @@
67
75
  class?: string
68
76
  } & DataAttributes = $props()
69
77
 
78
+ /** 透传到根节点的 data-* 属性。 */
70
79
  const dataAttributes = $derived(getDataAttributes(restProps))
71
- type Column = { key: keyof T | string, label: string, width?: string, align?: 'left' | 'center' | 'right', sortable?: boolean, render?: (item: T) => string }
80
+ /** DataTable 内部使用的列定义。 */
81
+ type Column = {
82
+ /** 列对应的数据字段。 */
83
+ key: keyof T | string
84
+ /** 表头文案。 */
85
+ label: string
86
+ /** 列宽 CSS 值。 */
87
+ width?: string
88
+ /** 单元格对齐方式。 */
89
+ align?: 'left' | 'center' | 'right'
90
+ /** 是否允许点击表头排序。 */
91
+ sortable?: boolean
92
+ /** 自定义单元格格式化函数。 */
93
+ render?: (item: T) => string
94
+ }
72
95
 
73
96
  // ─── 排序状态 ───
97
+ /** 是否由父组件接管排序;受控模式不复制和排序当前数据。 */
74
98
  const controlled = $derived(typeof onsort === 'function')
75
99
  // 非受控模式下的内部排序状态
100
+ /** 非受控模式下的本地排序字段。 */
76
101
  let localSortKey = $state<string | null>(null)
102
+ /** 非受控模式下的本地排序方向。 */
77
103
  let localSortDir = $state<'asc' | 'desc'>('asc')
78
104
 
105
+ /** 当前生效的排序字段。 */
79
106
  const activeSortKey = $derived(controlled ? (sortKey ?? null) : localSortKey)
107
+ /** 当前生效的排序方向。 */
80
108
  const activeSortDir = $derived<'asc' | 'desc' | null>(controlled ? (sortDir ?? null) : (localSortKey ? localSortDir : null))
81
109
 
110
+ /** 切换列排序,并在受控模式下通知父组件。 */
82
111
  function toggleSort(col: Column) {
83
112
  if (!col.sortable)
84
113
  return
114
+ /** 统一为字符串的列 key,用于和受控排序字段比较。 */
85
115
  const key = String(col.key)
116
+ /** 同一列再次点击时切换方向,否则从升序开始。 */
86
117
  const nextDir: 'asc' | 'desc' = activeSortKey === key && activeSortDir === 'asc' ? 'desc' : 'asc'
87
118
  if (controlled) {
88
119
  onsort?.(key, nextDir)
@@ -93,13 +124,17 @@
93
124
  }
94
125
 
95
126
  // 非受控模式:对当前数据做客户端排序(受控模式直接使用外部已排序数据)
127
+ /** 非受控模式的当前页客户端排序结果。 */
96
128
  const sortedData = $derived.by(() => {
97
129
  if (controlled || !localSortKey)
98
130
  return data
131
+ /** 已确定的排序字段和方向。 */
99
132
  const key = localSortKey
100
133
  const dir = localSortDir
134
+ /** 复制输入数组,避免客户端排序改变父组件数据。 */
101
135
  const copy = [...data]
102
136
  copy.sort((a, b) => {
137
+ /** 两条记录的待比较值。 */
103
138
  const av = (a as Record<string, unknown>)[key]
104
139
  const bv = (b as Record<string, unknown>)[key]
105
140
  if (av === bv)
@@ -108,6 +143,7 @@
108
143
  return 1
109
144
  if (bv === null || bv === undefined)
110
145
  return -1
146
+ /** 兼容数值和字符串字段的比较结果。 */
111
147
  let cmp: number
112
148
  if (typeof av === 'number' && typeof bv === 'number') {
113
149
  cmp = av - bv
@@ -120,32 +156,89 @@
120
156
  return copy
121
157
  })
122
158
 
159
+ /** 按密度生成表格单元格间距。 */
123
160
  const tableClass = $derived(
124
161
  density === 'compact'
125
- ? 'table table-sm w-full text-[12px] [&_thead_th]:px-3 [&_thead_th]:py-2 [&_tbody_td]:px-3 [&_tbody_td]:py-2'
126
- : 'table w-full text-[13px] [&_thead_th]:px-4 [&_thead_th]:py-2.5 [&_tbody_td]:px-4 [&_tbody_td]:py-3',
162
+ ? 'table table-sm w-full table-auto text-[12px] [&_thead_th]:px-3 [&_thead_th]:py-2 [&_tbody_td]:px-3 [&_tbody_td]:py-2'
163
+ : 'table w-full table-auto text-[13px] [&_thead_th]:px-4 [&_thead_th]:py-2.5 [&_tbody_td]:px-4 [&_tbody_td]:py-3',
127
164
  )
128
165
 
166
+ /** 表头字号样式;分隔线由每个表头单元格独立绘制,避免 sticky 列形成不同的层叠效果。 */
129
167
  const headerRowClass = $derived(
130
168
  density === 'compact'
131
- ? 'text-[11px] text-base-content/55 border-b border-base-content/6'
132
- : 'text-[12px] text-base-content/55 border-b border-base-content/6',
169
+ ? 'text-[11px] text-base-content/55'
170
+ : 'text-[12px] text-base-content/55',
133
171
  )
134
172
 
173
+ /** 所有表头单元格共用同一条分隔线,确保普通列与 sticky 操作列的颜色和位置完全一致。 */
174
+ const headerCellDividerClass = `after:absolute after:inset-x-0 after:-bottom-px after:h-px after:bg-base-300 after:content-['']`
175
+
176
+ /** 数据行背景、悬停和分隔线样式;背景直接下发到每个单元格,避免 sticky 层透出下方内容。 */
135
177
  const rowClass = $derived(
136
178
  hoverable
137
- ? 'border-b border-base-content/5 transition-colors hover:bg-base-content/3 last:border-b-0'
138
- : 'border-b border-base-content/5 last:border-b-0',
179
+ ? `border-b border-base-content/5 transition-colors [&>td]:bg-base-100 ${striped ? 'even:[&>td]:bg-base-200' : ''} hover:[&>td]:[background-color:color-mix(in_oklab,var(--color-base-content)_3%,var(--color-base-100))] ${striped ? 'even:hover:[&>td]:[background-color:color-mix(in_oklab,var(--color-base-content)_3%,var(--color-base-200))]' : ''} last:border-b-0`
180
+ : `border-b border-base-content/5 [&>td]:bg-base-100 ${striped ? 'even:[&>td]:bg-base-200' : ''} last:border-b-0`,
139
181
  )
140
182
 
141
- const actionColumnClass = $derived(density === 'compact' ? 'w-20 text-center font-medium' : 'w-24 text-center font-medium')
142
- const actionWrapClass = $derived(density === 'compact' ? 'flex items-center justify-center gap-0.5' : 'flex items-center justify-center gap-1')
183
+ /** 承载横向滚动的容器;固定操作列只在此容器中可能遮挡其他列。 */
184
+ let scrollContainer = $state<HTMLDivElement>()
185
+ /** 表格尺寸变化时需重新判断固定操作列是否已脱离其自然位置。 */
186
+ let tableElement = $state<HTMLTableElement>()
187
+ /** 仅在固定操作列覆盖滚动内容时显示其左侧分隔阴影。 */
188
+ let showActionColumnShadow = $state(false)
189
+
190
+ /**
191
+ * 固定列在滚动起点和终点都处于自然位置,不应产生视觉分割;
192
+ * 仅在横向滚动的中间区间覆盖其他列时才显示阴影,避免静态表格多出竖线。
193
+ */
194
+ function syncActionColumnShadow() {
195
+ if (!scrollContainer || !actions) {
196
+ showActionColumnShadow = false
197
+ return
198
+ }
199
+
200
+ const maxScrollLeft = scrollContainer.scrollWidth - scrollContainer.clientWidth
201
+ showActionColumnShadow = scrollContainer.scrollLeft > 1 && scrollContainer.scrollLeft < maxScrollLeft - 1
202
+ }
203
+
204
+ $effect(() => {
205
+ if (!scrollContainer || !tableElement || !actions) {
206
+ showActionColumnShadow = false
207
+ return
208
+ }
209
+
210
+ syncActionColumnShadow()
211
+ const resizeObserver = new ResizeObserver(syncActionColumnShadow)
212
+ resizeObserver.observe(scrollContainer)
213
+ resizeObserver.observe(tableElement)
214
+
215
+ return () => resizeObserver.disconnect()
216
+ })
217
+
218
+ /**
219
+ * 操作列严格按按钮内容的 max-content 宽度计算,不在少列时预留无效空白。
220
+ * 内容过宽时由表格外层已有的横向滚动容器承接,避免浏览器压缩或裁剪操作按钮。
221
+ */
222
+ const actionColumnClass = $derived(
223
+ `sticky right-0 z-10 w-max whitespace-nowrap text-center font-medium ${showActionColumnShadow ? 'shadow-[-1px_0_0_color-mix(in_srgb,var(--color-base-content)_10%,transparent)]' : ''}`,
224
+ )
225
+ /** 操作列表头复用统一分隔线,并保持不透明背景以遮挡横向滚动内容。 */
226
+ const actionHeaderClass = $derived(`${actionColumnClass} ${headerCellDividerClass} bg-base-100`)
227
+ /** 数据操作格的背景由 rowClass 统一下发,确保默认、斑马纹和 hover 状态一致且不透明。 */
228
+ const actionCellClass = $derived(actionColumnClass)
229
+ /** 操作按钮保持单行的固有宽度,既不强制填满单元格,也不会被其它列挤压。 */
230
+ const actionWrapClass = $derived(density === 'compact' ? 'inline-flex flex-nowrap items-center justify-center gap-0.5' : 'inline-flex flex-nowrap items-center justify-center gap-1')
231
+ /** 加载和空数据占位单元格样式。 */
143
232
  const placeholderCellClass = $derived(density === 'compact' ? 'text-center py-6 text-base-content/40' : 'text-center py-8 text-base-content/40')
233
+ /** 未声明宽度的文本列最多占用的空间,保留其他列与操作列的可见区域。 */
234
+ const defaultContentMaxWidth = 'min(24rem, 40vw)'
144
235
 
236
+ /** 解析单元格值并应用自定义渲染、空值和时间戳格式化。 */
145
237
  function getValue(item: T, col: Column): string {
146
238
  if (col.render) {
147
239
  return col.render(item)
148
240
  }
241
+ /** 当前列的原始字段值。 */
149
242
  const value = item[col.key as keyof T]
150
243
  if (value === null || value === undefined) {
151
244
  return '-'
@@ -157,6 +250,7 @@
157
250
  return String(value)
158
251
  }
159
252
 
253
+ /** 将列对齐配置转换为 Tailwind 文本对齐类。 */
160
254
  function getAlignClass(align?: string): string {
161
255
  switch (align) {
162
256
  case 'center': return 'text-center'
@@ -164,25 +258,49 @@
164
258
  default: return 'text-left'
165
259
  }
166
260
  }
261
+
262
+ /**
263
+ * 为每列提供可压缩的优先宽度与下限。
264
+ * 显式宽度是调用方的布局契约,允许小于默认列宽;未设置宽度的列才保留 7rem 下限。
265
+ */
266
+ function getColumnStyle(col: Column): string {
267
+ if (!col.width) {
268
+ return 'width: auto; min-width: 7rem;'
269
+ }
270
+
271
+ /** 显式宽度仍受视口约束,同时通过 max-width 防止表格自动布局重新撑宽该列。 */
272
+ const boundedWidth = `min(${col.width}, 20vw)`
273
+ return `width: ${boundedWidth}; min-width: 0; max-width: ${boundedWidth};`
274
+ }
275
+
276
+ /**
277
+ * 限制表头和默认单元格内容的实际可用宽度。
278
+ * 仅给 th 设置 width 会被 table-auto 的内容尺寸重新撑开,必须同时限制 td 内的内容盒子。
279
+ */
280
+ function getContentStyle(col: Column): string {
281
+ const maxWidth = col.width ? `min(${col.width}, 20vw)` : defaultContentMaxWidth
282
+ return `max-width: ${maxWidth};`
283
+ }
167
284
  </script>
168
285
 
169
- <div {...dataAttributes} class='overflow-x-auto {className}'>
170
- <table class={tableClass} class:table-zebra={striped}>
286
+ <div {...dataAttributes} bind:this={scrollContainer} class='overflow-x-auto {className}' onscroll={syncActionColumnShadow}>
287
+ <table bind:this={tableElement} class={tableClass}>
171
288
  <thead>
172
289
  <tr class={headerRowClass}>
173
290
  {#each columns as col (String(col.key))}
174
291
  {@const isSorted = activeSortKey === String(col.key)}
175
292
  <th
176
- style={col.width ? `width: ${col.width}` : ''}
177
- class='{getAlignClass(col.align)} font-medium'
293
+ style={getColumnStyle(col)}
294
+ class='{getAlignClass(col.align)} {headerCellDividerClass} relative font-medium'
178
295
  >
179
296
  {#if col.sortable}
180
297
  <button
181
298
  type='button'
182
- class='group inline-flex select-none items-center gap-1 rounded-md px-1.5 py-0.5 -mx-1.5 font-medium transition-colors hover:bg-base-content/5 {isSorted ? 'text-base-content' : ''}'
299
+ class='group inline-flex max-w-full min-w-0 select-none items-center gap-1 rounded-md px-1.5 py-0.5 -mx-1.5 font-medium transition-colors hover:bg-base-content/5 {isSorted ? 'text-base-content' : ''}'
300
+ style={getContentStyle(col)}
183
301
  onclick={() => toggleSort(col)}
184
302
  >
185
- <span>{col.label}</span>
303
+ <span class='min-w-0 truncate'>{col.label}</span>
186
304
  {#if isSorted && activeSortDir === 'asc'}
187
305
  <span class='icon-[tabler--arrow-narrow-up] size-3.5 text-base-content/70'></span>
188
306
  {:else if isSorted && activeSortDir === 'desc'}
@@ -192,12 +310,12 @@
192
310
  {/if}
193
311
  </button>
194
312
  {:else}
195
- {col.label}
313
+ <div class='block min-w-0 truncate' style={getContentStyle(col)} title={col.label}>{col.label}</div>
196
314
  {/if}
197
315
  </th>
198
316
  {/each}
199
317
  {#if actions}
200
- <th class={actionColumnClass}>{uiM('data_table_actions')}</th>
318
+ <th class='{actionHeaderClass} z-20'>{uiM('data_table_actions')}</th>
201
319
  {/if}
202
320
  </tr>
203
321
  </thead>
@@ -220,12 +338,20 @@
220
338
  </tr>
221
339
  {:else}
222
340
  {#each sortedData as item (item[keyField])}
223
- <tr class={rowClass}>
341
+ <tr class='{rowClass} {onrowclick ? 'cursor-pointer' : ''}' onclick={() => onrowclick?.(item)}>
224
342
  {#each columns as col (String(col.key))}
225
- <td class={getAlignClass(col.align)}>{getValue(item, col)}</td>
343
+ <td style={getColumnStyle(col)} class={getAlignClass(col.align)}>
344
+ {#if cell}
345
+ <div class='min-w-0' style={getContentStyle(col)}>
346
+ {@render cell(item, String(col.key))}
347
+ </div>
348
+ {:else}
349
+ <div class='block min-w-0 truncate' style={getContentStyle(col)} title={getValue(item, col)}>{getValue(item, col)}</div>
350
+ {/if}
351
+ </td>
226
352
  {/each}
227
353
  {#if actions}
228
- <td>
354
+ <td class={actionCellClass}>
229
355
  <div class={actionWrapClass}>
230
356
  {@render actions(item)}
231
357
  </div>
@@ -237,3 +363,13 @@
237
363
  </tbody>
238
364
  </table>
239
365
  </div>
366
+
367
+ <style>
368
+ /* 表头层级必须高于固定操作数据格,避免纵向滚动时操作按钮覆盖表头。 */
369
+ thead {
370
+ position: sticky;
371
+ top: 0;
372
+ z-index: 20;
373
+ background: var(--color-base-100);
374
+ }
375
+ </style>
@@ -12,7 +12,11 @@ declare function $$render<T>(): {
12
12
  render?: (item: T) => string;
13
13
  }[];
14
14
  keyField: keyof T;
15
+ /** 自定义单元格内容;参数依次为行记录和列 key。 */
16
+ cell?: Snippet<[T, string]>;
15
17
  actions?: Snippet<[T]>;
18
+ /** 点击数据行时的回调。 */
19
+ onrowclick?: (item: T) => void;
16
20
  empty?: Snippet;
17
21
  loading?: boolean;
18
22
  striped?: boolean;
@@ -52,7 +56,9 @@ interface $$IsomorphicComponent {
52
56
  * @prop {T[]} data - 表格数据
53
57
  * @prop {Column<T>[]} columns - 列配置
54
58
  * @prop {keyof T} keyField - 用于唯一标识行的字段
59
+ * @prop {Snippet<[T, string]>} cell - 单元格内容插槽
55
60
  * @prop {Snippet<[T]>} actions - 操作按钮插槽
61
+ * @prop {(item: T) => void} onrowclick - 行点击回调
56
62
  * @prop {Snippet} empty - 空状态插槽
57
63
  * @prop {boolean} loading - 是否加载中
58
64
  * @prop {boolean} striped - 是否斑马纹