@h-ai/ui 0.1.0-alpha.22 → 0.1.0-alpha.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +39 -5
  2. package/auto-import.d.ts +2 -2
  3. package/auto-import.js +2 -2
  4. package/dist/components/compounds/Card.svelte +1 -1
  5. package/dist/components/compounds/DataTable.svelte +32 -7
  6. package/dist/components/compounds/DataTable.svelte.d.ts +2 -0
  7. package/dist/components/compounds/Drawer.svelte +7 -2
  8. package/dist/components/compounds/Modal.svelte +2 -0
  9. package/dist/components/compounds/Pagination.svelte +57 -16
  10. package/dist/components/primitives/Input.svelte +23 -1
  11. package/dist/components/primitives/Select.svelte +2 -2
  12. package/dist/components/scenes/crud/CrudDetailDrawer.svelte +59 -18
  13. package/dist/components/scenes/crud/CrudDetailDrawer.svelte.d.ts +8 -2
  14. package/dist/components/scenes/crud/CrudDetailPanel.svelte +204 -0
  15. package/dist/components/scenes/crud/CrudDetailPanel.svelte.d.ts +45 -0
  16. package/dist/components/scenes/crud/CrudEditPanel.svelte +281 -0
  17. package/dist/components/scenes/crud/{CrudEditDrawer.svelte.d.ts → CrudEditPanel.svelte.d.ts} +13 -6
  18. package/dist/components/scenes/crud/CrudFilterBar.svelte +22 -18
  19. package/dist/components/scenes/crud/CrudPage.svelte +105 -60
  20. package/dist/components/scenes/crud/CrudPage.svelte.d.ts +6 -4
  21. package/dist/components/scenes/crud/crud-types.d.ts +36 -2
  22. package/dist/components/scenes/crud/index.d.ts +3 -3
  23. package/dist/components/scenes/crud/index.js +3 -3
  24. package/dist/messages/en-US.json +3 -2
  25. package/dist/messages/zh-CN.json +3 -2
  26. package/dist/types.d.ts +10 -0
  27. package/package.json +2 -2
  28. package/dist/components/scenes/crud/CrudEditDrawer.svelte +0 -225
package/README.md CHANGED
@@ -274,11 +274,21 @@ components/
274
274
 
275
275
  | 组件 | 描述 | 主要属性 / 能力 |
276
276
  | -------------------- | ------------ | ---------------- |
277
- | `CrudPage` | CRUD 主页面 | `definition`, `navAdapter`, `loading` |
278
- | `CrudFilterBar` | 过滤工具栏 | `filters`, `query`, `onchange` |
279
- | `CrudDetailDrawer` | 详情抽屉 | `open`, `item`, `fields` |
280
- | `CrudEditDrawer` | 编辑抽屉 | `open`, `schema`, `onsubmit` |
281
- | `CrudDeleteConfirm` | 删除确认框 | `open`, `item`, `onconfirm` |
277
+ | `CrudPage` | CRUD 主页面 | `crud`, `data`, `permissions`, `form`, `pagination`, `density`, `nav` |
278
+ | `CrudFilterBar` | 过滤工具栏 | `filterFields`, `searchValue`, `onsearch`, `onfilterchange` |
279
+ | `CrudDetailPanel` | 详情面板(抽屉/弹窗) | `open`, `item`, `fields`, `variant`, `drawerWidth`, `modalSize` |
280
+ | `CrudEditPanel` | 编辑面板(抽屉/弹窗) | `open`, `fields`, `variant`, `drawerWidth`, `modalSize`, `onsubmit` |
281
+ | `CrudDeleteConfirm` | 删除确认框 | `open`, `loading`, `onconfirm` |
282
+
283
+ > `CrudPage` 通过 `form` 配置新建/编辑的展示形式:
284
+ >
285
+ > - `form.variant`:`'drawer'`(抽屉,默认)或 `'modal'`(弹出窗口)。
286
+ > - 抽屉:`form.drawerSize` 选预设尺寸,`form.drawerWidth` 传任意 CSS 宽度(优先级更高)。
287
+ > - 弹窗:`form.modalSize` 选预设尺寸,`form.modalWidth` / `form.modalHeight` 传任意 CSS 尺寸。
288
+ >
289
+ > `pagination` 配置分页栏(始终显示):`showSizeChanger`、`pageSizeOptions`、`showJumper`、`showTotal` 默认开启。
290
+ >
291
+ > `density` 配置列表密度:`'normal'`(默认)或 `'compact'`(紧凑)。它只影响列表行、行内操作、分页与创建/编辑/详情表单元素,不影响页头与筛选栏。
282
292
 
