@h-ai/ui 0.1.0-alpha.25 → 0.1.0-alpha.26

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 (32) hide show
  1. package/README.md +19 -7
  2. package/auto-import.d.ts +5 -0
  3. package/auto-import.js +4 -0
  4. package/dist/components/compounds/DataTable.svelte +81 -4
  5. package/dist/components/compounds/DataTable.svelte.d.ts +7 -0
  6. package/dist/components/compounds/Modal.svelte +44 -6
  7. package/dist/components/compounds/Pagination.svelte +72 -119
  8. package/dist/components/scenes/app/SettingsLayout.svelte +111 -0
  9. package/dist/components/scenes/app/SettingsLayout.svelte.d.ts +41 -0
  10. package/dist/components/scenes/app/ThemeColorPicker.svelte +41 -0
  11. package/dist/components/scenes/app/index.d.ts +1 -0
  12. package/dist/components/scenes/app/index.js +1 -0
  13. package/dist/components/scenes/crud/CrudFilterBar.svelte +173 -35
  14. package/dist/components/scenes/crud/CrudFilterBar.svelte.d.ts +7 -1
  15. package/dist/components/scenes/crud/CrudPage.svelte +19 -15
  16. package/dist/components/scenes/error/ErrorPage.svelte +125 -0
  17. package/dist/components/scenes/error/ErrorPage.svelte.d.ts +42 -0
  18. package/dist/components/scenes/error/error-presets.d.ts +24 -0
  19. package/dist/components/scenes/error/error-presets.js +31 -0
  20. package/dist/components/scenes/error/index.d.ts +8 -0
  21. package/dist/components/scenes/error/index.js +8 -0
  22. package/dist/components/scenes/iam/AuthShell.svelte +138 -0
  23. package/dist/components/scenes/iam/AuthShell.svelte.d.ts +45 -0
  24. package/dist/components/scenes/iam/index.d.ts +1 -0
  25. package/dist/components/scenes/iam/index.js +2 -1
  26. package/dist/components/scenes/index.d.ts +1 -0
  27. package/dist/components/scenes/index.js +2 -0
  28. package/dist/messages/en-US.json +20 -1
  29. package/dist/messages/zh-CN.json +20 -1
  30. package/dist/styles/global.css +4 -1
  31. package/dist/types.d.ts +6 -0
  32. package/package.json +2 -2
