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

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.
@@ -13,7 +13,7 @@
13
13
  <script lang='ts'>
14
14
  import type { Snippet } from 'svelte'
15
15
  import type { DataAttributes } from '../../../types.js'
16
- import type { CrudDensity, CrudFormConfig, CrudPaginationConfig } from './crud-types.js'
16
+ import type { CrudDensity, CrudFormConfig, CrudPaginationConfig, CrudToolbarStyle } from './crud-types.js'
17
17
  import type { NavAdapter } from './nav-adapter.js'
18
18
  import { SvelteURLSearchParams } from 'svelte/reactivity'
19
19
  import { uiM } from '../../../messages.js'
@@ -22,6 +22,7 @@
22
22
  import DataTable from '../../compounds/DataTable.svelte'
23
23
  import PageHeader from '../../compounds/PageHeader.svelte'
24
24
  import Pagination from '../../compounds/Pagination.svelte'
25
+ import TableToolbar from '../../compounds/TableToolbar.svelte'
25
26
  import Button from '../../primitives/Button.svelte'
26
27
  import IconButton from '../../primitives/IconButton.svelte'
27
28
  import CrudDeleteConfirm from './CrudDeleteConfirm.svelte'
@@ -32,54 +33,100 @@
32
33
 
33
34
  // ─── 类型定义 ───
34
35
 
36
+ /** CRUD 字段定义,决定列表、筛选器和表单的字段行为。 */
35
37
  type FieldDef = {
38
+ /** 字段稳定标识,同时对应数据记录中的属性名。 */
36
39
  id: string
40
+ /** 字段显示文案,允许通过函数延迟读取当前语言。 */
37
41
  label: string | (() => string)
42
+ /** 字段控件类型,最终由表单或筛选器解释。 */
38
43
  type: string
44
+ /** 是否出现在列表中;未设置时默认显示。 */
39
45
  inList?: boolean
46
+ /** 是否出现在筛选器中;只有显式为 true 时显示。 */
40
47
  filterable?: boolean
48
+ /** 编辑时是否禁止修改。 */
41
49
  readonly?: boolean
50
+ /** 是否出现在新建表单中;未设置时默认显示。 */
42
51
  inCreate?: boolean
52
+ /** 是否出现在编辑表单中;未设置时默认显示。 */
43
53
  inEdit?: boolean
54
+ /** 是否出现在详情面板中;未设置时默认显示。 */
44
55
  inDetail?: boolean
56
+ /** 选择类字段的静态选项或动态选项工厂。 */
45
57
  options?: Array<{ label: string, value: string | number | boolean }> | (() => Array<{ label: string, value: string | number | boolean }>)
58
+ /** 表单校验规则。 */
46
59
  validation?: { required?: boolean, min?: number, max?: number, pattern?: string, message?: string }
60
+ /** 列宽 CSS 值。 */
47
61
  width?: string
62
+ /** 单元格对齐方式。 */
48
63
  align?: 'left' | 'center' | 'right'
64
+ /** 列表单元格的自定义渲染函数。 */
49
65
  render?: (value: unknown, item: Record<string, unknown>) => string
66
+ /** 输入控件占位文案。 */
50
67
  placeholder?: string | (() => string)
68
+ /** 日期范围起始值 key。 */
69
+ startKey?: string
70
+ /** 日期范围结束值 key。 */
71
+ endKey?: string
72
+ /** 新建记录时使用的默认值。 */
51
73
  defaultValue?: unknown
74
+ /** 字段排序权重,数值越小越靠前。 */
52
75
  order?: number
53
76
  }
54
77
 
55
78
  // 使用 method shorthand 语法(双向协变)以兼容来自 @h-ai/kit 的 CrudOperations 类型
56
79
  // 详见:https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-6.html#strict-function-types
