@h-ai/ui 0.1.0-alpha.25 → 0.1.0-alpha.27
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 +28 -8
- package/auto-import.d.ts +5 -0
- package/auto-import.js +4 -0
- package/dist/components/compounds/Calendar.svelte +1 -1
- package/dist/components/compounds/DataTable.svelte +81 -4
- package/dist/components/compounds/DataTable.svelte.d.ts +7 -0
- package/dist/components/compounds/DatePicker.svelte +1 -1
- package/dist/components/compounds/Modal.svelte +44 -6
- package/dist/components/compounds/Pagination.svelte +72 -119
- package/dist/components/compounds/ToastContainer.svelte +159 -20
- 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 +9 -3
- package/vite.d.ts +13 -0
- package/vite.js +95 -0
|
@@ -4,10 +4,17 @@
|
|
|
4
4
|
=============================================================================
|
|
5
5
|
Toast 容器组件,显示所有 Toast 通知
|
|
6
6
|
|
|
7
|
-
使用 Svelte 5 Runes ($derived)
|
|
7
|
+
使用 Svelte 5 Runes ($derived / $effect)
|
|
8
|
+
|
|
9
|
+
叠加策略(关键):原生 <dialog>.showModal() 处于浏览器 top layer,且在 Chromium 中
|
|
10
|
+
始终渲染在 Popover 之上。为保证 Toast 永远可见、不被弹窗遮挡:
|
|
11
|
+
- 当存在已打开的 modal dialog 时,将 Toast 层寄宿到该 dialog 内部(同处 top layer,
|
|
12
|
+
作为最后子节点渲染在其内容之上);
|
|
13
|
+
- 否则寄宿到 <body> 并以 Popover 进入 top layer,叠加在普通内容之上。
|
|
8
14
|
=============================================================================
|
|
9
15
|
-->
|
|
10
16
|
<script lang='ts'>
|
|
17
|
+
import { onMount } from 'svelte'
|
|
11
18
|
import { uiM } from '../../messages.js'
|
|
12
19
|
import { toast } from '../../toast.svelte.js'
|
|
13
20
|
import { cn, getAlertVariantClass } from '../../utils.js'
|
|
@@ -22,6 +29,11 @@
|
|
|
22
29
|
'bottom-center': 'toast-bottom toast-center',
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
// 容器根元素引用。
|
|
33
|
+
let layerElement: HTMLDivElement | undefined = $state()
|
|
34
|
+
|
|
35
|
+
const hasItems = $derived(toast.items.length > 0)
|
|
36
|
+
|
|
25
37
|
// 按位置分组
|
|
26
38
|
const groupedItems = $derived(() => {
|
|
27
39
|
const groups: Record<string, typeof toast.items> = {}
|
|
@@ -36,24 +48,151 @@
|
|
|
36
48
|
|
|
37
49
|
return groups
|
|
38
50
|
})
|
|
51
|
+
|
|
52
|
+
/** 找到最顶层处于打开状态的原生 modal dialog(位于浏览器 top layer)。 */
|
|
53
|
+
function findTopModalDialog(): HTMLDialogElement | null {
|
|
54
|
+
if (typeof document === 'undefined') {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog')
|
|
59
|
+
let top: HTMLDialogElement | null = null
|
|
60
|
+
for (const dialog of dialogs) {
|
|
61
|
+
if (dialog.open && typeof dialog.matches === 'function' && dialog.matches(':modal')) {
|
|
62
|
+
top = dialog
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return top
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function safeShowPopover(el: HTMLElement) {
|
|
69
|
+
try {
|
|
70
|
+
if (typeof el.showPopover === 'function' && el.isConnected && !el.matches(':popover-open')) {
|
|
71
|
+
el.showPopover()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// 元素未连接或重复调用时忽略。
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function safeHidePopover(el: HTMLElement) {
|
|
80
|
+
try {
|
|
81
|
+
if (typeof el.hidePopover === 'function' && el.matches(':popover-open')) {
|
|
82
|
+
el.hidePopover()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// 同上,忽略异常。
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 将 Toast 层寄宿到正确的容器:
|
|
92
|
+
* - 有打开的 modal dialog 时,去掉 popover 属性并寄宿到该 dialog 内部,渲染在其内容之上;
|
|
93
|
+
* - 否则寄宿到 <body>,恢复 popover 属性并按需进入 / 退出 top layer。
|
|
94
|
+
*/
|
|
95
|
+
function relocate() {
|
|
96
|
+
const el = layerElement
|
|
97
|
+
if (!el) {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const modal = hasItems ? findTopModalDialog() : null
|
|
102
|
+
|
|
103
|
+
if (modal) {
|
|
104
|
+
// 寄宿到 dialog:popover 元素未显示时会 display:none,故先移除 popover 属性。
|
|
105
|
+
safeHidePopover(el)
|
|
106
|
+
if (el.hasAttribute('popover')) {
|
|
107
|
+
el.removeAttribute('popover')
|
|
108
|
+
}
|
|
109
|
+
if (el.parentElement !== modal) {
|
|
110
|
+
modal.appendChild(el)
|
|
111
|
+
}
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (el.parentElement !== document.body) {
|
|
116
|
+
document.body.appendChild(el)
|
|
117
|
+
}
|
|
118
|
+
if (!el.hasAttribute('popover')) {
|
|
119
|
+
el.setAttribute('popover', 'manual')
|
|
120
|
+
}
|
|
121
|
+
if (hasItems) {
|
|
122
|
+
safeShowPopover(el)
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
safeHidePopover(el)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
onMount(() => {
|
|
130
|
+
relocate()
|
|
131
|
+
|
|
132
|
+
// dialog 通过 showModal()/close() 切换 `open` 属性;据此重新寄宿 Toast 层。
|
|
133
|
+
const observer = new MutationObserver(() => relocate())
|
|
134
|
+
observer.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['open'] })
|
|
135
|
+
|
|
136
|
+
return () => {
|
|
137
|
+
observer.disconnect()
|
|
138
|
+
const el = layerElement
|
|
139
|
+
if (el) {
|
|
140
|
+
safeHidePopover(el)
|
|
141
|
+
el.remove()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Toast 数量变化时重新寄宿(出现 / 消失、以及在 dialog 与 body 之间切换)。
|
|
147
|
+
$effect(() => {
|
|
148
|
+
void hasItems
|
|
149
|
+
relocate()
|
|
150
|
+
})
|
|
39
151
|
</script>
|
|
40
152
|
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
{
|
|
44
|
-
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
153
|
+
<div bind:this={layerElement} popover='manual' class='hai-toast-layer'>
|
|
154
|
+
{#each Object.entries(groupedItems()) as [position, items] (position)}
|
|
155
|
+
<div class={cn('toast', positionMap[position as keyof typeof positionMap])}>
|
|
156
|
+
{#each items as item (item.id)}
|
|
157
|
+
<div class={cn('alert', getAlertVariantClass(item.variant ?? 'info'))}>
|
|
158
|
+
<span>{item.message}</span>
|
|
159
|
+
{#if item.dismissible}
|
|
160
|
+
<IconButton
|
|
161
|
+
variant='ghost'
|
|
162
|
+
size='sm'
|
|
163
|
+
onclick={() => toast.remove(item.id)}
|
|
164
|
+
ariaLabel={uiM('toast_dismiss')}
|
|
165
|
+
>
|
|
166
|
+
✕
|
|
167
|
+
</IconButton>
|
|
168
|
+
{/if}
|
|
169
|
+
</div>
|
|
170
|
+
{/each}
|
|
171
|
+
</div>
|
|
172
|
+
{/each}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<style>
|
|
176
|
+
/* 容器铺满视口但不拦截交互;仅 toast 本体接收指针事件。 */
|
|
177
|
+
.hai-toast-layer {
|
|
178
|
+
position: fixed;
|
|
179
|
+
inset: 0;
|
|
180
|
+
width: 100%;
|
|
181
|
+
height: 100%;
|
|
182
|
+
max-width: none;
|
|
183
|
+
max-height: none;
|
|
184
|
+
margin: 0;
|
|
185
|
+
padding: 0;
|
|
186
|
+
border: 0;
|
|
187
|
+
background: transparent;
|
|
188
|
+
overflow: visible;
|
|
189
|
+
pointer-events: none;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* 未支持 Popover API 的浏览器忽略 popover 属性,容器常驻文档流;
|
|
193
|
+
此时无 toast 项则内部不渲染任何内容,容器透明且不拦截交互。 */
|
|
194
|
+
|
|
195
|
+
.hai-toast-layer :global(.toast) {
|
|
196
|
+
pointer-events: auto;
|
|
197
|
+
}
|
|
198
|
+
</style>
|
|
@@ -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>
|