@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.
- package/README.md +19 -7
- 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 +44 -6
- package/dist/components/compounds/Pagination.svelte +72 -119
- 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/types.d.ts +6 -0
- 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='
|
|
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}
|