80
+ /** CrudPage 使用的异步资源操作集合。 */
57
81
  type CrudApi = {
82
+ /** 获取当前分页、筛选和排序结果。 */
58
83
  list(params: Record<string, unknown>): Promise<{ items: Record<string, unknown>[], total: number, page: number, pageSize: number }>
84
+ /** 按主键获取一条记录。 */
59
85
  get?(id: string): Promise<Record<string, unknown> | null>
86
+ /** 创建一条记录。 */
60
87
  create?(data: Record<string, unknown>): Promise<Record<string, unknown>>
88
+ /** 更新一条记录。 */
61
89
  update?(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>
90
+ /** 删除一条记录。 */
62
91
  remove?(id: string): Promise<void>
63
92
  }
64
93
 
94
+ /** 声明式 CRUD 资源及字段转换能力。 */
65
95
  type CrudDef = {
96
+ /** 资源元信息与后端 API。 */
66
97
  resource: {
98
+ /** 资源名称,用于识别当前业务实体。 */
67
99
  name: string
100
+ /** 资源标题文案。 */
68
101
  label: string | (() => string)
102
+ /** 记录主键字段,默认使用 id。 */
69
103
  keyField?: string
104
+ /** 资源字段定义。 */
70
105
  fields: FieldDef[]
106
+ /** 默认分页条数。 */
71
107
  defaultPageSize?: number
108
+ /** 是否显示搜索框。 */
72
109
  searchable?: boolean
110
+ /** 搜索框占位文案。 */
73
111
  searchPlaceholder?: string | (() => string)
112
+ /** 资源 CRUD API。 */
74
113
  api: CrudApi
75
114
  }
115
+ /** 获取列表字段。 */
76
116
  getListFields: () => FieldDef[]
117
+ /** 获取筛选字段。 */
77
118
  getFilterFields: () => FieldDef[]
119
+ /** 获取新建字段。 */
78
120
  getCreateFields: () => FieldDef[]
121
+ /** 获取编辑字段。 */
79
122
  getEditFields: () => FieldDef[]
123
+ /** 获取详情字段。 */
80
124
  getDetailFields: () => FieldDef[]
125
+ /** 转换为 DataTable 列定义。 */
81
126
  toTableColumns: () => Array<{ key: string, label: string, width?: string, align?: string, render?: (item: Record<string, unknown>) => string }>
127
+ /** 生成新建表单默认值。 */
82
128
  getDefaultValues: () => Record<string, unknown>
129
+ /** API 操作代理。 */
83
130
  api: CrudApi
84
131
  }
85
132
 
@@ -93,10 +140,18 @@
93
140
  pagination = {},
94
141
  density = 'normal',
95
142
  rowClickDetail = true,
143
+ showHeader = true,
96
144
  listItemActions,
145
+ tableCell,
146
+ onrowclick,
97
147
  editFormExtra,
98
148
  detailExtra,
99
149
  headerActions,
150
+ toolbarStyle = 'toolbar',
151
+ toolbarLeading,
152
+ toolbarActions,
153
+ card,
154
+ sortableColumns = [],
100
155
  onbeforedelete,
101
156
  onaftersubmit,
102
157
  onafterdelete,
@@ -106,21 +161,53 @@
106
161
  class: className = '',
107
162
  ...restProps
108
163
  }: {
164
+ /** CRUD 资源定义及其 API 操作。 */
109
165
  crud: CrudDef
166
+ /** 当前页数据与服务端分页元数据。 */
110
167
  data: { items: Record<string, unknown>[], total: number, page: number, pageSize: number, filters?: Record<string, unknown> }
168
+ /** 新建、编辑、删除权限;未设置的权限默认跟随 API 是否存在。 */
111
169
  permissions?: { create?: boolean, update?: boolean, delete?: boolean }
170
+ /** 新建/编辑面板展示配置。 */
112
171
  form?: CrudFormConfig
172
+ /** 分页栏展示配置。 */
113
173
  pagination?: CrudPaginationConfig
174
+ /** 列表密度,影响行高、操作按钮和面板控件尺寸。 */
114
175
  density?: CrudDensity
176
+ /** 点击详情图标是否打开详情面板。 */
115
177
  rowClickDetail?: boolean
178
+ /** 是否显示默认页面标题栏;使用 toolbarLeading 承载标题时可设为 false。 */
179
+ showHeader?: boolean
180
+ /** 每行额外操作按钮插槽。 */
116
181
  listItemActions?: Snippet<[Record<string, unknown>]>
182
+ /** 自定义表格单元格;参数依次为行记录和列 key。 */
183
+ tableCell?: Snippet<[Record<string, unknown>, string]>
184
+ /** 点击表格行时的回调。 */
185
+ onrowclick?: (item: Record<string, unknown>) => void
186
+ /** 编辑表单额外字段插槽。 */
117
187
  editFormExtra?: Snippet<[Record<string, unknown> | null, 'create' | 'edit']>
188
+ /** 详情面板额外内容插槽。 */
118
189
  detailExtra?: Snippet<[Record<string, unknown>]>
190
+ /** 页面标题右侧额外操作插槽。 */
119
191
  headerActions?: Snippet
192
+ /** 工具栏风格:`toolbar` 使用图标弹层工具栏(默认),`filter-bar` 使用平铺筛选栏。 */
193
+ toolbarStyle?: CrudToolbarStyle
194
+ /** toolbar 风格下工具栏左侧内容插槽,可用于放置页面标题或说明。 */
195
+ toolbarLeading?: Snippet
196
+ /** TableToolbar 风格下工具栏右侧自定义操作插槽。 */
197
+ toolbarActions?: Snippet
198
+ /** 卡片列表项渲染插槽;存在时替代 DataTable,仍复用工具栏、分页和独立滚动容器。 */
199
+ card?: Snippet<[Record<string, unknown>]>
200
+ /** 可排序列配置;为空时默认使用全部列表列。 */
201
+ sortableColumns?: Array<{ key: string, label: string }>
202
+ /** 删除前钩子,返回 false 时取消删除。 */
120
203
  onbeforedelete?: (item: Record<string, unknown>) => Promise<boolean> | boolean
204
+ /** 新建或编辑成功后的回调。 */
121
205
  onaftersubmit?: (item: Record<string, unknown>, mode: 'create' | 'edit') => void
206
+ /** 删除成功后的回调。 */
122
207
  onafterdelete?: (item: Record<string, unknown>) => void
208
+ /** 自定义错误回调。 */
123
209
  onerror?: (error: string) => void
210
+ /** 列表路由基础路径。 */
124
211
  basePath?: string
125
212
  /**
126
213
  * 路由适配器:用于读取当前 pathname、同步过滤参数到 URL、列表刷新。
@@ -129,91 +216,164 @@
129
216
  * 以获得客户端跳转 + invalidateAll 体验。
130
217
  */
131
218
  nav?: NavAdapter
219
+ /** 根节点自定义 class。 */
132
220
  class?: string
133
221
  } & DataAttributes = $props()
