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

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
@@ -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
  ])
@@ -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,6 +51,7 @@
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
 
@@ -79,7 +82,7 @@
79
82
  </script>
80
83
 
81
84
  <div class='overflow-x-auto {className}'>
82
- <table class='table w-full text-[13px]' class:table-zebra={striped}>
85
+ <table class='table w-full {density === `compact` ? `table-sm text-xs` : `text-[13px]`}' class:table-zebra={striped}>
83
86
  <thead>
84
87
  <tr class='text-xs text-base-content/50 border-b border-base-content/6'>
85
88
  {#each columns as col (String(col.key))}
@@ -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(() => {
@@ -99,15 +103,38 @@
99
103
  jumperValue = ''
100
104
  }
101
105
  }
106
+
107
+ function handlePageSizeChange(value: string) {
108
+ const next = Number.parseInt(value, 10)
109
+ if (!Number.isNaN(next) && next !== pageSize) {
110
+ onpagesizechange?.(next)
111
+ }
112
+ }
102
113
  </script>
103
114
 
104
- <div class='flex items-center gap-4'>
115
+ <div class='flex flex-wrap items-center gap-4'>
105
116
  {#if showTotal}
106
117
  <span class='text-sm text-base-content/70'>
107
118
  {(labels.total ?? uiM('pagination_total')).replace('{count}', String(total))}
108
119
  </span>
109
120
  {/if}
110
121
 
122
+ {#if showSizeChanger}
123
+ <div class='w-28 shrink-0'>
124
+ <Select
125
+ size='sm'
126
+ value={String(pageSize)}
127
+ onchange={handlePageSizeChange}
128
+ >
129
+ {#each pageSizeOptions as opt (opt)}
130
+ <option value={String(opt)}>
131
+ {(labels.pageSize ?? uiM('pagination_page_size')).replace('{size}', String(opt))}
132
+ </option>
133
+ {/each}
134
+ </Select>
135
+ </div>
136
+ {/if}
137
+
111
138
  <div class={joinClass}>
112
139
  <BareButton
113
140
  class={btnClass}
@@ -140,18 +167,18 @@
140
167
  </div>
141
168
 
142
169
  {#if showJumper}
143
- <div class='flex items-center gap-2'>
144
- <span class='text-sm'>{labels.jumpTo ?? uiM('pagination_jump_to')}</span>
170
+ <div class='flex min-w-fit items-center gap-2 whitespace-nowrap'>
171
+ <span class='shrink-0 text-sm'>{labels.jumpTo ?? uiM('pagination_jump_to')}</span>
145
172
  <Input
146
173
  type='number'
147
- size='sm'
148
- class='w-16'
174
+ size='xs'
175
+ class='w-14 shrink-0'
149
176
  min={1}
150
177
  max={totalPages}
151
178
  bind:value={jumperValue}
152
179
  onkeydown={(e: KeyboardEvent & { currentTarget: HTMLInputElement }) => e.key === 'Enter' && handleJump()}
153
180
  />
154
- <span class='text-sm'>{labels.page ?? uiM('pagination_page')}</span>
181
+ <span class='shrink-0 text-sm'>{labels.page ?? uiM('pagination_page')}</span>
155
182
  </div>
156
183
  {/if}
157
184
  </div>
@@ -1,15 +1,17 @@
1
1
  <!--
2
- @component CrudDetailDrawer
3
- CRUD 详情抽屉组件(只读展示)
2
+ @component CrudDetailPanel
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
@@ -63,14 +75,12 @@
63
75
  return field.render(value, item)
64
76
  }
65
77
 
66
- // 选项类型:将 value 映射为 label
67
78
  if (field.type === 'select' || field.type === 'radio') {
68
79
  const opts = resolveOptions(field.options)
69
80
  const found = opts.find(o => String(o.value) === String(value))
70
81
  return found?.label ?? String(value)
71
82
  }
72
83
 
73
- // 多选类型
74
84
  if (field.type === 'multi-select' && Array.isArray(value)) {
75
85
  const opts = resolveOptions(field.options)
76
86
  return value.map((v) => {
@@ -79,12 +89,10 @@
79
89
  }).join(', ')
80
90
  }
81
91
 
82
- // 布尔
83
92
  if (field.type === 'boolean' || field.type === 'checkbox') {
84
93
  return value ? '✓' : '✗'
85
94
  }
86
95
 
87
- // 日期/时间
88
96
  if (field.type === 'date' && (typeof value === 'string' || typeof value === 'number' || value instanceof Date)) {
89
97
  return new Date(value as string | number).toLocaleDateString()
90
98
  }
@@ -107,9 +115,9 @@
107
115
  }
108
116
  </script>
109
117
 
110
- <Drawer bind:open {title} position='right' {size} onclose={handleClose}>
118
+ {#snippet detailBody()}
111
119
  {#if item}
112
- <div class='space-y-4 pb-20'>
120
+ <div class='space-y-4'>
113
121
  {#each detailFields as field (field.id)}
114
122
  {@const value = item[field.id]}
115
123
  <div>
@@ -138,18 +146,47 @@
138
146
  </div>
139
147
  {/if}
140
148
  </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
149
  {/if}
155
- </Drawer>
150
+ {/snippet}
151
+
152
+ {#snippet footerActions()}
153
+ {#if canEdit}
154
+ <Button variant='primary' onclick={onedit}>
155
+ <span class='icon-[tabler--edit] size-4 mr-1'></span>
156
+ {uiM('crud_edit')}
157
+ </Button>
158
+ {/if}
159
+ <Button variant='ghost' onclick={handleClose}>
160
+ {uiM('crud_close')}
161
+ </Button>
162
+ {/snippet}
163
+
164
+ {#if variant === 'modal'}
165
+ <Modal
166
+ bind:open
167
+ {title}
168
+ size={modalSize}
169
+ width={modalWidth}
170
+ height={modalHeight}
171
+ onclose={handleClose}
172
+ >
173
+ {@render detailBody()}
174
+
175
+ {#snippet footer()}
176
+ {@render footerActions()}
177
+ {/snippet}
178
+ </Modal>
179
+ {:else}
180
+ <Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose}>
181
+ {#if item}
182
+ <div class='pb-20'>
183
+ {@render detailBody()}
184
+ </div>
185
+
186
+ <!-- 底部操作栏 -->
187
+ <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'>
188
+ {@render footerActions()}
189
+ </div>
190
+ {/if}
191
+ </Drawer>
192
+ {/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,19 +21,24 @@ 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;
27
33
  detailExtra?: Snippet<[Record<string, unknown>]>;
28
34
  };
29
35
  /**
30
- * CrudDetailDrawer
31
- * CRUD 详情抽屉组件(只读展示)
36
+ * CrudDetailPanel
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
- declare const CrudDetailDrawer: import("svelte").Component<$$ComponentProps, {}, "open">;
37
- type CrudDetailDrawer = ReturnType<typeof CrudDetailDrawer>;
38
- export default CrudDetailDrawer;
42
+ declare const CrudDetailPanel: import("svelte").Component<$$ComponentProps, {}, "open">;
43
+ type CrudDetailPanel = ReturnType<typeof CrudDetailPanel>;
44
+ export default CrudDetailPanel;