283
293
  ## 使用示例
284
294
 
@@ -368,6 +378,30 @@ components/
368
378
  <ToastContainer />
369
379
  ```
370
380
 
381
+ ### 声明式 CRUD 页面(CrudPage)
382
+
383
+ ```svelte
384
+ <script lang='ts'>
385
+ import { createSvelteKitNavAdapter } from '@h-ai/kit/client'
386
+ import { CrudPage } from '@h-ai/ui'
387
+
388
+ let { data } = $props()
389
+ const nav = createSvelteKitNavAdapter()
390
+ </script>
391
+
392
+ <CrudPage
393
+ crud={roleCrud}
394
+ {data}
395
+ permissions={{ create: true, update: true, delete: true }}
396
+ form={{ variant: 'modal', modalSize: 'lg' }}
397
+ pagination={{ showSizeChanger: true, showJumper: true, pageSizeOptions: [10, 20, 50] }}
398
+ density='compact'
399
+ {nav}
400
+ />
401
+ ```
402
+
403
+ > 抽屉形式可改用 `form={{ variant: 'drawer', drawerWidth: '40rem' }}`。
404
+
371
405
  ### 登录页面
372
406
 
373
407
  ```svelte
package/auto-import.d.ts CHANGED
@@ -110,8 +110,8 @@ declare global {
110
110
 
111
111
  // scenes - crud
112
112
  const CrudDeleteConfirm: typeof import('@h-ai/ui')['CrudDeleteConfirm']
113
- const CrudDetailDrawer: typeof import('@h-ai/ui')['CrudDetailDrawer']
114
- const CrudEditDrawer: typeof import('@h-ai/ui')['CrudEditDrawer']
113
+ const CrudDetailPanel: typeof import('@h-ai/ui')['CrudDetailPanel']
114
+ const CrudEditPanel: typeof import('@h-ai/ui')['CrudEditPanel']
115
115
  const CrudFilterBar: typeof import('@h-ai/ui')['CrudFilterBar']
116
116
  const CrudPage: typeof import('@h-ai/ui')['CrudPage']
117
117
  }
package/auto-import.js CHANGED
@@ -95,8 +95,8 @@ const UI_COMPONENTS = new Set([
95
95
  'MarkdownRenderer',
96
96
  // scenes - crud
97
97
  'CrudDeleteConfirm',
98
- 'CrudDetailDrawer',
99
- 'CrudEditDrawer',
98
+ 'CrudDetailPanel',
99
+ 'CrudEditPanel',
100
100
  'CrudFilterBar',
101
101
  'CrudPage',
102
102
  ])
@@ -18,7 +18,7 @@
18
18
  }: CardProps = $props()
19
19
 
20
20
  const paddingMap = {
21
- 'none': '',
21
+ 'none': 'p-0',
22
22
  'xs': 'p-2',
23
23
  'sm': 'p-3',
24
24
  'md': 'p-4',
@@ -10,6 +10,7 @@
10
10
  @prop {boolean} loading - 是否加载中
11
11
  @prop {boolean} striped - 是否斑马纹
12
12
  @prop {boolean} hoverable - 是否悬停高亮
13
+ @prop {'normal' | 'compact'} density - 显示密度,compact 为紧凑布局
13
14
 
14
15
  @example
15
16
  <DataTable
@@ -39,6 +40,7 @@
39
40
  loading = false,
40
41
  striped = true,
41
42
  hoverable = true,
43
+ density = 'normal',
42
44
  class: className = '',
43
45
  }: {
44
46
  data: T[]
@@ -49,11 +51,34 @@
49
51
  loading?: boolean
50
52
  striped?: boolean
51
53
  hoverable?: boolean
54
+ density?: 'normal' | 'compact'
52
55
  class?: string
53
56
  } = $props()
54
57
 