134
222
 
223
+ /** 透传到根节点的 data-* 属性。 */
135
224
  const dataAttributes = $derived(getDataAttributes(restProps))
136
225
  // ─── 状态 ───
137
226
 
227
+ /** 表格行的主键字段。 */
138
228
  const keyField = $derived(crud.resource.keyField ?? 'id')
229
+ /** 当前资源的本地化标题。 */
139
230
  const resourceLabel = $derived(resolveText(crud.resource.label))
231
+ /** 根据权限和 API 能力判断是否显示新建入口。 */
140
232
  const canCreate = $derived(permissions.create !== false && Boolean(crud.api.create))
233
+ /** 根据权限和 API 能力判断是否显示编辑入口。 */
141
234
  const canUpdate = $derived(permissions.update !== false && Boolean(crud.api.update))
235
+ /** 根据权限和 API 能力判断是否显示删除入口。 */
142
236
  const canDelete = $derived(permissions.delete !== false && Boolean(crud.api.remove))
237
+ /** 当前资源是否支持关键字搜索。 */
143
238
  const searchable = $derived(crud.resource.searchable !== false)
239
+ /** 是否使用紧凑列表密度。 */
144
240
  const isCompact = $derived(density === 'compact')
145
- const pageGapClass = 'space-y-5'
241
+ /** 页面主体垂直间距;列表卡片会占满其余可用高度。 */
242
+ const pageGapClass = 'flex min-h-0 h-full flex-col gap-5'
243
+ /** 行内操作按钮尺寸。 */
146
244
  const actionButtonSize = $derived(isCompact ? 'xs' : 'sm')
245
+ /** 行内操作图标尺寸。 */
147
246
  const actionIconClass = $derived(isCompact ? 'size-3.5' : 'size-4')
247
+ /** 分页组件尺寸。 */
148
248
  const paginationSize = $derived(isCompact ? 'xs' : 'sm')
149
- const listCardClass = 'overflow-hidden rounded-[1.25rem] border-base-content/7 shadow-sm'
150
- const paginationBarClass = 'border-t border-base-content/5 px-4 py-3'
249
+ /**
250
+ * 列表承载容器样式;表格模式使用有边框的 Card,卡片模式移除该外壳以保留业务卡片的原始视觉层级。
251
+ * 两种模式均保留 flex 约束,避免数据区把底部分页推出父容器。
252
+ */
253
+ const listCardClass = $derived(
254
+ card
255
+ ? 'flex min-h-0 flex-1 flex-col overflow-hidden !rounded-none !border-0 !bg-transparent !shadow-none [&>.card-body]:min-h-0 [&>.card-body]:flex-1'
256
+ : 'flex min-h-0 flex-1 flex-col overflow-hidden rounded-[1.25rem] border-base-content/7 shadow-sm [&>.card-body]:min-h-0 [&>.card-body]:flex-1',
257
+ )
258
+ /** 分页栏样式;shrink-0 保证它始终贴在卡片底部。 */
259
+ const paginationBarClass = 'shrink-0 border-t border-base-content/5 px-4 py-3'
151
260
 
152
261
  // 表单展示配置(抽屉 / 弹窗)
262
+ /** 编辑/新建面板类型。 */
153
263
  const formVariant = $derived(form.variant ?? 'drawer')
264
+ /** 抽屉尺寸预设。 */
154
265
  const formDrawerSize = $derived(form.drawerSize ?? '2xl')
266
+ /** 抽屉自定义宽度。 */
155
267
  const formDrawerWidth = $derived(form.drawerWidth)
268
+ /** 弹窗尺寸预设。 */
156
269
  const formModalSize = $derived(form.modalSize ?? '2xl')