@@ -0,0 +1,111 @@
1
+ <!--
2
+ @component SettingsLayout
3
+ 设置页布局场景组件
4
+
5
+ 借鉴 shadcn-admin 设置页:顶部标题/描述 + 左侧分区导航 + 右侧内容区。
6
+ 分区内容由使用方通过 children 提供,导航高亮由 active 控制。
7
+
8
+ 使用 Svelte 5 Runes ($props, $derived)
9
+
10
+ @example
11
+ <SettingsLayout
12
+ title="设置"
13
+ description="管理你的账户与偏好"
14
+ sections={[{ id: 'appearance', label: '外观', icon: 'icon-[tabler--palette]' }]}
15
+ active={active}
16
+ onselect={(id) => active = id}
17
+ >
18
+ {#if active === 'appearance'}<AppearanceSection />{/if}
19
+ </SettingsLayout>
20
+ -->
21
+ <script lang='ts'>
22
+ import type { Snippet } from 'svelte'
23
+ import { cn } from '../../../utils.js'
24
+ import BareButton from '../../primitives/BareButton.svelte'
25
+
26
+ type SettingsSection = {
27
+ id: string
28
+ label: string
29
+ icon?: string
30
+ /** 提供时渲染为链接(SSR 友好),否则触发 onselect */
31
+ href?: string
32
+ }
33
+
34
+ const {
35
+ title = '',
36
+ description = '',
37
+ sections = [],
38
+ active = '',
39
+ onselect,
40
+ children,
41
+ headerActions,
42
+ class: className = '',
43
+ }: {
44
+ title?: string
45
+ description?: string
46
+ sections?: SettingsSection[]
47
+ active?: string
48
+ onselect?: (id: string) => void
49
+ children?: Snippet
50
+ headerActions?: Snippet
51
+ class?: string
52
+ } = $props()
53
+
54
+ function navItemClass(id: string): string {
55
+ return cn(
56
+ 'flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
57
+ id === active
58
+ ? 'bg-base-content/8 text-base-content'
59
+ : 'text-base-content/60 hover:bg-base-content/5 hover:text-base-content',
60
+ )
61
+ }
62
+ </script>
63
+
64
+ <div class={cn('space-y-5', className)}>
65
+ <!-- 标题区 -->
66
+ {#if title || description || headerActions}
67
+ <div class='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
68
+ <div>
69
+ {#if title}
70
+ <h1 class='text-xl font-semibold tracking-tight'>{title}</h1>
71
+ {/if}
72
+ {#if description}
73
+ <p class='mt-0.5 text-sm text-base-content/45'>{description}</p>
74
+ {/if}
75
+ </div>
76
+ {#if headerActions}
77
+ <div class='flex items-center gap-2'>{@render headerActions()}</div>
78
+ {/if}
79
+ </div>
80
+ {/if}
81
+
82
+ <div class='grid gap-6 lg:grid-cols-[13rem_minmax(0,1fr)]'>
83
+ <!-- 左侧分区导航 -->
84
+ {#if sections.length > 0}
85
+ <aside class='lg:sticky lg:top-4 lg:self-start'>
86
+ <nav class='flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible'>
87
+ {#each sections as section (section.id)}
88
+ {#if section.href}
89
+ <a href={section.href} class={navItemClass(section.id)} aria-current={section.id === active ? 'page' : undefined}>
90
+ {#if section.icon}<span class='{section.icon} size-4 shrink-0'></span>{/if}
91
+ <span class='whitespace-nowrap'>{section.label}</span>
92
+ </a>
93
+ {:else}
94
+ <BareButton type='button' class={navItemClass(section.id)} onclick={() => onselect?.(section.id)}>
95
+ {#if section.icon}<span class='{section.icon} size-4 shrink-0'></span>{/if}
96
+ <span class='whitespace-nowrap'>{section.label}</span>
97
+ </BareButton>
98
+ {/if}
99
+ {/each}
100
+ </nav>
101
+ </aside>
102
+ {/if}
103
+
104
+ <!-- 内容区 -->
105
+ <div class='min-w-0 space-y-5'>
106
+ {#if children}
107
+ {@render children()}
108
+ {/if}
109
+ </div>
110
+ </div>
111
+ </div>
@@ -0,0 +1,41 @@
1
+ import type { Snippet } from 'svelte';
2
+ type SettingsSection = {
3
+ id: string;
4
+ label: string;
5
+ icon?: string;
6
+ /** 提供时渲染为链接(SSR 友好),否则触发 onselect */
7
+ href?: string;
8
+ };
9
+ type $$ComponentProps = {
10
+ title?: string;
11
+ description?: string;
12
+ sections?: SettingsSection[];
13
+ active?: string;
14
+ onselect?: (id: string) => void;
15
+ children?: Snippet;
16
+ headerActions?: Snippet;
17
+ class?: string;
18
+ };
19
+ /**
20
+ * SettingsLayout
21
+ * 设置页布局场景组件
22
+ *
23
+ * 借鉴 shadcn-admin 设置页:顶部标题/描述 + 左侧分区导航 + 右侧内容区。
24
+ * 分区内容由使用方通过 children 提供,导航高亮由 active 控制。
25
+ *
26
+ * 使用 Svelte 5 Runes ($props, $derived)
27
+ *
28
+ * @example
29
+ * <SettingsLayout
30
+ * title="设置"
31
+ * description="管理你的账户与偏好"
32
+ * sections={[{ id: 'appearance', label: '外观', icon: 'icon-[tabler--palette]' }]}
33
+ * active={active}
34
+ * onselect={(id) => active = id}
35
+ * >
36
+ * {#if active === 'appearance'}<AppearanceSection />{/if}
37
+ * </SettingsLayout>
38
+ */
39
+ declare const SettingsLayout: import("svelte").Component<$$ComponentProps, {}, "">;
40
+ type SettingsLayout = ReturnType<typeof SettingsLayout>;
41
+ export default SettingsLayout;
@@ -163,3 +163,44 @@
163
163
  />
164
164
  </div>
165
165
  </div>
166
+
167
+ <style>
168
+ button {
169
+ border-color: var(--soft-border, color-mix(in oklab, var(--color-base-content) 12%, transparent));
170
+ background: var(--soft-surface, var(--color-base-100));
171
+ color: var(--soft-text-body, var(--color-base-content));
172
+ box-shadow: none;
173
+ font-weight: 500;
174
+ }
175
+
176
+ button:hover:not(:disabled) {
177
+ border-color: color-mix(in oklab, var(--color-primary) 28%, var(--soft-border, transparent) 72%);
178
+ background: color-mix(
179
+ in oklab,
180
+ var(--soft-surface, var(--color-base-100)) 88%,
181
+ var(--color-base-200) 12%
182
+ );
183
+ }
184
+
185
+ button[aria-pressed='true'] {
186
+ border-color: color-mix(in oklab, var(--color-primary) 42%, var(--soft-border, transparent) 58%);
187
+ background: color-mix(
188
+ in oklab,
189
+ var(--color-primary) 10%,
190
+ var(--soft-surface, var(--color-base-100)) 90%
191
+ );
192
+ color: var(--color-primary);
193
+ }
194
+
195
+ button:focus,
196
+ button:focus-visible {
197
+ outline: none;
198
+ border-color: color-mix(in oklab, var(--color-primary) 48%, var(--soft-border, transparent) 52%);
199
+ box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-primary) 12%, transparent 88%);
200
+ }
201
+
202
+ button > span:first-child {
203
+ border-color: color-mix(in oklab, var(--color-base-content) 10%, transparent 90%);
204
+ box-shadow: none;
205
+ }
206
+ </style>
@@ -6,6 +6,7 @@
6
6
  */
7
7
  export { default as FeedbackModal } from './FeedbackModal.svelte';
8
8
  export { default as LanguageSwitch } from './LanguageSwitch.svelte';
9
+ export { default as SettingsLayout } from './SettingsLayout.svelte';
9
10
  export { default as SettingsModal } from './SettingsModal.svelte';
10
11
  export { default as ThemeColorPicker } from './ThemeColorPicker.svelte';
11
12
  export { default as ThemeSelector } from './ThemeSelector.svelte';
@@ -6,6 +6,7 @@
6
6
  */
7
7
  export { default as FeedbackModal } from './FeedbackModal.svelte';
8
8
  export { default as LanguageSwitch } from './LanguageSwitch.svelte';
9
+ export { default as SettingsLayout } from './SettingsLayout.svelte';
9
10
  export { default as SettingsModal } from './SettingsModal.svelte';
10
11
  export { default as ThemeColorPicker } from './ThemeColorPicker.svelte';
11
12
  export { default as ThemeSelector } from './ThemeSelector.svelte';
@@ -6,6 +6,7 @@
6
6
  -->
7
7
  <script lang='ts'>
8
8
  import { uiM } from '../../../messages.js'
9
+ import BareButton from '../../primitives/BareButton.svelte'
9
10
  import Input from '../../primitives/Input.svelte'
10
11
  import Select from '../../primitives/Select.svelte'
11
12
 
@@ -13,6 +14,12 @@
13
14
  id: string
14
15
  label: string | (() => string)
15
16
  type: string
17
+ placeholder?: string | (() => string)
18
+ startKey?: string
19
+ endKey?: string
20
+ min?: number
21
+ max?: number
22
+ step?: number
16
23
  options?: Array<{ label: string, value: string | number | boolean }> | (() => Array<{ label: string, value: string | number | boolean }>)
17
24
  }
18
25
 
@@ -22,22 +29,62 @@
22
29
  searchValue = $bindable(''),
23
30
  filterFields = [],
24
31
  filterValues = $bindable<Record<string, unknown>>({}),
25
- total = 0,
26
32
  onsearch,
27
33
  onfilterchange,
34
+ onreset,
28
35
  }: {
29
36
  searchable?: boolean
30
37
  searchPlaceholder?: string
31
38
  searchValue?: string
32
39
  filterFields?: FilterFieldDef[]
33
40
  filterValues?: Record<string, unknown>
34
- total?: number
35
41
  onsearch?: (search: string) => void
36
42
  onfilterchange?: (filters: Record<string, unknown>) => void
43
+ onreset?: () => void
37
44
  } = $props()
38
45
 
39
46
  let searchTimer: ReturnType<typeof setTimeout> | undefined
40
47
 
48
+ // 是否存在已激活的搜索/过滤条件(用于显示重置按钮)
49
+ const hasFilterValue = $derived(Object.values(filterValues).some(v => v !== undefined && v !== '' && v !== null))
50
+ const hasActiveFilters = $derived(searchValue.trim() !== '' || hasFilterValue)
51
+
52
+ const dateLikeTypes = new Set(['date', 'datetime-local', 'datetime', 'month', 'week'])
53
+ const numberLikeTypes = new Set(['number', 'integer'])
54
+
55
+ function resolveText(text: string | (() => string) | undefined): string {
56
+ if (!text)
57
+ return ''
58
+ return typeof text === 'function' ? text() : text
59
+ }
60
+
61
+ function normalizeType(type: string): string {
62
+ return type.trim().toLowerCase()
63
+ }
64
+
65
+ function isBooleanField(field: FilterFieldDef): boolean {
66
+ const t = normalizeType(field.type)
67
+ return t === 'boolean' || t === 'switch' || t === 'toggle'
68
+ }
69
+
70
+ function isDateRangeField(field: FilterFieldDef): boolean {
71
+ const t = normalizeType(field.type)
72
+ return t === 'date-range' || t === 'daterange'
73
+ }
74
+
75
+ function isDateField(field: FilterFieldDef): boolean {
76
+ return dateLikeTypes.has(normalizeType(field.type))
77
+ }
78
+
79
+ function isNumberField(field: FilterFieldDef): boolean {
80
+ return numberLikeTypes.has(normalizeType(field.type))
81
+ }
82
+
83
+ function isTextField(field: FilterFieldDef): boolean {
84
+ const t = normalizeType(field.type)
85
+ return t === 'text' || t === 'search' || t === 'keyword'
86
+ }
87
+
41
88
  function resolveOptions(options?: FilterFieldDef['options']): Array<{ label: string, value: string | number | boolean }> {
42
89
  if (!options)
43
90
  return []
@@ -55,46 +102,137 @@
55
102
  filterValues = { ...filterValues, [fieldId]: value }
56
103
  onfilterchange?.(filterValues)
57
104
  }
105
+
106
+ function handleFilterPatch(partial: Record<string, unknown>) {
107
+ filterValues = { ...filterValues, ...partial }
108
+ onfilterchange?.(filterValues)
109
+ }
110
+
111
+ function handleReset() {
112
+ clearTimeout(searchTimer)
113
+ searchValue = ''
114
+ filterValues = {}
115
+ onreset?.()
116
+ }
58
117
  </script>
59
118
 
60
- <div class='grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center'>
61
- <div class='flex min-w-0 flex-wrap items-center gap-3 [&_.fieldset]:m-0 [&_.fieldset]:min-w-0'>
62
- {#if searchable}
63
- <div class='relative min-w-[15rem] flex-[1_1_20rem]'>
64
- <span class='icon-[tabler--search] pointer-events-none absolute left-3.5 top-1/2 z-10 size-4 -translate-y-1/2 text-base-content/35'></span>
119
+ <div class='flex flex-wrap items-center gap-2 [&_.fieldset]:m-0 [&_.fieldset]:min-w-0'>
120
+ {#if searchable}
121
+ <div class='relative w-full sm:w-64 md:w-72'>
122
+ <span class='icon-[tabler--search] pointer-events-none absolute left-3 top-1/2 z-10 size-4 -translate-y-1/2 text-base-content/35'></span>
123
+ <Input
124
+ type='text'
125
+ size='sm'
126
+ placeholder={searchPlaceholder || uiM('crud_search_placeholder')}
127
+ class='h-8 pl-9 shadow-none'
128
+ bind:value={searchValue}
129
+ oninput={handleSearchInput}
130
+ autocomplete='off'
131
+ />
132
+ </div>
133
+ {/if}
134
+
135
+ {#each filterFields as field (field.id)}
136
+ {@const fieldPlaceholder = resolveText(field.placeholder)}
137
+ {@const opts = resolveOptions(field.options)}
138
+
139
+ {#if isDateRangeField(field)}
140
+ {@const startKey = field.startKey ?? `${field.id}Start`}
141
+ {@const endKey = field.endKey ?? `${field.id}End`}
142
+ <div class='flex items-center gap-1.5'>
143
+ <div class='w-32 shrink-0'>
144
+ <input
145
+ type='date'
146
+ class='h-8 w-full rounded-lg border border-base-content/15 bg-base-100 px-2.5 text-sm outline-none transition-[border-color,box-shadow] duration-150 focus:border-primary/50 focus:ring-2 focus:ring-primary/10'
147
+ aria-label={fieldPlaceholder || uiM('crud_filter_from')}
148
+ value={String(filterValues[startKey] ?? '')}
149
+ onchange={(e: Event & { currentTarget: HTMLInputElement }) => handleFilterPatch({ [startKey]: e.currentTarget.value })}
150
+ />
151
+ </div>
152
+ <span class='text-sm text-base-content/40'>~</span>
153
+ <div class='w-32 shrink-0'>
154
+ <input
155
+ type='date'
156
+ class='h-8 w-full rounded-lg border border-base-content/15 bg-base-100 px-2.5 text-sm outline-none transition-[border-color,box-shadow] duration-150 focus:border-primary/50 focus:ring-2 focus:ring-primary/10'
157
+ aria-label={uiM('crud_filter_to')}
158
+ value={String(filterValues[endKey] ?? '')}
159
+ onchange={(e: Event & { currentTarget: HTMLInputElement }) => handleFilterPatch({ [endKey]: e.currentTarget.value })}
160
+ />
161
+ </div>
162
+ </div>
163
+ {:else if isBooleanField(field)}
164
+ <div class='w-30 shrink-0'>
165
+ <Select
166
+ size='sm'
167
+ class='h-8 border-base-content/20 bg-base-100 shadow-none'
168
+ value={String(filterValues[field.id] ?? '')}
169
+ onchange={(value: string) => handleFilterChange(field.id, value)}
170
+ >
171
+ <option value="">{uiM('crud_filter_all')}</option>
172
+ <option value='true'>{uiM('crud_filter_yes')}</option>
173
+ <option value='false'>{uiM('crud_filter_no')}</option>
174
+ </Select>
175
+ </div>
176
+ {:else if isDateField(field)}
177
+ <div class='w-36 shrink-0'>
178
+ <input
179
+ type='date'
180
+ class='h-8 w-full rounded-lg border border-base-content/15 bg-base-100 px-2.5 text-sm outline-none transition-[border-color,box-shadow] duration-150 focus:border-primary/50 focus:ring-2 focus:ring-primary/10'
181
+ aria-label={fieldPlaceholder || resolveText(field.label)}
182
+ value={String(filterValues[field.id] ?? '')}
183
+ onchange={(e: Event & { currentTarget: HTMLInputElement }) => handleFilterChange(field.id, e.currentTarget.value)}
184
+ />
185
+ </div>
186
+ {:else if isNumberField(field)}
187
+ <div class='w-28 shrink-0'>
188
+ <Input
189
+ type='number'
190
+ size='sm'
191
+ min={field.min}
192
+ max={field.max}
193
+ step={field.step}
194
+ class='h-8 shadow-none'
195
+ placeholder={fieldPlaceholder || resolveText(field.label)}
196
+ value={String(filterValues[field.id] ?? '')}
197
+ onchange={(e: Event & { currentTarget: HTMLInputElement }) => handleFilterChange(field.id, e.currentTarget.value)}
198
+ />
199
+ </div>
200
+ {:else if opts.length > 0}
201
+ <div class='w-34 shrink-0'>
202
+ <Select
203
+ size='sm'
204
+ class='h-8 border-base-content/20 bg-base-100 shadow-none'
205
+ value={String(filterValues[field.id] ?? '')}
206
+ onchange={(value: string) => handleFilterChange(field.id, value)}
207
+ >
208
+ <option value="">{uiM('crud_filter_all')}</option>
209
+ {#each opts as opt (String(opt.value))}
210
+ <option value={String(opt.value)}>{opt.label}</option>
211
+ {/each}
212
+ </Select>
213
+ </div>
214
+ {:else if isTextField(field)}
215
+ <div class='w-44 shrink-0'>
65
216
  <Input
66
217
  type='text'
67
218
  size='sm'
68
- placeholder={searchPlaceholder || uiM('crud_search_placeholder')}
69
- class='pl-10 shadow-none'
70
- bind:value={searchValue}
71
- oninput={handleSearchInput}
72
- autocomplete='off'
219
+ class='h-8 shadow-none'
220
+ placeholder={fieldPlaceholder || resolveText(field.label)}
221
+ value={String(filterValues[field.id] ?? '')}
222
+ onchange={(e: Event & { currentTarget: HTMLInputElement }) => handleFilterChange(field.id, e.currentTarget.value)}
73
223
  />
74
224
  </div>
75
225
  {/if}
226
+ {/each}
76
227
 
77
- {#each filterFields as field (field.id)}
78
- {@const opts = resolveOptions(field.options)}
79
- {#if opts.length > 0}
80
- <div class='w-full sm:w-[11rem] sm:flex-none'>
81
- <Select
82
- size='sm'
83
- class='border-base-content/10 bg-base-100 shadow-none'
84
- value={String(filterValues[field.id] ?? '')}
85
- onchange={value => handleFilterChange(field.id, value)}
86
- >
87
- <option value="">{uiM('crud_filter_all')}</option>
88
- {#each opts as opt (String(opt.value))}
89
- <option value={String(opt.value)}>{opt.label}</option>
90
- {/each}
91
- </Select>
92
- </div>
93
- {/if}
94
- {/each}
95
- </div>
96
-
97
- <div class='inline-flex items-center justify-self-start rounded-full border border-base-content/8 bg-base-200/60 px-3 py-1 text-xs font-medium text-base-content/60 md:justify-self-end'>
98
- {uiM('crud_total', { count: total })}
99
- </div>
228
+ {#if hasActiveFilters}
229
+ <BareButton
230
+ type='button'
231
+ class='inline-flex h-8 items-center gap-1 rounded-md px-2.5 text-sm font-medium text-base-content/60 transition-colors hover:bg-base-content/5 hover:text-base-content'
232
+ onclick={handleReset}
233
+ >
234
+ {uiM('crud_reset')}
235
+ <span class='icon-[tabler--x] size-3.5'></span>
236
+ </BareButton>
237
+ {/if}
100
238
  </div>
@@ -2,6 +2,12 @@ type FilterFieldDef = {
2
2
  id: string;
3
3
  label: string | (() => string);
4
4
  type: string;
5
+ placeholder?: string | (() => string);
6
+ startKey?: string;
7
+ endKey?: string;
8
+ min?: number;
9
+ max?: number;
10
+ step?: number;
5
11
  options?: Array<{
6
12
  label: string;
7
13
  value: string | number | boolean;
@@ -16,9 +22,9 @@ type $$ComponentProps = {
16
22
  searchValue?: string;
17
23
  filterFields?: FilterFieldDef[];
18
24
  filterValues?: Record<string, unknown>;
19
- total?: number;
20
25
  onsearch?: (search: string) => void;
21
26
  onfilterchange?: (filters: Record<string, unknown>) => void;
27
+ onreset?: () => void;
22
28
  };
23
29
  /**
24
30
  * CrudFilterBar
@@ -142,9 +142,8 @@
142
142
  const actionButtonSize = $derived(isCompact ? 'xs' : 'sm')
143
143
  const actionIconClass = $derived(isCompact ? 'size-3.5' : 'size-4')
144
144
  const paginationSize = $derived(isCompact ? 'xs' : 'sm')
145
- const filterCardClass = 'overflow-hidden rounded-[1.25rem] border-base-content/7 shadow-sm'
146
145
  const listCardClass = 'overflow-hidden rounded-[1.25rem] border-base-content/7 shadow-sm'
147
- const paginationBarClass = 'flex justify-center border-t border-base-content/5 px-4 py-2'
146
+ const paginationBarClass = 'border-t border-base-content/5 px-4 py-3'
148
147
 
149
148
  // 表单展示配置(抽屉 / 弹窗)
150
149
  const formVariant = $derived(form.variant ?? 'drawer')
@@ -249,6 +248,12 @@
249
248
  navigateWithParams({ pageSize: newPageSize, page: 1 })
250
249
  }
251
250
 
251
+ function handleResetFilters() {
252
+ searchValue = ''
253
+ filterValues = {}
254
+ void Promise.resolve(nav.navigate(currentBasePath)).then(() => nav.refresh?.())
255
+ }
256
+
252
257
  // ─── 打开/关闭抽屉 ───
253
258
 
254
259
  function openDetail(item: Record<string, unknown>) {
@@ -396,6 +401,7 @@
396
401
  label: col.label,
397
402
  width: col.width,
398
403
  align: col.align as 'left' | 'center' | 'right' | undefined,
404
+ sortable: true,
399
405
  render: col.render
400
406
  ? (item: Record<string, unknown>) => col.render!(item)
401
407
  : undefined,
@@ -421,18 +427,16 @@
421
427
 
422
428
  <!-- 搜索 + 过滤栏 -->
423
429
  {#if searchable || filterFields.length > 0}
424
- <Card padding='sm' class={filterCardClass}>
425
- <CrudFilterBar
426
- {searchable}
427
- searchPlaceholder={searchPlaceholderText}
428
- bind:searchValue
429
- {filterFields}
430
- bind:filterValues
431
- total={data.total}
432
- onsearch={handleSearch}
433
- onfilterchange={handleFilterChange}
434
- />
435
- </Card>
430
+ <CrudFilterBar
431
+ {searchable}
432
+ searchPlaceholder={searchPlaceholderText}
433
+ bind:searchValue
434
+ {filterFields}
435
+ bind:filterValues
436
+ onsearch={handleSearch}
437
+ onfilterchange={handleFilterChange}
438
+ onreset={handleResetFilters}
439
+ />
436
440
  {/if}
437
441
 
438
442
  <!-- 数据列表 -->
@@ -482,7 +486,7 @@
482
486
  {/snippet}
483
487
  </DataTable>
484
488
 
485
- <!-- 分页栏:始终显示,支持每页条数选择与跳页 -->
489
+ <!-- 分页栏:始终显示,支持每页条数选择与跳页(shadcn table 风格) -->
486
490
  <div class={paginationBarClass}>
487
491
  <Pagination
488
492
  page={data.page}