55
58
  type Column = { key: keyof T | string, label: string, width?: string, align?: 'left' | 'center' | 'right', render?: (item: T) => string }
56
59
 
60
+ const tableClass = $derived(
61
+ density === 'compact'
62
+ ? 'table table-sm w-full text-[12px] [&_thead_th]:px-3 [&_thead_th]:py-2 [&_tbody_td]:px-3 [&_tbody_td]:py-2'
63
+ : 'table w-full text-[13px] [&_thead_th]:px-4 [&_thead_th]:py-2.5 [&_tbody_td]:px-4 [&_tbody_td]:py-3',
64
+ )
65
+
66
+ const headerRowClass = $derived(
67
+ density === 'compact'
68
+ ? 'text-[11px] text-base-content/55 border-b border-base-content/6'
69
+ : 'text-[12px] text-base-content/55 border-b border-base-content/6',
70
+ )
71
+
72
+ const rowClass = $derived(
73
+ hoverable
74
+ ? 'border-b border-base-content/5 transition-colors hover:bg-base-content/3 last:border-b-0'
75
+ : 'border-b border-base-content/5 last:border-b-0',
76
+ )
77
+
78
+ const actionColumnClass = $derived(density === 'compact' ? 'w-20 text-center font-medium' : 'w-24 text-center font-medium')
79
+ const actionWrapClass = $derived(density === 'compact' ? 'flex items-center justify-center gap-0.5' : 'flex items-center justify-center gap-1')
80
+ const placeholderCellClass = $derived(density === 'compact' ? 'text-center py-6 text-base-content/40' : 'text-center py-8 text-base-content/40')
81
+
57
82
  function getValue(item: T, col: Column): string {
58
83
  if (col.render) {
59
84
  return col.render(item)
@@ -79,9 +104,9 @@
79
104
  </script>
80
105
 
81
106
  <div class='overflow-x-auto {className}'>
82
- <table class='table w-full text-[13px]' class:table-zebra={striped}>
107
+ <table class={tableClass} class:table-zebra={striped}>
83
108
  <thead>
84
- <tr class='text-xs text-base-content/50 border-b border-base-content/6'>
109
+ <tr class={headerRowClass}>
85
110
  {#each columns as col (String(col.key))}
86
111
  <th
87
112
  style={col.width ? `width: ${col.width}` : ''}
@@ -91,20 +116,20 @@
91
116
  </th>
92
117
  {/each}
93
118
  {#if actions}
94
- <th class='w-24 text-center font-medium'>{uiM('data_table_actions')}</th>
119
+ <th class={actionColumnClass}>{uiM('data_table_actions')}</th>
95
120
  {/if}
96
121
  </tr>
97
122
  </thead>
98
123
  <tbody>
99
124
  {#if loading}
100
125
  <tr>
101
- <td colspan={columns.length + (actions ? 1 : 0)} class='text-center py-8'>
126
+ <td colspan={columns.length + (actions ? 1 : 0)} class={placeholderCellClass}>
102
127
  <span class='loading loading-spinner loading-sm'></span>
103
128
  </td>
104
129
  </tr>
105
130
  {:else if data.length === 0}
106
131
  <tr>
107
- <td colspan={columns.length + (actions ? 1 : 0)} class='text-center py-8 text-base-content/40'>
132
+ <td colspan={columns.length + (actions ? 1 : 0)} class={placeholderCellClass}>
108
133
  {#if empty}
109
134
  {@render empty()}
110
135
  {:else}
@@ -114,13 +139,13 @@
114
139
  </tr>
115
140
  {:else}
116
141
  {#each data as item (item[keyField])}
117
- <tr class={hoverable ? 'hover:bg-base-content/3 transition-colors' : ''}>
142
+ <tr class={rowClass}>
118
143
  {#each columns as col (String(col.key))}
119
144
  <td class={getAlignClass(col.align)}>{getValue(item, col)}</td>
120
145
  {/each}
121
146
  {#if actions}
122
147
  <td>
123
- <div class='flex items-center justify-center gap-1'>
148
+ <div class={actionWrapClass}>
124
149
  {@render actions(item)}
125
150
  </div>
126
151
  </td>
@@ -15,6 +15,7 @@ declare function $$render<T>(): {
15
15
  loading?: boolean;
16
16
  striped?: boolean;
17
17
  hoverable?: boolean;
18
+ density?: "normal" | "compact";
18
19
  class?: string;
19
20
  };
20
21
  exports: {};
@@ -48,6 +49,7 @@ interface $$IsomorphicComponent {
48
49
  * @prop {boolean} loading - 是否加载中
49
50
  * @prop {boolean} striped - 是否斑马纹
50
51
  * @prop {boolean} hoverable - 是否悬停高亮
52
+ * @prop {'normal' | 'compact'} density - 显示密度,compact 为紧凑布局
51
53
  *
52
54
  * @example
53
55
  * <DataTable
@@ -20,6 +20,7 @@
20
20
  title = '',
21
21
  position = 'right',
22
22
  size = 'md',
23
+ width,
23
24
  closeOnBackdrop = true,
24
25
  showClose = true,
25
26
  class: className = '',
@@ -40,6 +41,10 @@
40
41
  '4xl': 'w-[42rem]',
41
42
  }
42
43
 
44
+ // width 优先:传入自定义 CSS 宽度时通过 inline style 覆盖预设宽度类
45
+ const hasCustomWidth = $derived(typeof width === 'string' && width.trim().length > 0)
46
+ const widthStyle = $derived(hasCustomWidth ? `width: ${width!.trim()}; max-width: 100vw` : undefined)
47
+
43
48
  const drawerClass = $derived(
44
49
  cn(
45
50
  'drawer',
@@ -56,7 +61,7 @@
56
61
  const contentClass = $derived(
57
62
  cn(
58
63
  'menu bg-base-200 text-base-content min-h-full p-4',
59
- sizeMap[size],
64
+ !hasCustomWidth && sizeMap[size],
60
65
  className,
61
66
  ),
62
67
  )
@@ -86,7 +91,7 @@
86
91
  onkeydown={e => e.key === 'Enter' && handleBackdropClick()}
87
92
  ></div>
88
93
 
89
- <div class={contentClass}>
94
+ <div class={contentClass} style={widthStyle}>
90
95
  <div class='flex items-center justify-between mb-4'>
91
96
  {#if title}
92
97
  <h3 class='text-lg font-bold'>{title}</h3>
@@ -210,6 +210,7 @@
210
210
  max-height: calc(100vh - clamp(2rem, 5.2vw, 4rem));
211
211
  max-height: calc(100dvh - clamp(2rem, 5.2vw, 4rem));
212
212
  border-radius: 1.4rem;
213
+ overflow: hidden;
213
214
  color: var(--color-base-content);
214
215
  outline: none;
215
216
  box-shadow:
@@ -224,6 +225,7 @@
224
225
 
225
226
  .hai-modal__footer {
226
227
  box-shadow: inset 0 1px 0 color-mix(in srgb, var(--color-base-content) 6%, transparent);
228
+ border-radius: 0 0 1.4rem 1.4rem;
227
229
  }
228
230
 
229
231
  .hai-modal__close {
@@ -14,6 +14,7 @@
14
14
  import BareButton from '../primitives/BareButton.svelte'
15
15
 
16
16
  import Input from '../primitives/Input.svelte'
17
+ import Select from '../primitives/Select.svelte'
17
18
 
18
19
  let {
19
20
  page = $bindable(1),
@@ -22,15 +23,18 @@
22
23
  size = 'md',
23
24
  showTotal = true,
24
25
  showJumper = false,
26
+ showSizeChanger = false,
27
+ pageSizeOptions = [10, 20, 50, 100],
25
28
  labels = {},
26
29
  class: className = '',
27
30
  onchange,
31
+ onpagesizechange,
28
32
  }: PaginationProps = $props()
29
33
 
30
34
  // labels 优先,缺省回退到内置消息 uiM(...)
31
35
 
32
- // 计算总页数
33
- const totalPages = $derived(Math.ceil(total / pageSize))
36
+ // 计算总页数(至少 1 页,保证分页栏在空数据时仍可渲染)
37
+ const totalPages = $derived(Math.max(1, Math.ceil(total / pageSize)))
34
38
 
35
39
  // 生成页码列表
36
40
  const pages = $derived(() => {
@@ -83,6 +87,18 @@
83
87
  ),
84
88
  )
85
89
 
90
+ const controlSize = $derived(size === 'xs' ? 'xs' : 'sm')
91
+ const jumperControlSize = 'xs'
92
+ const wrapperClass = $derived(size === 'xs' ? 'flex flex-wrap items-center justify-center gap-1.5 text-xs' : 'flex flex-wrap items-center justify-center gap-2.5 text-sm')
93
+ const totalClass = $derived(size === 'xs' ? 'text-xs text-base-content/70' : 'text-sm text-base-content/70')
94
+ const sizeChangerClass = $derived(size === 'xs' ? 'w-22 shrink-0' : 'w-24 shrink-0')
95
+ const jumperWrapClass = $derived(size === 'xs' ? 'flex min-w-fit items-center gap-1.5 whitespace-nowrap' : 'flex min-w-fit items-center gap-1.5 whitespace-nowrap')
96
+ const jumperInputClass = $derived(
97
+ size === 'xs'
98
+ ? 'w-[2.25rem] shrink-0 [&>.fieldset]:m-0 [&>.fieldset]:min-w-0'
99
+ : 'w-[2.5rem] shrink-0 [&>.fieldset]:m-0 [&>.fieldset]:min-w-0',
100
+ )
101
+
86
102
  function goToPage(p: number) {
87
103
  if (p >= 1 && p <= totalPages && p !== page) {
88
104
  page = p
@@ -99,15 +115,38 @@
99
115
  jumperValue = ''
100
116
  }
101
117
  }
118
+
119
+ function handlePageSizeChange(value: string) {
120
+ const next = Number.parseInt(value, 10)
121
+ if (!Number.isNaN(next) && next !== pageSize) {
122
+ onpagesizechange?.(next)
123
+ }
124
+ }
102
125
  </script>
103
126
 
104
- <div class='flex items-center gap-4'>
127
+ <div class={wrapperClass}>
105
128
  {#if showTotal}
106
- <span class='text-sm text-base-content/70'>
129
+ <span class={totalClass}>
107
130
  {(labels.total ?? uiM('pagination_total')).replace('{count}', String(total))}
108
131
  </span>
109
132
  {/if}
110
133
 
134
+ {#if showSizeChanger}
135
+ <div class={sizeChangerClass}>
136
+ <Select
137
+ size={controlSize}
138
+ value={String(pageSize)}
139
+ onchange={handlePageSizeChange}
140
+ >
141
+ {#each pageSizeOptions as opt (opt)}
142
+ <option value={String(opt)}>
143
+ {(labels.pageSize ?? uiM('pagination_page_size')).replace('{size}', String(opt))}
144
+ </option>
145
+ {/each}
146
+ </Select>
147
+ </div>
148
+ {/if}
149
+
111
150
  <div class={joinClass}>
112
151
  <BareButton
113
152
  class={btnClass}
@@ -140,18 +179,20 @@
140
179
  </div>
141
180
 
142
181
  {#if showJumper}
143
- <div class='flex items-center gap-2'>
144
- <span class='text-sm'>{labels.jumpTo ?? uiM('pagination_jump_to')}</span>
145
- <Input
146
- type='number'
147
- size='sm'
148
- class='w-16'
149
- min={1}
150
- max={totalPages}
151
- bind:value={jumperValue}
152
- onkeydown={(e: KeyboardEvent & { currentTarget: HTMLInputElement }) => e.key === 'Enter' && handleJump()}
153
- />
154
- <span class='text-sm'>{labels.page ?? uiM('pagination_page')}</span>
182
+ <div class={jumperWrapClass}>
183
+ <span class={totalClass}>{labels.jumpTo ?? uiM('pagination_jump_to')}</span>
184
+ <div class={jumperInputClass}>
185
+ <Input
186
+ type='number'
187
+ size={jumperControlSize}
188
+ class='h-7 w-full rounded-md [&_input]:px-1.5 [&_input]:text-center [&_input]:text-[12px]'
189
+ min={1}
190
+ max={totalPages}
191
+ bind:value={jumperValue}
192
+ onkeydown={(e: KeyboardEvent & { currentTarget: HTMLInputElement }) => e.key === 'Enter' && handleJump()}
193
+ />
194
+ </div>
195
+ <span class={totalClass}>{labels.page ?? uiM('pagination_page')}</span>
155
196
  </div>
156
197
  {/if}
157
198
  </div>
@@ -49,6 +49,24 @@
49
49
  : 'h-10',
50
50
  )
51
51
 
52
+ const inputSpacingClass = $derived(
53
+ size === 'xs'
54
+ ? 'px-2.5 text-xs'
55
+ : size === 'sm'
56
+ ? 'px-3 text-sm'
57
+ : size === 'lg'
58
+ ? 'px-4 text-base'
59
+ : size === 'xl'
60
+ ? 'px-4 text-lg'
61
+ : 'px-3 text-sm',
62
+ )
63
+
64
+ const inputTypeClass = $derived(
65
+ type === 'number'
66
+ ? '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
67
+ : '',
68
+ )
69
+
52
70
  const containerClass = $derived(
53
71
  cn(
54
72
  'flex items-center w-full rounded-lg border bg-base-100',
@@ -114,7 +132,11 @@
114
132
  {step}
115
133
  inputmode={inputmode}
116
134
  autocomplete={autocomplete as HTMLInputElement['autocomplete']}
117
- class='flex-1 h-full px-3 bg-transparent border-none outline-none text-sm placeholder:text-base-content/35'
135
+ class={cn(
136
+ 'flex-1 h-full bg-transparent border-none outline-none placeholder:text-base-content/35',
137
+ inputSpacingClass,
138
+ inputTypeClass,
139
+ )}
118
140
  bind:value
119
141
  oninput={handleInput}
120
142
  onchange={handleChange}
@@ -10,7 +10,7 @@
10
10
  -->
11
11
  <script lang='ts' generics="T = string">
12
12
  import type { SelectProps } from '../../types.js'
13
- import { cn, getInputSizeClass } from '../../utils.js'
13
+ import { cn, getSizeClass } from '../../utils.js'
14
14
 
15
15
  let {
16
16
  value = $bindable<T>(),
@@ -32,7 +32,7 @@
32
32
  const selectClass = $derived(
33
33
  cn(
34
34
  'select w-full',
35
- getInputSizeClass(size),
35
+ getSizeClass(size, 'input'),
36
36
  error && 'select-error',
37
37
  className,
38
38
  ),
@@ -1,15 +1,17 @@
1
1
  <!--
2
2
  @component CrudDetailDrawer
3
- CRUD 详情抽屉组件(只读展示)
3
+ CRUD 详情组件(只读展示),支持抽屉(drawer)与弹窗(modal)两种展示形式
4
4
 
5
5
  使用 Svelte 5 Runes ($props, $derived)
6
- 使用 compounds 组件:Drawer, Badge
6
+ 使用 compounds 组件:Drawer, Modal, Badge
7
7
  -->
8
8
  <script lang='ts'>
9
9
  import type { Snippet } from 'svelte'
10
10
  import type { Size } from '../../../types.js'
11
+ import type { CrudFormVariant } from './crud-types.js'
11
12
  import { uiM } from '../../../messages.js'
12
13
  import Drawer from '../../compounds/Drawer.svelte'
14
+ import Modal from '../../compounds/Modal.svelte'
13
15
  import Badge from '../../primitives/Badge.svelte'
14
16
  import Button from '../../primitives/Button.svelte'
15
17
 
@@ -28,7 +30,12 @@
28
30
  item = null,
29
31
  fields = [],
30
32
  title = '',
33
+ variant = 'drawer' as CrudFormVariant,
31
34
  size = '2xl' as Size,
35
+ drawerWidth,
36
+ modalSize = '2xl' as Size | 'full',
37
+ modalWidth,
38
+ modalHeight,
32
39
  canEdit = false,
33
40
  onedit,
34
41
  onclose,
@@ -38,7 +45,12 @@
38
45
  item?: Record<string, unknown> | null
39
46
  fields?: FieldDef[]
40
47
  title?: string
48
+ variant?: CrudFormVariant
41
49
  size?: Size
50
+ drawerWidth?: string
51
+ modalSize?: Size | 'full'
52
+ modalWidth?: string
53
+ modalHeight?: string
42
54
  canEdit?: boolean
43
55
  onedit?: () => void
44
56
  onclose?: () => void
@@ -107,9 +119,9 @@
107
119
  }
108
120
  </script>
109
121
 
110
- <Drawer bind:open {title} position='right' {size} onclose={handleClose}>
122
+ {#snippet detailBody()}
111
123
  {#if item}
112
- <div class='space-y-4 pb-20'>
124
+ <div class='space-y-4'>
113
125
  {#each detailFields as field (field.id)}
114
126
  {@const value = item[field.id]}
115
127
  <div>
@@ -138,18 +150,47 @@
138
150
  </div>
139
151
  {/if}
140
152
  </div>
141
-
142
- <!-- 底部操作栏 -->
143
- <div class='absolute bottom-0 left-0 right-0 p-4 bg-base-200 border-t border-base-content/10 flex justify-end gap-2'>
144
- {#if canEdit}
145
- <Button variant='primary' onclick={onedit}>
146
- <span class='icon-[tabler--edit] size-4 mr-1'></span>
147
- {uiM('crud_edit')}
148
- </Button>
149
- {/if}
150
- <Button variant='ghost' onclick={handleClose}>
151
- {uiM('crud_close')}
152
- </Button>
153
- </div>
154
153
  {/if}
155
- </Drawer>
154
+ {/snippet}
155
+
156
+ {#snippet footerActions()}
157
+ {#if canEdit}
158
+ <Button variant='primary' onclick={onedit}>
159
+ <span class='icon-[tabler--edit] size-4 mr-1'></span>
160
+ {uiM('crud_edit')}
161
+ </Button>
162
+ {/if}
163
+ <Button variant='ghost' onclick={handleClose}>
164
+ {uiM('crud_close')}
165
+ </Button>
166
+ {/snippet}
167
+
168
+ {#if variant === 'modal'}
169
+ <Modal
170
+ bind:open
171
+ {title}
172
+ size={modalSize}
173
+ width={modalWidth}
174
+ height={modalHeight}
175
+ onclose={handleClose}
176
+ >
177
+ {@render detailBody()}
178
+
179
+ {#snippet footer()}
180
+ {@render footerActions()}
181
+ {/snippet}
182
+ </Modal>
183
+ {:else}
184
+ <Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose}>
185
+ {#if item}
186
+ <div class='pb-20'>
187
+ {@render detailBody()}
188
+ </div>
189
+
190
+ <!-- 底部操作栏 -->
191
+ <div class='absolute bottom-0 left-0 right-0 p-4 bg-base-200 border-t border-base-content/10 flex justify-end gap-2'>
192
+ {@render footerActions()}
193
+ </div>
194
+ {/if}
195
+ </Drawer>
196
+ {/if}
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { Size } from '../../../types.js';
3
+ import type { CrudFormVariant } from './crud-types.js';
3
4
  type FieldDef = {
4
5
  id: string;
5
6
  label: string | (() => string);
@@ -20,7 +21,12 @@ type $$ComponentProps = {
20
21
  item?: Record<string, unknown> | null;
21
22
  fields?: FieldDef[];
22
23
  title?: string;
24
+ variant?: CrudFormVariant;
23
25
  size?: Size;
26
+ drawerWidth?: string;
27
+ modalSize?: Size | 'full';
28
+ modalWidth?: string;
29
+ modalHeight?: string;
24
30
  canEdit?: boolean;
25
31
  onedit?: () => void;
26
32
  onclose?: () => void;
@@ -28,10 +34,10 @@ type $$ComponentProps = {
28
34
  };
29
35
  /**
30
36
  * CrudDetailDrawer
31
- * CRUD 详情抽屉组件(只读展示)
37
+ * CRUD 详情组件(只读展示),支持抽屉(drawer)与弹窗(modal)两种展示形式
32
38
  *
33
39
  * 使用 Svelte 5 Runes ($props, $derived)
34
- * 使用 compounds 组件:Drawer, Badge
40
+ * 使用 compounds 组件:Drawer, Modal, Badge
35
41
  */
36
42
  declare const CrudDetailDrawer: import("svelte").Component<$$ComponentProps, {}, "open">;
37
43
  type CrudDetailDrawer = ReturnType<typeof CrudDetailDrawer>;