270
+ /** 弹窗自定义宽度。 */
157
271
  const formModalWidth = $derived(form.modalWidth)
272
+ /** 弹窗自定义高度。 */
158
273
  const formModalHeight = $derived(form.modalHeight)
159
274
 
160
275
  // 分页栏配置
276
+ /** 是否显示每页条数选择器。 */
161
277
  const paginationShowSizeChanger = $derived(pagination.showSizeChanger !== false)
278
+ /** 是否显示跳页输入框。 */
162
279
  const paginationShowJumper = $derived(pagination.showJumper !== false)
280
+ /** 是否显示记录总数。 */
163
281
  const paginationShowTotal = $derived(pagination.showTotal !== false)
282
+ /** 是否显示当前页 / 总页数文案。 */
164
283
  const paginationShowPageInfo = $derived(pagination.showPageInfo !== false)
284
+ /** 是否显示首页和末页按钮。 */
165
285
  const paginationShowFirstLast = $derived(pagination.showFirstLast !== false)
286
+ /** 每页条数候选项。 */
166
287
  const paginationPageSizeOptions = $derived(pagination.pageSizeOptions ?? [10, 20, 50, 100])
167
288
 
168
289
  // 视图状态
290
+ /** 当前打开的详情/编辑面板模式。 */
169
291
  let panelMode = $state<'detail' | 'edit' | 'create' | null>(null)
292
+ /** 当前选中的列表记录。 */
170
293
  let selectedItem = $state<Record<string, unknown> | null>(null)
294
+ /** 编辑面板的草稿数据。 */
171
295
  let formData = $state<Record<string, unknown>>({})
296
+ /** 新建或编辑请求是否进行中。 */
172
297
  let submitting = $state(false)
298
+ /** 当前表单错误文案。 */
173
299
  let formError = $state('')
300
+ /** 删除确认框是否打开。 */
174
301
  let deleteConfirmOpen = $state(false)
302
+ /** 等待确认删除的记录。 */
175
303
  let deletingItem = $state<Record<string, unknown> | null>(null)
304
+ /** 删除请求是否进行中。 */
176
305
  let deleting = $state(false)
177
306
 
178
307
  // 过滤状态
308
+ /** 搜索框当前值;提交时才同步到路由。 */
179
309
  let searchValue = $state('')
310
+ /** 当前已提交的筛选值。 */
180
311
  let filterValues = $state<Record<string, unknown>>({})
181
312
 
313
+ // 排序状态(toolbarStyle='toolbar' 时使用)
314
+ /** 当前服务端排序字段。 */
315
+ let sortBy = $state('')
316
+ /** 当前服务端排序方向。 */
317
+ let sortDirection = $state<'asc' | 'desc'>('desc')
318
+
182
319
  // 基础路径
320
+ /** 用于生成列表查询 URL 的基础路径。 */
183
321
  const currentBasePath = $derived(basePath || nav.pathname)
184
322
 
185
323
  // ─── 工具函数 ───
186
324
 
325
+ /** 解析静态或延迟读取的显示文案。 */
187
326
  function resolveText(text: string | (() => string)): string {
188
327
  return typeof text === 'function' ? text() : text
189
328
  }
190
329
 
