@h-ai/ui 0.1.0-alpha.24 → 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.
- package/README.md +41 -11
- package/auto-import.d.ts +5 -0
- package/auto-import.js +4 -0
- package/dist/components/compounds/DataTable.svelte +81 -4
- package/dist/components/compounds/DataTable.svelte.d.ts +7 -0
- package/dist/components/compounds/Modal.svelte +123 -63
- package/dist/components/compounds/Pagination.svelte +72 -119
- package/dist/components/scenes/ai/markdown-parse.js +12 -4
- package/dist/components/scenes/ai/table/AiTableEditor.svelte +4 -2
- package/dist/components/scenes/app/SettingsLayout.svelte +111 -0
- package/dist/components/scenes/app/SettingsLayout.svelte.d.ts +41 -0
- package/dist/components/scenes/app/ThemeColorPicker.svelte +41 -0
- package/dist/components/scenes/app/index.d.ts +1 -0
- package/dist/components/scenes/app/index.js +1 -0
- package/dist/components/scenes/crud/CrudFilterBar.svelte +173 -35
- package/dist/components/scenes/crud/CrudFilterBar.svelte.d.ts +7 -1
- package/dist/components/scenes/crud/CrudPage.svelte +19 -15
- package/dist/components/scenes/error/ErrorPage.svelte +125 -0
- package/dist/components/scenes/error/ErrorPage.svelte.d.ts +42 -0
- package/dist/components/scenes/error/error-presets.d.ts +24 -0
- package/dist/components/scenes/error/error-presets.js +31 -0
- package/dist/components/scenes/error/index.d.ts +8 -0
- package/dist/components/scenes/error/index.js +8 -0
- package/dist/components/scenes/iam/AuthShell.svelte +138 -0
- package/dist/components/scenes/iam/AuthShell.svelte.d.ts +45 -0
- package/dist/components/scenes/iam/index.d.ts +1 -0
- package/dist/components/scenes/iam/index.js +2 -1
- package/dist/components/scenes/index.d.ts +1 -0
- package/dist/components/scenes/index.js +2 -0
- package/dist/messages/en-US.json +20 -1
- package/dist/messages/zh-CN.json +20 -1
- package/dist/styles/global.css +4 -1
- package/dist/theme-config.d.ts +60 -4
- package/dist/theme-config.js +77 -6
- package/dist/types.d.ts +6 -0
- package/package.json +2 -2
|
@@ -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='
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
<
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 = '
|
|
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
|
-
<
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component ErrorPage
|
|
3
|
+
通用错误页场景组件
|
|
4
|
+
|
|
5
|
+
展示大号状态码 + 标题 + 描述 + 操作按钮,内置 401/403/404/500/503 预设。
|
|
6
|
+
借鉴 shadcn-admin 错误页布局,复用现有主题 token。
|
|
7
|
+
|
|
8
|
+
使用 Svelte 5 Runes ($props, $derived)
|
|
9
|
+
|
|
10
|
+
@example
|
|
11
|
+
<ErrorPage status={403} homeUrl="/admin" onback={() => history.back()} />
|
|
12
|
+
-->
|
|
13
|
+
<script lang='ts'>
|
|
14
|
+
import type { Snippet } from 'svelte'
|
|
15
|
+
import type { ErrorPreset } from './error-presets.js'
|
|
16
|
+
import { uiM } from '../../../messages.js'
|
|
17
|
+
import { cn } from '../../../utils.js'
|
|
18
|
+
import Button from '../../primitives/Button.svelte'
|
|
19
|
+
import { ERROR_PRESETS, resolveErrorPreset } from './error-presets.js'
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
status = 404,
|
|
23
|
+
code,
|
|
24
|
+
title,
|
|
25
|
+
description,
|
|
26
|
+
showHome = true,
|
|
27
|
+
showBack = true,
|
|
28
|
+
homeUrl = '/',
|
|
29
|
+
onhome,
|
|
30
|
+
onback,
|
|
31
|
+
icon,
|
|
32
|
+
actions,
|
|
33
|
+
class: className = '',
|
|
34
|
+
}: {
|
|
35
|
+
/** HTTP 状态码或预设标识;未命中预设时回退到最接近的预设 */
|
|
36
|
+
status?: number | string | ErrorPreset
|
|
37
|
+
/** 自定义大号展示码(覆盖预设) */
|
|
38
|
+
code?: string
|
|
39
|
+
/** 自定义标题(覆盖预设文案) */
|
|
40
|
+
title?: string
|
|
41
|
+
/** 自定义描述(覆盖预设文案) */
|
|
42
|
+
description?: string
|
|
43
|
+
/** 是否显示「返回首页」按钮 */
|
|
44
|
+
showHome?: boolean
|
|
45
|
+
/** 是否显示「返回上一页」按钮 */
|
|
46
|
+
showBack?: boolean
|
|
47
|
+
/** 首页地址(未提供 onhome 时作为链接跳转) */
|
|
48
|
+
homeUrl?: string
|
|
49
|
+
/** 自定义「返回首页」处理(优先于 homeUrl 链接) */
|
|
50
|
+
onhome?: () => void
|
|
51
|
+
/** 自定义「返回上一页」处理(默认 history.back()) */
|
|
52
|
+
onback?: () => void
|
|
53
|
+
/** 自定义图标插槽 */
|
|
54
|
+
icon?: Snippet
|
|
55
|
+
/** 自定义操作区插槽(提供时覆盖默认按钮) */
|
|
56
|
+
actions?: Snippet
|
|
57
|
+
class?: string
|
|
58
|
+
} = $props()
|
|
59
|
+
|
|
60
|
+
const preset = $derived(ERROR_PRESETS[resolveErrorPreset(status)])
|
|
61
|
+
const displayCode = $derived(code ?? preset.code)
|
|
62
|
+
const displayTitle = $derived(title ?? uiM(preset.titleKey))
|
|
63
|
+
const displayDesc = $derived(description ?? uiM(preset.descKey))
|
|
64
|
+
|
|
65
|
+
function handleBack() {
|
|
66
|
+
if (onback) {
|
|
67
|
+
onback()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (typeof history !== 'undefined') {
|
|
71
|
+
history.back()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function handleHome() {
|
|
76
|
+
onhome?.()
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<div class={cn('flex min-h-[60vh] w-full flex-col items-center justify-center px-6 py-12 text-center', className)}>
|
|
81
|
+
<!-- 图标 -->
|
|
82
|
+
<div class='mb-6 flex size-16 items-center justify-center rounded-2xl bg-base-content/5 text-base-content/40'>
|
|
83
|
+
{#if icon}
|
|
84
|
+
{@render icon()}
|
|
85
|
+
{:else}
|
|
86
|
+
<span class='{preset.icon} size-8'></span>
|
|
87
|
+
{/if}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<!-- 大号状态码 -->
|
|
91
|
+
<p class='text-6xl font-bold tracking-tight text-base-content/85 sm:text-7xl'>{displayCode}</p>
|
|
92
|
+
|
|
93
|
+
<!-- 标题 -->
|
|
94
|
+
<h1 class='mt-4 text-xl font-semibold text-base-content sm:text-2xl'>{displayTitle}</h1>
|
|
95
|
+
|
|
96
|
+
<!-- 描述 -->
|
|
97
|
+
<p class='mt-2 max-w-md text-sm text-base-content/55'>{displayDesc}</p>
|
|
98
|
+
|
|
99
|
+
<!-- 操作区 -->
|
|
100
|
+
<div class='mt-8 flex flex-wrap items-center justify-center gap-3'>
|
|
101
|
+
{#if actions}
|
|
102
|
+
{@render actions()}
|
|
103
|
+
{:else}
|
|
104
|
+
{#if showBack}
|
|
105
|
+
<Button variant='outline' size='sm' onclick={handleBack}>
|
|
106
|
+
<span class='icon-[tabler--arrow-left] mr-1.5 size-4'></span>
|
|
107
|
+
{uiM('error_go_back')}
|
|
108
|
+
</Button>
|
|
109
|
+
{/if}
|
|
110
|
+
{#if showHome}
|
|
111
|
+
{#if onhome}
|
|
112
|
+
<Button variant='primary' size='sm' onclick={handleHome}>
|
|
113
|
+
<span class='icon-[tabler--home] mr-1.5 size-4'></span>
|
|
114
|
+
{uiM('error_back_home')}
|
|
115
|
+
</Button>
|
|
116
|
+
{:else}
|
|
117
|
+
<a href={homeUrl} class='btn btn-primary btn-sm'>
|
|
118
|
+
<span class='icon-[tabler--home] mr-1.5 size-4'></span>
|
|
119
|
+
{uiM('error_back_home')}
|
|
120
|
+
</a>
|
|
121
|
+
{/if}
|
|
122
|
+
{/if}
|
|
123
|
+
{/if}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { ErrorPreset } from './error-presets.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
/** HTTP 状态码或预设标识;未命中预设时回退到最接近的预设 */
|
|
5
|
+
status?: number | string | ErrorPreset;
|
|
6
|
+
/** 自定义大号展示码(覆盖预设) */
|
|
7
|
+
code?: string;
|
|
8
|
+
/** 自定义标题(覆盖预设文案) */
|
|
9
|
+
title?: string;
|
|
10
|
+
/** 自定义描述(覆盖预设文案) */
|
|
11
|
+
description?: string;
|
|
12
|
+
/** 是否显示「返回首页」按钮 */
|
|
13
|
+
showHome?: boolean;
|
|
14
|
+
/** 是否显示「返回上一页」按钮 */
|
|
15
|
+
showBack?: boolean;
|
|
16
|
+
/** 首页地址(未提供 onhome 时作为链接跳转) */
|
|
17
|
+
homeUrl?: string;
|
|
18
|
+
/** 自定义「返回首页」处理(优先于 homeUrl 链接) */
|
|
19
|
+
onhome?: () => void;
|
|
20
|
+
/** 自定义「返回上一页」处理(默认 history.back()) */
|
|
21
|
+
onback?: () => void;
|
|
22
|
+
/** 自定义图标插槽 */
|
|
23
|
+
icon?: Snippet;
|
|
24
|
+
/** 自定义操作区插槽(提供时覆盖默认按钮) */
|
|
25
|
+
actions?: Snippet;
|
|
26
|
+
class?: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* ErrorPage
|
|
30
|
+
* 通用错误页场景组件
|
|
31
|
+
*
|
|
32
|
+
* 展示大号状态码 + 标题 + 描述 + 操作按钮,内置 401/403/404/500/503 预设。
|
|
33
|
+
* 借鉴 shadcn-admin 错误页布局,复用现有主题 token。
|
|
34
|
+
*
|
|
35
|
+
* 使用 Svelte 5 Runes ($props, $derived)
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* <ErrorPage status={403} homeUrl="/admin" onback={() => history.back()} />
|
|
39
|
+
*/
|
|
40
|
+
declare const ErrorPage: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
41
|
+
type ErrorPage = ReturnType<typeof ErrorPage>;
|
|
42
|
+
export default ErrorPage;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @h-ai/ui — 错误页预设
|
|
3
|
+
*
|
|
4
|
+
* 为 ErrorPage 提供常见 HTTP 错误的内置文案 key 与图标。
|
|
5
|
+
* @module error-presets
|
|
6
|
+
*/
|
|
7
|
+
import type { UIMessageKey } from '../../../messages.js';
|
|
8
|
+
/** 内置错误预设标识 */
|
|
9
|
+
export type ErrorPreset = '401' | '403' | '404' | '500' | '503';
|
|
10
|
+
/** 单个错误预设的展示信息 */
|
|
11
|
+
export interface ErrorPresetInfo {
|
|
12
|
+
/** HTTP 状态码(用于大号展示) */
|
|
13
|
+
code: string;
|
|
14
|
+
/** 标题文案 key */
|
|
15
|
+
titleKey: UIMessageKey;
|
|
16
|
+
/** 描述文案 key */
|
|
17
|
+
descKey: UIMessageKey;
|
|
18
|
+
/** Tabler 图标类名 */
|
|
19
|
+
icon: string;
|
|
20
|
+
}
|
|
21
|
+
/** 错误预设映射表 */
|
|
22
|
+
export declare const ERROR_PRESETS: Record<ErrorPreset, ErrorPresetInfo>;
|
|
23
|
+
/** 根据 HTTP 状态码解析到最接近的预设 */
|
|
24
|
+
export declare function resolveErrorPreset(status: number | string | undefined): ErrorPreset;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @h-ai/ui — 错误页预设
|
|
3
|
+
*
|
|
4
|
+
* 为 ErrorPage 提供常见 HTTP 错误的内置文案 key 与图标。
|
|
5
|
+
* @module error-presets
|
|
6
|
+
*/
|
|
7
|
+
/** 错误预设映射表 */
|
|
8
|
+
export const ERROR_PRESETS = {
|
|
9
|
+
401: { code: '401', titleKey: 'error_401_title', descKey: 'error_401_desc', icon: 'icon-[tabler--lock]' },
|
|
10
|
+
403: { code: '403', titleKey: 'error_403_title', descKey: 'error_403_desc', icon: 'icon-[tabler--shield-lock]' },
|
|
11
|
+
404: { code: '404', titleKey: 'error_404_title', descKey: 'error_404_desc', icon: 'icon-[tabler--map-search]' },
|
|
12
|
+
500: { code: '500', titleKey: 'error_500_title', descKey: 'error_500_desc', icon: 'icon-[tabler--server-off]' },
|
|
13
|
+
503: { code: '503', titleKey: 'error_503_title', descKey: 'error_503_desc', icon: 'icon-[tabler--tool]' },
|
|
14
|
+
};
|
|
15
|
+
/** 根据 HTTP 状态码解析到最接近的预设 */
|
|
16
|
+
export function resolveErrorPreset(status) {
|
|
17
|
+
const code = String(status ?? '');
|
|
18
|
+
if (code in ERROR_PRESETS) {
|
|
19
|
+
return code;
|
|
20
|
+
}
|
|
21
|
+
const num = Number(code);
|
|
22
|
+
if (num === 401)
|
|
23
|
+
return '401';
|
|
24
|
+
if (num === 403)
|
|
25
|
+
return '403';
|
|
26
|
+
if (num === 503)
|
|
27
|
+
return '503';
|
|
28
|
+
if (num >= 500)
|
|
29
|
+
return '500';
|
|
30
|
+
return '404';
|
|
31
|
+
}
|