191
- // 同步 data.filters → 本地过滤状态
330
+ // 同步 data.filters → 本地过滤/排序状态;没有 filters 时也要清空旧状态。
192
331
  $effect(() => {
193
- if (data.filters) {
194
- searchValue = String(data.filters.search ?? '')
195
- const fv: Record<string, unknown> = {}
196
- for (const f of crud.getFilterFields()) {
197
- if (data.filters[f.id] !== undefined) {
198
- fv[f.id] = data.filters[f.id]
199
- }
332
+ const filters = data.filters ?? {}
333
+ searchValue = String(filters.search ?? '')
334
+ sortBy = String(filters.sortBy ?? '')
335
+ sortDirection = filters.sortDirection === 'asc' ? 'asc' : 'desc'
336
+ const fv: Record<string, unknown> = {}
337
+ for (const f of crud.getFilterFields()) {
338
+ if (filters[f.id] !== undefined) {
339
+ fv[f.id] = filters[f.id]
340
+ }
341
+ if (f.type.trim().toLowerCase() === 'date-range') {
342
+ const startKey = f.startKey ?? `${f.id}Start`
343
+ const endKey = f.endKey ?? `${f.id}End`
344
+ if (filters[startKey] !== undefined)
345
+ fv[startKey] = filters[startKey]
346
+ if (filters[endKey] !== undefined)
347
+ fv[endKey] = filters[endKey]
200
348
  }
201
- filterValues = fv
202
349
  }
350
+ filterValues = fv
203
351
  })
204
352
 
205
353
  // ─── 表格列 ───
206
354
 
355
+ /** 资源定义生成的基础列表列。 */
207
356
  const tableColumns = $derived(crud.toTableColumns())
357
+ /** 供排序弹层显示的列;未传时默认允许所有列表列排序。 */
358
+ const effectiveSortableColumns = $derived(
359
+ sortableColumns.length > 0
360
+ ? sortableColumns
361
+ : tableColumns.map(column => ({ key: column.key, label: column.label })),
362
+ )
208
363
 
209
364
  // ─── 导航 ───
210
365
 
366
+ /** 合并搜索、筛选、排序和分页状态,并交给宿主导航器刷新列表。 */
211
367
  function navigateWithParams(overrides: Record<string, string | number>) {
368
+ /** 只包含有效值的查询参数容器。 */
212
369
  const params = new SvelteURLSearchParams()
370
+ /** 当前状态与本次覆盖值合并后的完整查询对象。 */
213
371
  const merged: Record<string, string | number> = {
214
372
  search: searchValue,
215
373
  page: data.page,
216
374
  pageSize: data.pageSize,
375
+ sortBy,
376
+ sortDirection,
217
377
  ...Object.fromEntries(
218
378
  Object.entries(filterValues)
219
379
  .filter(([, v]) => v !== undefined && v !== '')
@@ -223,22 +383,28 @@
223
383
  }
224
384
 
225
385
  for (const [key, val] of Object.entries(merged)) {
386
+ /** 统一转换后的查询值。 */
226
387
  const strVal = String(val)
227
388
  if (strVal && strVal !== '' && !(key === 'page' && strVal === '1') && !(key === 'pageSize' && strVal === String(crud.resource.defaultPageSize ?? 20))) {
228
389
  params.set(key, strVal)
229
390
  }
230
391
  }
231
392
 
393
+ /** 序列化后的查询字符串。 */
232
394
  const qs = params.toString()
395
+ /** 交给宿主路由的目标 URL。 */
233
396
  const url = `${currentBasePath}${qs ? `?${qs}` : ''}`
234
397
  void Promise.resolve(nav.navigate(url)).then(() => nav.refresh?.())
235
398
  }
236
399
 
400
+ /** 提交关键字搜索并回到第一页。 */
237
401
  function handleSearch(search: string) {
238
402
  navigateWithParams({ search, page: 1 })
239
403
  }
240
404
 
405
+ /** 提交筛选条件并回到第一页。 */
241
406
  function handleFilterChange(filters: Record<string, unknown>) {
407
+ /** 筛选变化时的 URL 覆盖项。 */
242
408
  const overrides: Record<string, string | number> = { page: 1 }
243
409
  for (const [k, v] of Object.entries(filters)) {
244
410
  overrides[k] = String(v ?? '')
@@ -246,27 +412,48 @@
246
412
  navigateWithParams(overrides)
247
413
  }
248
414
 
415
+ /** 切换页码。 */
249
416
  function handlePageChange(newPage: number) {
250
417
  navigateWithParams({ page: newPage })
251
418
  }
252
419
 
420
+ /** 修改每页条数并回到第一页。 */
253
421
  function handlePageSizeChange(newPageSize: number) {
254
422
  navigateWithParams({ pageSize: newPageSize, page: 1 })
255
423
  }
256
424
 
425
+ /** 清空搜索、筛选、排序并恢复默认分页。 */
257
426
  function handleResetFilters() {
258
427
  searchValue = ''
259
428
  filterValues = {}
429
+ sortBy = ''
430
+ sortDirection = 'desc'
260
431
  void Promise.resolve(nav.navigate(currentBasePath)).then(() => nav.refresh?.())
261
432
  }
262
433
 
434
+ /** 提交服务端排序条件。 */
435
+ function handleSort(key: string, direction: 'asc' | 'desc') {
436
+ sortBy = key
437
+ sortDirection = direction
438
+ navigateWithParams({ sortBy: key, sortDirection: direction, page: 1 })
439
+ }
440
+
441
+ /** 清空服务端排序条件。 */
442
+ function handleClearSort() {
443
+ sortBy = ''
444
+ sortDirection = 'desc'
445
+ navigateWithParams({ sortBy: '', sortDirection: '', page: 1 })
446
+ }
447
+
263
448
  // ─── 打开/关闭抽屉 ───
264
449
 
450
+ /** 打开记录详情面板。 */
265
451
  function openDetail(item: Record<string, unknown>) {
266
452
  selectedItem = item
267
453
  panelMode = 'detail'
268
454
  }
269
455
 
456
+ /** 打开新建面板并填充默认值。 */
270
457
  function openCreate() {
271
458
  selectedItem = null
272
459
  formData = { ...crud.getDefaultValues() }
@@ -274,6 +461,7 @@
274
461
  panelMode = 'create'
275
462
  }
276
463
 
464
+ /** 打开编辑面板并复制当前记录作为草稿。 */
277
465
  function openEdit(item: Record<string, unknown>) {
278
466
  selectedItem = item
279
467
  formData = { ...item }
@@ -281,12 +469,14 @@
281
469
  panelMode = 'edit'
282
470
  }
283
471
 
472
+ /** 关闭详情或编辑面板并清理临时状态。 */
284
473
  function closePanel() {
285
474
  panelMode = null
286
475
  selectedItem = null
287
476
  formError = ''
288
477
  }
289
478
 
479
+ /** 将详情面板切换为当前记录的编辑面板。 */
290
480
  function switchToEdit() {
291
481
  if (selectedItem) {
292
482
  openEdit(selectedItem)
@@ -295,12 +485,14 @@
295
485
 
296
486
  // ─── 提交 ───
297
487
 
488
+ /** 根据当前面板模式调用新建或更新 API。 */
298
489
  async function handleSubmit(submitData: Record<string, unknown>) {
299
490
  formError = ''
300
491
  submitting = true
301
492
 
302
493
  try {
303
494
  if (panelMode === 'create' && crud.api.create) {
495
+ /** 创建 API 返回的记录;空返回时传递空对象以保持回调稳定。 */
304
496
  const result = await crud.api.create(submitData)
305
497
  const submitted = (result ?? {}) as Record<string, unknown>
306
498
  closePanel()
@@ -308,7 +500,9 @@
308
500
  await nav.refresh?.()
309
501
  }
310
502
  else if (panelMode === 'edit' && crud.api.update && selectedItem) {
503
+ /** 当前编辑记录的稳定主键。 */
311
504
  const id = String(selectedItem[keyField])
505
+ /** 更新 API 返回的记录。 */
312
506
  const result = await crud.api.update(id, submitData)
313
507
  const submitted = (result ?? {}) as Record<string, unknown>
314
508
  closePanel()
@@ -317,6 +511,7 @@
317
511
  }
318
512
  }
319
513
  catch (e) {
514
+ /** 优先展示 API Error 文案,未知异常回退到内置 i18n。 */
320
515
  const msg = e instanceof Error ? e.message : uiM('crud_submit_failed')
321
516
  formError = msg
322
517
  onerror?.(msg)
@@ -328,16 +523,19 @@
328
523
 
329
524
  // ─── 删除 ───
330
525
 
526
+ /** 打开删除确认框并记录待删除项。 */
331
527
  function requestDelete(item: Record<string, unknown>) {
332
528
  deletingItem = item
333
529
  deleteConfirmOpen = true
334
530
  }
335
531
 
532
+ /** 执行删除前钩子、删除请求和刷新流程。 */
336
533
  async function confirmDelete() {
337
534
  if (!deletingItem || !crud.api.remove)
338
535
  return
339
536
 
340
537
  if (onbeforedelete) {
538
+ /** 业务删除前钩子的最终决策。 */
341
539
  const proceed = await onbeforedelete(deletingItem)
342
540
  if (!proceed) {
343
541
  deleteConfirmOpen = false
@@ -348,8 +546,10 @@
348
546
 
349
547
  deleting = true
350
548
  try {
549
+ /** 待删除记录的稳定主键。 */
351
550
  const id = String(deletingItem[keyField])
352
551
  await crud.api.remove(id)
552
+ /** 保留删除成功的记录,供回调在状态清空后使用。 */
353
553
  const deleted = deletingItem
354
554
  deleteConfirmOpen = false
355
555
  deletingItem = null
@@ -361,6 +561,7 @@
361
561
  await nav.refresh?.()
362
562
  }
363
563
  catch (e) {
564
+ /** 删除失败提示。 */
364
565
  const msg = e instanceof Error ? e.message : uiM('crud_delete_failed')
365
566
  onerror?.(msg)
366
567
  }
@@ -369,12 +570,14 @@
369
570
  }
370
571
  }
371
572
 
573
+ /** 取消删除并清理确认状态。 */
372
574
  function cancelDelete() {
373
575
  deleteConfirmOpen = false
374
576
  deletingItem = null
375
577
  }
376
578
 
377
579
  // 搜索占位符
580
+ /** 搜索框最终显示的占位文案。 */
378
581
  const searchPlaceholderText = $derived(
379
582
  crud.resource.searchPlaceholder
380
583
  ? resolveText(crud.resource.searchPlaceholder)
@@ -382,12 +585,17 @@
382
585
  )
383
586
 
384
587
  // 构建编辑/新建字段
588
+ /** 新建面板字段。 */
385
589
  const createFields = $derived(crud.getCreateFields() as FieldDef[])
590
+ /** 编辑面板字段。 */
386
591
  const editFields = $derived(crud.getEditFields() as FieldDef[])
592
+ /** 详情面板字段。 */
387
593
  const detailFields = $derived(crud.getDetailFields() as FieldDef[])
594
+ /** 工具栏筛选字段。 */
388
595
  const filterFields = $derived(crud.getFilterFields() as FieldDef[])
389
596
 
390
597
  // 抽屉标题
598
+ /** 当前详情/编辑面板标题。 */
391
599
  const panelTitle = $derived(
392
600
  panelMode === 'create'
393
601
  ? `${uiM('crud_create')}${resourceLabel}`
@@ -397,17 +605,20 @@
397
605
  )
398
606
 
399
607
  // 面板 open 状态
608
+ /** 详情面板开关。 */
400
609
  const detailOpen = $derived(panelMode === 'detail')
610
+ /** 编辑/新建面板开关。 */
401
611
  const editOpen = $derived(panelMode === 'create' || panelMode === 'edit')
402
612
 
403
613
  // 用于 DataTable 的 columns(含自定义渲染)
614
+ /** DataTable 列定义,补充受控排序所需的列元数据。 */
404
615
  const dtColumns = $derived(
405
616
  tableColumns.map(col => ({
406
617
  key: col.key,
407
618
  label: col.label,
408
619
  width: col.width,
409
620
  align: col.align as 'left' | 'center' | 'right' | undefined,
410
- sortable: true,
621
+ sortable: effectiveSortableColumns.some(column => column.key === col.key),
411
622
  render: col.render
412
623
  ? (item: Record<string, unknown>) => col.render!(item)
413
624
  : undefined,
@@ -416,81 +627,143 @@
416
627
  </script>
417
628
 
418
629
  <div {...dataAttributes} class='{pageGapClass} {className}'>
419
- <!-- 页面标题 -->
420
- <PageHeader title={resourceLabel}>
421
- {#snippet actions()}
422
- {#if headerActions}
423
- {@render headerActions()}
424
- {/if}
425
- {#if canCreate}
426
- <Button variant='primary' size='sm' onclick={openCreate} class='shadow-sm'>
427
- <span class='icon-[tabler--plus] size-4.5 mr-1.5'></span>
428
- {uiM('crud_create')}
429
- </Button>
430
- {/if}
431
- {/snippet}
432
- </PageHeader>
630
+ <!-- 页面标题;toolbarLeading 承载标题时由调用方关闭,避免重复占用垂直空间。 -->
631
+ {#if showHeader}
632
+ <PageHeader title={resourceLabel} class='mb-0 shrink-0'>
633
+ {#snippet actions()}
634
+ {#if headerActions}
635
+ {@render headerActions()}
636
+ {/if}
637
+ {#if canCreate && toolbarStyle === 'filter-bar'}
638
+ <Button variant='primary' size='sm' onclick={openCreate} class='shadow-sm'>
639
+ <span class='icon-[tabler--plus] size-4.5 mr-1.5'></span>
640
+ {uiM('crud_create')}
641
+ </Button>
642
+ {/if}
643
+ {/snippet}
644
+ </PageHeader>
645
+ {/if}
433
646
 
434
647
  <!-- 搜索 + 过滤栏 -->
435
648
  {#if searchable || filterFields.length > 0}
436
- <CrudFilterBar
437
- {searchable}
438
- searchPlaceholder={searchPlaceholderText}
439
- bind:searchValue
440
- {filterFields}
441
- bind:filterValues
442
- onsearch={handleSearch}
443
- onfilterchange={handleFilterChange}
444
- onreset={handleResetFilters}
445
- />
649
+ {#if toolbarStyle === 'toolbar'}
650
+ <TableToolbar
651
+ {searchable}
652
+ searchPlaceholder={searchPlaceholderText}
653
+ searchLabel={searchPlaceholderText}
654
+ bind:searchValue
655
+ {filterFields}
656
+ bind:filterValues
657
+ sortableColumns={effectiveSortableColumns}
658
+ bind:sortBy
659
+ bind:sortDirection
660
+ showViewSwitch={false}
661
+ leading={toolbarLeading}
662
+ primaryActionLabel={canCreate ? uiM('crud_create') : ''}
663
+ onPrimaryAction={canCreate ? openCreate : undefined}
664
+ onsearch={handleSearch}
665
+ onfilterchange={handleFilterChange}
666
+ onclearfilters={handleResetFilters}
667
+ onsort={handleSort}
668
+ onclearsort={handleClearSort}
669
+ >
670
+ {#if toolbarActions}
671
+ {@render toolbarActions()}
672
+ {/if}
673
+ </TableToolbar>
674
+ {:else}
675
+ <CrudFilterBar
676
+ {searchable}
677
+ searchPlaceholder={searchPlaceholderText}
678
+ bind:searchValue
679
+ {filterFields}
680
+ bind:filterValues
681
+ onsearch={handleSearch}
682
+ onfilterchange={handleFilterChange}
683
+ onreset={handleResetFilters}
684
+ />
685
+ {/if}
446
686
  {/if}
447
687
 
448
688
  <!-- 数据列表 -->
449
689
  <Card padding='none' class={listCardClass}>
450
- <DataTable
451
- data={data.items}
452
- columns={dtColumns}
453
- keyField={keyField}
454
- loading={false}
455
- {density}
456
- >
457
- {#snippet actions(item)}
458
- {#if rowClickDetail}
459
- <IconButton
460
- variant='ghost'
461
- size={actionButtonSize}
462
- ariaLabel={uiM('crud_detail')}
463
- onclick={() => openDetail(item)}
464
- >
465
- <span class='icon-[tabler--eye] {actionIconClass}'></span>
466
- </IconButton>
690
+ {#if card}
691
+ <!-- 卡片视图使用独立滚动容器,分页栏始终保留在容器外。 -->
692
+ <div class='min-h-0 flex-1 overflow-auto'>
693
+ {#if data.items.length > 0}
694
+ <div class='grid min-h-full grid-cols-[repeat(auto-fill,minmax(min(100%,18rem),1fr))] content-start gap-3'>
695
+ {#each data.items as item (String(item[keyField]))}
696
+ {@render card(item)}
697
+ {/each}
698
+ </div>
699
+ {:else}
700
+ <div class='flex min-h-full items-center justify-center px-4 text-sm text-base-content/55'>
701
+ {uiM('data_table_empty')}
702
+ </div>
467
703
  {/if}
468
- {#if canUpdate}
469
- <IconButton
470
- variant='ghost'
471
- size={actionButtonSize}
472
- ariaLabel={uiM('crud_edit')}
473
- onclick={() => openEdit(item)}
474
- >
475
- <span class='icon-[tabler--edit] {actionIconClass}'></span>
476
- </IconButton>
477
- {/if}
478
- {#if canDelete}
479
- <IconButton
480
- variant='ghost'
481
- size={actionButtonSize}
482
- ariaLabel={uiM('crud_delete')}
483
- onclick={() => requestDelete(item)}
484
- class='hover:text-error'
485
- >
486
- <span class='icon-[tabler--trash] {actionIconClass}'></span>
487
- </IconButton>
488
- {/if}
489
- {#if listItemActions}
490
- {@render listItemActions(item)}
491
- {/if}
492
- {/snippet}
493
- </DataTable>
704
+ </div>
705
+ {:else}
706
+ <!-- DataTable 自身同时承接横向和纵向滚动,确保 sticky 表头与数据行共享滚动上下文。 -->
707
+ <DataTable
708
+ data={data.items}
709
+ columns={dtColumns}
710
+ keyField={keyField}
711
+ loading={false}
712
+ sortKey={sortBy || undefined}
713
+ sortDir={sortBy ? sortDirection : null}
714
+ onsort={handleSort}
715
+ class='min-h-0 min-w-0 flex-1 overflow-auto'
716
+ {density}
717
+ cell={tableCell}
718
+ {onrowclick}
719
+ >
720
+ {#snippet actions(item)}
721
+ {#if rowClickDetail}
722
+ <IconButton
723
+ variant='ghost'
724
+ size={actionButtonSize}
725
+ ariaLabel={uiM('crud_detail')}
726
+ onclick={(event) => {
727
+ event.stopPropagation()
728
+ openDetail(item)
729
+ }}
730
+ >
731
+ <span class='icon-[tabler--eye] {actionIconClass}'></span>
732
+ </IconButton>
733
+ {/if}
734
+ {#if canUpdate}
735
+ <IconButton
736
+ variant='ghost'
737
+ size={actionButtonSize}
738
+ ariaLabel={uiM('crud_edit')}
739
+ onclick={(event) => {
740
+ event.stopPropagation()
741
+ openEdit(item)
742
+ }}
743
+ >
744
+ <span class='icon-[tabler--edit] {actionIconClass}'></span>
745
+ </IconButton>
746
+ {/if}
747
+ {#if canDelete}
748
+ <IconButton
749
+ variant='ghost'
750
+ size={actionButtonSize}
751
+ ariaLabel={uiM('crud_delete')}
752
+ onclick={(event) => {
753
+ event.stopPropagation()
754
+ requestDelete(item)
755
+ }}
756
+ class='hover:text-error'
757
+ >
758
+ <span class='icon-[tabler--trash] {actionIconClass}'></span>
759
+ </IconButton>
760
+ {/if}
761
+ {#if listItemActions}
762
+ {@render listItemActions(item)}
763
+ {/if}
764
+ {/snippet}
765
+ </DataTable>
766
+ {/if}
494
767
 
495
768
  <!-- 分页栏:始终显示,支持每页条数选择与跳页(shadcn table 风格) -->
496
769
  <div class={paginationBarClass}>