@h-ai/ui 0.1.0-alpha.49 → 0.1.0-alpha.50
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 -2
- package/dist/components/compounds/Tabs.svelte +20 -1
- package/dist/components/scenes/crud/CrudPage.svelte +166 -89
- package/dist/components/scenes/crud/CrudPage.svelte.d.ts +13 -0
- package/dist/components/scenes/crud/crud-controller.svelte.d.ts +213 -0
- package/dist/components/scenes/crud/crud-controller.svelte.js +265 -0
- package/dist/components/scenes/crud/index.d.ts +5 -1
- package/dist/components/scenes/crud/index.js +5 -1
- package/dist/messages/en-US.json +3 -1
- package/dist/messages/zh-CN.json +3 -1
- package/dist/toast.svelte.d.ts +46 -0
- package/dist/toast.svelte.js +61 -0
- package/dist/types.d.ts +18 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -211,7 +211,7 @@ components/
|
|
|
211
211
|
|
|
212
212
|
| 组件 | 描述 | 主要属性 |
|
|
213
213
|
| ------------ | -------- | -------------------------------------------- |
|
|
214
|
-
| `Tabs` | 标签页 | `items`, `active`, `type`, `size`
|
|
214
|
+
| `Tabs` | 标签页 | `items`, `active`, `type`, `size`, `lazy`, `onactivate` |
|
|
215
215
|
| `Pagination` | 分页(统一 table 风格) | `page`, `total`, `pageSize`, `showTotal`, `showJumper`, `showSizeChanger`, `showPageInfo`, `showFirstLast` |
|
|
216
216
|
| `Breadcrumb` | 面包屑 | `items`, `separator` |
|
|
217
217
|
| `Steps` | 步骤条 | `items`, `current`, `direction`, `clickable` |
|
|
@@ -334,7 +334,7 @@ components/
|
|
|
334
334
|
|
|
335
335
|
| 组件 | 描述 | 主要属性 / 能力 |
|
|
336
336
|
| -------------------- | ------------ | ---------------- |
|
|
337
|
-
| `CrudPage` | CRUD 主页面 | `crud`, `data`, `permissions`, `form`, `pagination`, `density`, `detailColumns`, `showHeader`, `toolbarStyle`, `toolbarLeading`, `toolbarActions`, `card`, `tableCell`, `onrowclick`, `sortableColumns`, `nav` |
|
|
337
|
+
| `CrudPage` | CRUD 主页面 | `crud`, `data`, `permissions`, `form`, `pagination`, `density`, `detailColumns`, `showHeader`, `toolbarStyle`, `toolbarLeading`, `toolbarActions`, `card`, `tableCell`, `onrowclick`, `sortableColumns`, `nav`, `loading`, `loaded`, `error`, `onRetry`, `keepPreviousData` |
|
|
338
338
|
| `CrudFilterBar` | 过滤工具栏(搜索 + 多类型过滤 + 重置) | `filterFields`, `searchValue`, `onsearch`, `onfilterchange`, `onreset` |
|
|
339
339
|
| `CrudDetailPanel` | 详情面板(抽屉/弹窗) | `open`, `item`, `fields`, `variant`, `drawerWidth`, `modalSize` |
|
|
340
340
|
| `CrudEditPanel` | 编辑面板(抽屉/弹窗) | `open`, `fields`, `variant`, `drawerWidth`, `modalSize`, `onsubmit` |
|
|
@@ -361,6 +361,21 @@ components/
|
|
|
361
361
|
> `pagination` 配置分页栏(始终显示):`showSizeChanger`、`pageSizeOptions`、`showJumper`、`showTotal` 默认开启;紧凑视图可用 `showPageInfo` 关闭页码文案、`showFirstLast` 关闭首页 / 末页按钮。
|
|
362
362
|
>
|
|
363
363
|
> `density` 配置列表密度:`'normal'`(默认)或 `'compact'`(紧凑)。它只影响列表行、行内操作、分页与创建/编辑/详情表单元素,不影响页头与筛选栏。
|
|
364
|
+
>
|
|
365
|
+
> `CrudPage` 受控加载态:`loading` / `loaded` / `error` / `onRetry` / `keepPreviousData`。仅当 `loaded && total === 0` 时才展示空状态(避免首次请求未回时误显“暂无数据”);首次查询展示“查询中”;失败且无可用数据时展示错误与重试(`onRetry`);`keepPreviousData`(默认 true)在刷新/失败时保留旧数据。
|
|
366
|
+
>
|
|
367
|
+
> 纯客户端(无 SvelteKit SSR)可用框架级 `defineCrud` + `createCrudController` 托管 `data` 与 `nav`,自带 latest-wins(seq + AbortSignal)、失败保留旧数据、惰性加载与并发去重:
|
|
368
|
+
>
|
|
369
|
+
> ```svelte
|
|
370
|
+
> <script>
|
|
371
|
+
> import { CrudPage, createCrudController, defineCrud } from '@h-ai/ui'
|
|
372
|
+
> const crud = defineCrud({ name: 'user', label: '用户', fields: [/* ... */], api: { list: params => listUsers(params) } })
|
|
373
|
+
> const c = createCrudController(crud) // 惰性页签传 { autoLoad: false },首次激活时 c.load()
|
|
374
|
+
> </script>
|
|
375
|
+
> <CrudPage {crud} data={c.data} nav={c.nav} loading={c.loading} loaded={c.loaded} error={c.error} onRetry={c.reload} />
|
|
376
|
+
> ```
|
|
377
|
+
>
|
|
378
|
+
> `Tabs` 支持惰性页签:`lazy` 开启后未激活过的页签内容不渲染(`children` 提供 `isActivated(key)` 判断),`onactivate(key)` 在页签首次激活时触发一次,适合配合 `createCrudController({ autoLoad: false })` 在首次可见时才加载。
|
|
364
379
|
|
|
365
380
|
## 使用示例
|
|
366
381
|
|
|
@@ -382,6 +397,8 @@ components/
|
|
|
382
397
|
<ToastContainer />
|
|
383
398
|
```
|
|
384
399
|
|
|
400
|
+
> 已内置 `notifySuccess` / `notifyError` / `notifyInfo` / `notifyWarning` 封装,业务应用无需自建 toast 工具文件:`notifyError(err, fallback?)` 接受 `Error` 或字符串并内置兑底文案,默认成功 3s / 失败 5s。
|
|
401
|
+
|
|
385
402
|
### Message 消息提示
|
|
386
403
|
|
|
387
404
|
```svelte
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
-->
|
|
5
5
|
<script lang='ts'>
|
|
6
6
|
import type { DataAttributes, TabsProps } from '../../types.js'
|
|
7
|
+
import { SvelteSet } from 'svelte/reactivity'
|
|
7
8
|
import { cn, getDataAttributes } from '../../utils.js'
|
|
8
9
|
import BareButton from '../primitives/BareButton.svelte'
|
|
9
10
|
|
|
@@ -14,11 +15,16 @@
|
|
|
14
15
|
type = 'line',
|
|
15
16
|
class: className = '',
|
|
16
17
|
onchange,
|
|
18
|
+
onactivate,
|
|
19
|
+
lazy = false,
|
|
17
20
|
children,
|
|
18
21
|
...restProps
|
|
19
22
|
}: TabsProps & DataAttributes = $props()
|
|
20
23
|
|
|
21
24
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
25
|
+
// 已激活过的页签集合,用于惰性挂载(不预加载不可见页签)。
|
|
26
|
+
const activatedKeys = new SvelteSet<string>()
|
|
27
|
+
|
|
22
28
|
// 默认选中第一个
|
|
23
29
|
$effect(() => {
|
|
24
30
|
if (!active && items.length > 0) {
|
|
@@ -26,6 +32,19 @@
|
|
|
26
32
|
}
|
|
27
33
|
})
|
|
28
34
|
|
|
35
|
+
// 记录激活过的页签,并在首次激活时回调一次,供惰性加载对应内容。
|
|
36
|
+
$effect(() => {
|
|
37
|
+
if (active && !activatedKeys.has(active)) {
|
|
38
|
+
activatedKeys.add(active)
|
|
39
|
+
onactivate?.(active)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/** 指定页签是否已激活过;非惰性模式下恒为 true(内容全部预挂载)。 */
|
|
44
|
+
function isActivated(key: string): boolean {
|
|
45
|
+
return !lazy || activatedKeys.has(key)
|
|
46
|
+
}
|
|
47
|
+
|
|
29
48
|
const typeMap = {
|
|
30
49
|
line: 'tabs-border',
|
|
31
50
|
card: 'tabs-box',
|
|
@@ -77,6 +96,6 @@
|
|
|
77
96
|
|
|
78
97
|
{#if children}
|
|
79
98
|
<div class='py-4'>
|
|
80
|
-
{@render children()}
|
|
99
|
+
{@render children({ active, isActivated })}
|
|
81
100
|
</div>
|
|
82
101
|
{/if}
|
|
@@ -159,6 +159,11 @@
|
|
|
159
159
|
onerror,
|
|
160
160
|
basePath = '',
|
|
161
161
|
nav = createBrowserNavAdapter(),
|
|
162
|
+
loading = false,
|
|
163
|
+
loaded = true,
|
|
164
|
+
error,
|
|
165
|
+
onRetry,
|
|
166
|
+
keepPreviousData = true,
|
|
162
167
|
class: className = '',
|
|
163
168
|
...restProps
|
|
164
169
|
}: {
|
|
@@ -219,12 +224,42 @@
|
|
|
219
224
|
* 以获得客户端跳转 + invalidateAll 体验。
|
|
220
225
|
*/
|
|
221
226
|
nav?: NavAdapter
|
|
227
|
+
/** 列表是否正在加载;由外部控制器(`createCrudController`)提供,用于展示查询中占位。 */
|
|
228
|
+
loading?: boolean
|
|
229
|
+
/**
|
|
230
|
+
* 是否至少成功加载过一次。仅当 `loaded && total === 0` 时展示空状态,
|
|
231
|
+
* 避免首次请求未回时误显示“暂无数据”。默认 true 以兼容 SvelteKit SSR(数据随页面就绪)。
|
|
232
|
+
*/
|
|
233
|
+
loaded?: boolean
|
|
234
|
+
/** 最新一轮加载错误;无可用数据时展示错误信息与重试按钮。 */
|
|
235
|
+
error?: Error | string
|
|
236
|
+
/** 点击“重试”回调;通常传入控制器的 `reload`。 */
|
|
237
|
+
onRetry?: () => void
|
|
238
|
+
/** 失败或刷新时是否保留旧数据(默认 true);为 false 时加载中展示查询中占位而非旧列表。 */
|
|
239
|
+
keepPreviousData?: boolean
|
|
222
240
|
/** 根节点自定义 class。 */
|
|
223
241
|
class?: string
|
|
224
242
|
} & DataAttributes = $props()
|
|
225
243
|
|
|
226
244
|
/** 透传到根节点的 data-* 属性。 */
|
|
227
245
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
246
|
+
|
|
247
|
+
// ─── 加载 / 空 / 错误展示态 ───
|
|
248
|
+
|
|
249
|
+
/** 归一化错误文案;error 允许是 Error 或字符串。 */
|
|
250
|
+
const errorMessage = $derived(error ? (typeof error === 'string' ? error : error.message) : '')
|
|
251
|
+
/** 是否已有可展示的数据行。 */
|
|
252
|
+
const hasItems = $derived(data.items.length > 0)
|
|
253
|
+
/** 加载失败且无可保留数据:整块替换为错误信息 + 重试。 */
|
|
254
|
+
const showErrorState = $derived(!!error && (!hasItems || !keepPreviousData))
|
|
255
|
+
/** 首次查询(或不保留旧数据时)尚无数据:展示查询中占位,避免误显示空状态。 */
|
|
256
|
+
const showQuerying = $derived(loading && !showErrorState && (!hasItems || !keepPreviousData))
|
|
257
|
+
/** 常规列表区块(含空状态);仅此区块内才可能出现“暂无数据”。 */
|
|
258
|
+
const showListRegion = $derived(!showErrorState && !showQuerying)
|
|
259
|
+
/** 列表可见但仍在刷新(保留旧数据):顶部细进度条提示。 */
|
|
260
|
+
const showRefreshing = $derived(showListRegion && loading && hasItems)
|
|
261
|
+
/** 保留旧数据时的错误细条 + 重试,不打断浏览已有列表。 */
|
|
262
|
+
const showErrorBanner = $derived(showListRegion && !!error && hasItems)
|
|
228
263
|
// ─── 状态 ───
|
|
229
264
|
|
|
230
265
|
/** 表格行的主键字段。 */
|
|
@@ -697,101 +732,143 @@
|
|
|
697
732
|
|
|
698
733
|
<!-- 数据列表 -->
|
|
699
734
|
<Card padding='none' class={listCardClass}>
|
|
700
|
-
{#if
|
|
701
|
-
<!--
|
|
702
|
-
<div class='min-h-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
735
|
+
{#if showQuerying}
|
|
736
|
+
<!-- 首次查询:展示查询中占位,避免请求未回时误显示“暂无数据” -->
|
|
737
|
+
<div class='flex min-h-40 flex-1 flex-col items-center justify-center gap-2 px-4 py-10 text-sm text-base-content/55'>
|
|
738
|
+
<span class='loading loading-spinner loading-md text-base-content/40'></span>
|
|
739
|
+
<span>{uiM('crud_loading')}</span>
|
|
740
|
+
</div>
|
|
741
|
+
{:else if showErrorState}
|
|
742
|
+
<!-- 加载失败且无可保留数据:错误信息 + 重试 -->
|
|
743
|
+
<div class='flex min-h-40 flex-1 flex-col items-center justify-center gap-3 px-4 py-10 text-sm'>
|
|
744
|
+
<span class='icon-[tabler--alert-triangle] size-8 text-error/70'></span>
|
|
745
|
+
<p class='max-w-md text-center text-base-content/70'>{errorMessage || uiM('crud_load_failed')}</p>
|
|
746
|
+
{#if onRetry}
|
|
747
|
+
<Button size={actionButtonSize} onclick={() => onRetry?.()}>
|
|
748
|
+
<span class='icon-[tabler--refresh] mr-1 size-4'></span>
|
|
749
|
+
{uiM('crud_retry')}
|
|
750
|
+
</Button>
|
|
713
751
|
{/if}
|
|
714
752
|
</div>
|
|
715
753
|
{:else}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
sortDir={sortBy ? sortDirection : null}
|
|
724
|
-
onsort={handleSort}
|
|
725
|
-
class='min-h-0 min-w-0 flex-1 overflow-auto'
|
|
726
|
-
{density}
|
|
727
|
-
cell={tableCell}
|
|
728
|
-
{onrowclick}
|
|
729
|
-
>
|
|
730
|
-
{#snippet actions(item)}
|
|
731
|
-
{#if rowClickDetail}
|
|
732
|
-
<IconButton
|
|
733
|
-
variant='ghost'
|
|
734
|
-
size={actionButtonSize}
|
|
735
|
-
ariaLabel={uiM('crud_detail')}
|
|
736
|
-
onclick={(event) => {
|
|
737
|
-
event.stopPropagation()
|
|
738
|
-
openDetail(item)
|
|
739
|
-
}}
|
|
740
|
-
>
|
|
741
|
-
<span class='icon-[tabler--eye] {actionIconClass}'></span>
|
|
742
|
-
</IconButton>
|
|
743
|
-
{/if}
|
|
744
|
-
{#if canUpdate}
|
|
745
|
-
<IconButton
|
|
746
|
-
variant='ghost'
|
|
747
|
-
size={actionButtonSize}
|
|
748
|
-
ariaLabel={uiM('crud_edit')}
|
|
749
|
-
onclick={(event) => {
|
|
750
|
-
event.stopPropagation()
|
|
751
|
-
openEdit(item)
|
|
752
|
-
}}
|
|
753
|
-
>
|
|
754
|
-
<span class='icon-[tabler--edit] {actionIconClass}'></span>
|
|
755
|
-
</IconButton>
|
|
756
|
-
{/if}
|
|
757
|
-
{#if canDelete}
|
|
758
|
-
<IconButton
|
|
759
|
-
variant='ghost'
|
|
760
|
-
size={actionButtonSize}
|
|
761
|
-
ariaLabel={uiM('crud_delete')}
|
|
762
|
-
onclick={(event) => {
|
|
763
|
-
event.stopPropagation()
|
|
764
|
-
requestDelete(item)
|
|
765
|
-
}}
|
|
766
|
-
class='hover:text-error'
|
|
767
|
-
>
|
|
768
|
-
<span class='icon-[tabler--trash] {actionIconClass}'></span>
|
|
769
|
-
</IconButton>
|
|
754
|
+
{#if showErrorBanner}
|
|
755
|
+
<!-- 保留旧数据时的错误细条:不打断浏览,仍提供重试 -->
|
|
756
|
+
<div role='alert' class='flex items-center gap-2 border-b border-error/20 bg-error/10 px-3 py-2 text-xs text-error'>
|
|
757
|
+
<span class='icon-[tabler--alert-triangle] size-4 shrink-0'></span>
|
|
758
|
+
<span class='min-w-0 flex-1 truncate'>{errorMessage || uiM('crud_load_failed')}</span>
|
|
759
|
+
{#if onRetry}
|
|
760
|
+
<button type='button' class='shrink-0 font-medium underline underline-offset-2' onclick={() => onRetry?.()}>{uiM('crud_retry')}</button>
|
|
770
761
|
{/if}
|
|
771
|
-
|
|
772
|
-
|
|
762
|
+
</div>
|
|
763
|
+
{/if}
|
|
764
|
+
{#if showRefreshing}
|
|
765
|
+
<!-- 保留旧数据的刷新态:顶部细进度提示 -->
|
|
766
|
+
<div class='h-0.5 w-full overflow-hidden'>
|
|
767
|
+
<div class='h-full w-1/3 animate-pulse rounded-full bg-primary/60'></div>
|
|
768
|
+
</div>
|
|
769
|
+
{/if}
|
|
770
|
+
{#if card}
|
|
771
|
+
<!-- 卡片视图使用独立滚动容器,分页栏始终保留在容器外。 -->
|
|
772
|
+
<div class='min-h-0 flex-1 overflow-auto pt-2'>
|
|
773
|
+
{#if hasItems}
|
|
774
|
+
<div class='grid min-h-full grid-cols-[repeat(auto-fill,minmax(min(100%,18rem),1fr))] content-start gap-3'>
|
|
775
|
+
{#each data.items as item (String(item[keyField]))}
|
|
776
|
+
{@render card(item)}
|
|
777
|
+
{/each}
|
|
778
|
+
</div>
|
|
779
|
+
{:else if loaded}
|
|
780
|
+
<div class='flex min-h-full items-center justify-center px-4 text-sm text-base-content/55'>
|
|
781
|
+
{uiM('data_table_empty')}
|
|
782
|
+
</div>
|
|
783
|
+
{:else}
|
|
784
|
+
<!-- 惰性页签尚未激活:既未加载也无数据,留白不显示空状态 -->
|
|
785
|
+
<div class='min-h-full'></div>
|
|
773
786
|
{/if}
|
|
774
|
-
|
|
775
|
-
|
|
787
|
+
</div>
|
|
788
|
+
{:else if loaded || hasItems}
|
|
789
|
+
<!-- DataTable 自身同时承接横向和纵向滚动,确保 sticky 表头与数据行共享滚动上下文。 -->
|
|
790
|
+
<DataTable
|
|
791
|
+
data={data.items}
|
|
792
|
+
columns={dtColumns}
|
|
793
|
+
keyField={keyField}
|
|
794
|
+
loading={false}
|
|
795
|
+
sortKey={sortBy || undefined}
|
|
796
|
+
sortDir={sortBy ? sortDirection : null}
|
|
797
|
+
onsort={handleSort}
|
|
798
|
+
class='min-h-0 min-w-0 flex-1 overflow-auto'
|
|
799
|
+
{density}
|
|
800
|
+
cell={tableCell}
|
|
801
|
+
{onrowclick}
|
|
802
|
+
>
|
|
803
|
+
{#snippet actions(item)}
|
|
804
|
+
{#if rowClickDetail}
|
|
805
|
+
<IconButton
|
|
806
|
+
variant='ghost'
|
|
807
|
+
size={actionButtonSize}
|
|
808
|
+
ariaLabel={uiM('crud_detail')}
|
|
809
|
+
onclick={(event) => {
|
|
810
|
+
event.stopPropagation()
|
|
811
|
+
openDetail(item)
|
|
812
|
+
}}
|
|
813
|
+
>
|
|
814
|
+
<span class='icon-[tabler--eye] {actionIconClass}'></span>
|
|
815
|
+
</IconButton>
|
|
816
|
+
{/if}
|
|
817
|
+
{#if canUpdate}
|
|
818
|
+
<IconButton
|
|
819
|
+
variant='ghost'
|
|
820
|
+
size={actionButtonSize}
|
|
821
|
+
ariaLabel={uiM('crud_edit')}
|
|
822
|
+
onclick={(event) => {
|
|
823
|
+
event.stopPropagation()
|
|
824
|
+
openEdit(item)
|
|
825
|
+
}}
|
|
826
|
+
>
|
|
827
|
+
<span class='icon-[tabler--edit] {actionIconClass}'></span>
|
|
828
|
+
</IconButton>
|
|
829
|
+
{/if}
|
|
830
|
+
{#if canDelete}
|
|
831
|
+
<IconButton
|
|
832
|
+
variant='ghost'
|
|
833
|
+
size={actionButtonSize}
|
|
834
|
+
ariaLabel={uiM('crud_delete')}
|
|
835
|
+
onclick={(event) => {
|
|
836
|
+
event.stopPropagation()
|
|
837
|
+
requestDelete(item)
|
|
838
|
+
}}
|
|
839
|
+
class='hover:text-error'
|
|
840
|
+
>
|
|
841
|
+
<span class='icon-[tabler--trash] {actionIconClass}'></span>
|
|
842
|
+
</IconButton>
|
|
843
|
+
{/if}
|
|
844
|
+
{#if listItemActions}
|
|
845
|
+
{@render listItemActions(item)}
|
|
846
|
+
{/if}
|
|
847
|
+
{/snippet}
|
|
848
|
+
</DataTable>
|
|
849
|
+
{:else}
|
|
850
|
+
<!-- 惰性页签未激活:既未加载也无数据,留白不显示空状态 -->
|
|
851
|
+
<div class='min-h-40 flex-1'></div>
|
|
852
|
+
{/if}
|
|
853
|
+
|
|
854
|
+
<!-- 分页栏:始终显示,支持每页条数选择与跳页(shadcn table 风格) -->
|
|
855
|
+
<div class={paginationBarClass}>
|
|
856
|
+
<Pagination
|
|
857
|
+
page={data.page}
|
|
858
|
+
total={data.total}
|
|
859
|
+
pageSize={data.pageSize}
|
|
860
|
+
size={paginationSize}
|
|
861
|
+
showTotal={paginationShowTotal}
|
|
862
|
+
showJumper={paginationShowJumper}
|
|
863
|
+
showSizeChanger={paginationShowSizeChanger}
|
|
864
|
+
showPageInfo={paginationShowPageInfo}
|
|
865
|
+
showFirstLast={paginationShowFirstLast}
|
|
866
|
+
pageSizeOptions={paginationPageSizeOptions}
|
|
867
|
+
onchange={handlePageChange}
|
|
868
|
+
onpagesizechange={handlePageSizeChange}
|
|
869
|
+
/>
|
|
870
|
+
</div>
|
|
776
871
|
{/if}
|
|
777
|
-
|
|
778
|
-
<!-- 分页栏:始终显示,支持每页条数选择与跳页(shadcn table 风格) -->
|
|
779
|
-
<div class={paginationBarClass}>
|
|
780
|
-
<Pagination
|
|
781
|
-
page={data.page}
|
|
782
|
-
total={data.total}
|
|
783
|
-
pageSize={data.pageSize}
|
|
784
|
-
size={paginationSize}
|
|
785
|
-
showTotal={paginationShowTotal}
|
|
786
|
-
showJumper={paginationShowJumper}
|
|
787
|
-
showSizeChanger={paginationShowSizeChanger}
|
|
788
|
-
showPageInfo={paginationShowPageInfo}
|
|
789
|
-
showFirstLast={paginationShowFirstLast}
|
|
790
|
-
pageSizeOptions={paginationPageSizeOptions}
|
|
791
|
-
onchange={handlePageChange}
|
|
792
|
-
onpagesizechange={handlePageSizeChange}
|
|
793
|
-
/>
|
|
794
|
-
</div>
|
|
795
872
|
</Card>
|
|
796
873
|
</div>
|
|
797
874
|
|
|
@@ -188,6 +188,19 @@ type $$ComponentProps = {
|
|
|
188
188
|
* 以获得客户端跳转 + invalidateAll 体验。
|
|
189
189
|
*/
|
|
190
190
|
nav?: NavAdapter;
|
|
191
|
+
/** 列表是否正在加载;由外部控制器(`createCrudController`)提供,用于展示查询中占位。 */
|
|
192
|
+
loading?: boolean;
|
|
193
|
+
/**
|
|
194
|
+
* 是否至少成功加载过一次。仅当 `loaded && total === 0` 时展示空状态,
|
|
195
|
+
* 避免首次请求未回时误显示“暂无数据”。默认 true 以兼容 SvelteKit SSR(数据随页面就绪)。
|
|
196
|
+
*/
|
|
197
|
+
loaded?: boolean;
|
|
198
|
+
/** 最新一轮加载错误;无可用数据时展示错误信息与重试按钮。 */
|
|
199
|
+
error?: Error | string;
|
|
200
|
+
/** 点击“重试”回调;通常传入控制器的 `reload`。 */
|
|
201
|
+
onRetry?: () => void;
|
|
202
|
+
/** 失败或刷新时是否保留旧数据(默认 true);为 false 时加载中展示查询中占位而非旧列表。 */
|
|
203
|
+
keepPreviousData?: boolean;
|
|
191
204
|
/** 根节点自定义 class。 */
|
|
192
205
|
class?: string;
|
|
193
206
|
} & DataAttributes;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @h-ai/ui — CRUD 控制器(SPA 数据 + 导航托管)
|
|
3
|
+
*
|
|
4
|
+
* 为 `CrudPage` 提供两个框架级适配器,业务应用无需在各自项目里重复实现:
|
|
5
|
+
* 1. `defineCrud`:由声明式资源定义生成 `CrudPage` 所需的 `crud` 对象(字段筛选、列映射、默认值),
|
|
6
|
+
* 不依赖 `@h-ai/kit`,纯 SPA / Tauri / Capacitor 均可直接使用。
|
|
7
|
+
* 2. `createCrudController`:在纯客户端(无 SvelteKit SSR)下托管 `CrudPage` 的 `data` 与 `nav`,
|
|
8
|
+
* 把 `CrudPage` 通过 URL query 同步的搜索/过滤/分页参数解析回本地状态并重新拉取列表。
|
|
9
|
+
*
|
|
10
|
+
* 控制器特性:latest-wins(seq + AbortSignal 双保险)、失败保留旧数据、惰性加载、并发去重、
|
|
11
|
+
* 查询串回填地址栏(保留 pathname 与 hash,不整页跳转)。
|
|
12
|
+
*
|
|
13
|
+
* @module crud-controller
|
|
14
|
+
*/
|
|
15
|
+
import type { PaginatedResult } from '@h-ai/core';
|
|
16
|
+
import type { NavAdapter } from './nav-adapter.js';
|
|
17
|
+
/** 字段展示形式;与 `CrudEditPanel` / `CrudFilterBar` 支持的类型对齐,保留 `string` 以兼容 `@h-ai/kit`。 */
|
|
18
|
+
export type CrudFieldType = 'text' | 'string' | 'textarea' | 'select' | 'radio' | 'multi-select' | 'boolean' | 'checkbox' | 'email' | 'url' | 'tel' | 'password' | 'number' | 'date' | 'datetime' | 'date-range' | 'custom';
|
|
19
|
+
/** 可解析为字符串的文本值(支持 i18n 函数)。 */
|
|
20
|
+
export type CrudText = string | (() => string);
|
|
21
|
+
/** 选项项(select / radio / multi-select)。 */
|
|
22
|
+
export interface CrudOption {
|
|
23
|
+
label: string;
|
|
24
|
+
value: string | number | boolean;
|
|
25
|
+
}
|
|
26
|
+
/** 字段定义(与 `CrudPage` 的字段模型对齐)。 */
|
|
27
|
+
export interface CrudFieldDef {
|
|
28
|
+
/** 字段 id(对应数据对象的属性名)。 */
|
|
29
|
+
id: string;
|
|
30
|
+
/** 字段标签(支持 i18n 函数)。 */
|
|
31
|
+
label: CrudText;
|
|
32
|
+
/** 字段类型。 */
|
|
33
|
+
type: CrudFieldType;
|
|
34
|
+
/** 是否在列表展示(默认 true)。 */
|
|
35
|
+
inList?: boolean;
|
|
36
|
+
/** 是否可过滤(默认 false)。 */
|
|
37
|
+
filterable?: boolean;
|
|
38
|
+
/** 是否只读(编辑态禁用)。 */
|
|
39
|
+
readonly?: boolean;
|
|
40
|
+
/** 是否在新建表单出现(默认 true)。 */
|
|
41
|
+
inCreate?: boolean;
|
|
42
|
+
/** 是否在编辑表单出现(默认 true)。 */
|
|
43
|
+
inEdit?: boolean;
|
|
44
|
+
/** 是否在详情出现(默认 true)。 */
|
|
45
|
+
inDetail?: boolean;
|
|
46
|
+
/** 选项(select / radio / multi-select),支持工厂函数。 */
|
|
47
|
+
options?: CrudOption[] | (() => CrudOption[]);
|
|
48
|
+
/** 校验规则。 */
|
|
49
|
+
validation?: {
|
|
50
|
+
required?: boolean;
|
|
51
|
+
min?: number;
|
|
52
|
+
max?: number;
|
|
53
|
+
pattern?: string;
|
|
54
|
+
message?: string;
|
|
55
|
+
};
|
|
56
|
+
/** 列宽(CSS)。 */
|
|
57
|
+
width?: string;
|
|
58
|
+
/** 列对齐。 */
|
|
59
|
+
align?: 'left' | 'center' | 'right';
|
|
60
|
+
/** 列表单元格自定义渲染。 */
|
|
61
|
+
render?: (value: unknown, item: Record<string, unknown>) => string;
|
|
62
|
+
/** 占位文本。 */
|
|
63
|
+
placeholder?: CrudText;
|
|
64
|
+
/** 日期范围起始值 key(仅 date-range,默认 `${id}Start`)。 */
|
|
65
|
+
startKey?: string;
|
|
66
|
+
/** 日期范围结束值 key(仅 date-range,默认 `${id}End`)。 */
|
|
67
|
+
endKey?: string;
|
|
68
|
+
/** 默认值(新建时)。 */
|
|
69
|
+
defaultValue?: unknown;
|
|
70
|
+
/** 排序权重(升序)。 */
|
|
71
|
+
order?: number;
|
|
72
|
+
}
|
|
73
|
+
/** 列表请求附加项;用于 latest-wins 取消旧请求。 */
|
|
74
|
+
export interface CrudListOptions {
|
|
75
|
+
/** 取消信号;旧请求被新请求取代时触发 abort。 */
|
|
76
|
+
signal?: AbortSignal;
|
|
77
|
+
}
|
|
78
|
+
/** CRUD API 集合。列表返回标准 `PaginatedResult<T>`,避免各页面重复 page/pageSize/total 映射。 */
|
|
79
|
+
export interface CrudApi<T> {
|
|
80
|
+
/** 分页列表;第二参数携带取消信号,实现方可透传给 fetch。 */
|
|
81
|
+
list: (params: Record<string, unknown>, options?: CrudListOptions) => Promise<PaginatedResult<T>>;
|
|
82
|
+
/** 按主键获取单条。 */
|
|
83
|
+
get?: (id: string) => Promise<T | null>;
|
|
84
|
+
/** 创建一条记录。 */
|
|
85
|
+
create?: (data: Partial<T>) => Promise<T>;
|
|
86
|
+
/** 更新一条记录。 */
|
|
87
|
+
update?: (id: string, data: Partial<T>) => Promise<T>;
|
|
88
|
+
/** 删除一条记录。 */
|
|
89
|
+
remove?: (id: string) => Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
/** 资源定义。 */
|
|
92
|
+
export interface CrudResource<T> {
|
|
93
|
+
/** 资源名称(用于识别业务实体)。 */
|
|
94
|
+
name: string;
|
|
95
|
+
/** 资源标题(支持 i18n 函数)。 */
|
|
96
|
+
label: CrudText;
|
|
97
|
+
/** 主键字段(默认 `id`)。 */
|
|
98
|
+
keyField?: string;
|
|
99
|
+
/** 字段定义列表。 */
|
|
100
|
+
fields: CrudFieldDef[];
|
|
101
|
+
/** 默认分页条数(默认 10)。 */
|
|
102
|
+
defaultPageSize?: number;
|
|
103
|
+
/** 是否显示搜索框(默认 true)。 */
|
|
104
|
+
searchable?: boolean;
|
|
105
|
+
/** 搜索框占位文案。 */
|
|
106
|
+
searchPlaceholder?: CrudText;
|
|
107
|
+
/** 资源 CRUD API。 */
|
|
108
|
+
api: CrudApi<T>;
|
|
109
|
+
}
|
|
110
|
+
/** DataTable 列定义(适配 `CrudPage`)。 */
|
|
111
|
+
export interface CrudTableColumn {
|
|
112
|
+
key: string;
|
|
113
|
+
label: string;
|
|
114
|
+
width?: string;
|
|
115
|
+
align?: 'left' | 'center' | 'right';
|
|
116
|
+
render?: (item: Record<string, unknown>) => string;
|
|
117
|
+
}
|
|
118
|
+
/** `CrudPage` 所需的 crud 对象形状(`defineCrud` 的返回值)。 */
|
|
119
|
+
export interface CrudDef<T> {
|
|
120
|
+
resource: CrudResource<T>;
|
|
121
|
+
getListFields: () => CrudFieldDef[];
|
|
122
|
+
getFilterFields: () => CrudFieldDef[];
|
|
123
|
+
getCreateFields: () => CrudFieldDef[];
|
|
124
|
+
getEditFields: () => CrudFieldDef[];
|
|
125
|
+
getDetailFields: () => CrudFieldDef[];
|
|
126
|
+
toTableColumns: () => CrudTableColumn[];
|
|
127
|
+
getDefaultValues: () => Record<string, unknown>;
|
|
128
|
+
api: CrudApi<T>;
|
|
129
|
+
}
|
|
130
|
+
/** `CrudPage` 的 `data` 形状。 */
|
|
131
|
+
export interface CrudData<T> {
|
|
132
|
+
items: T[];
|
|
133
|
+
total: number;
|
|
134
|
+
page: number;
|
|
135
|
+
pageSize: number;
|
|
136
|
+
filters: Record<string, unknown>;
|
|
137
|
+
}
|
|
138
|
+
/** CRUD 控制器:托管列表数据与 SPA 导航适配器。 */
|
|
139
|
+
export interface CrudController<T> {
|
|
140
|
+
/** 传给 `<CrudPage data={...}>` 的响应式数据对象。 */
|
|
141
|
+
readonly data: CrudData<T>;
|
|
142
|
+
/** 传给 `<CrudPage nav={...}>` 的导航适配器(仅同步 query,不整页跳转)。 */
|
|
143
|
+
readonly nav: NavAdapter;
|
|
144
|
+
/** 首次或按需拉取当前参数;惰性页签用它在首次激活时启动查询。 */
|
|
145
|
+
load: () => Promise<void>;
|
|
146
|
+
/** 主动按当前参数重新拉取(写操作后或重试触发)。 */
|
|
147
|
+
reload: () => Promise<void>;
|
|
148
|
+
/** 当前是否有最新一轮列表请求尚未结束。 */
|
|
149
|
+
readonly loading: boolean;
|
|
150
|
+
/** 是否至少成功取得过一次列表响应。 */
|
|
151
|
+
readonly loaded: boolean;
|
|
152
|
+
/** 最新一轮请求错误;已有成功数据时仍保留原列表。 */
|
|
153
|
+
readonly error: Error | undefined;
|
|
154
|
+
}
|
|
155
|
+
/** CRUD 控制器创建选项。 */
|
|
156
|
+
export interface CrudControllerOptions {
|
|
157
|
+
/** 是否在创建后立即加载;不可见 / 惰性页签应设为 false(默认 true)。 */
|
|
158
|
+
autoLoad?: boolean;
|
|
159
|
+
/** 请求失败或重新拉取时是否保留已有数据(默认 true,避免闪现空列表)。 */
|
|
160
|
+
keepPreviousData?: boolean;
|
|
161
|
+
/** 是否把查询串回填浏览器地址栏(保留 pathname 与 hash,默认 true)。 */
|
|
162
|
+
syncUrl?: boolean;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* 映射分页结果的 `items`,保留 total / page / pageSize。
|
|
166
|
+
*
|
|
167
|
+
* @description 标准 `PaginatedResult<T>` 适配器:业务页面只需转换条目本身,
|
|
168
|
+
* 无需重复搬运分页元数据,配合 `CrudApi.list` 消除 page/pageSize/total 样板。
|
|
169
|
+
*
|
|
170
|
+
* @param result - 后端返回的分页结果。
|
|
171
|
+
* @param map - 单条映射函数。
|
|
172
|
+
* @returns 条目被映射、分页元数据不变的新分页结果。
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* list: async (params) => mapPaginated(await api.listUsers(params), toRow)
|
|
176
|
+
*/
|
|
177
|
+
export declare function mapPaginated<T, R>(result: PaginatedResult<T>, map: (item: T, index: number) => R): PaginatedResult<R>;
|
|
178
|
+
/**
|
|
179
|
+
* 由声明式资源定义生成 `CrudPage` 所需的 `crud` 对象。
|
|
180
|
+
*
|
|
181
|
+
* @description 仅做字段筛选、列映射与默认值收集,不依赖 `@h-ai/kit`,可在纯 SPA 直接使用。
|
|
182
|
+
*
|
|
183
|
+
* @param resource - 资源定义(字段、标签、API)。
|
|
184
|
+
* @returns `CrudPage` 可直接消费的 `crud` 对象。
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* const userCrud = defineCrud<User>({
|
|
188
|
+
* name: 'user',
|
|
189
|
+
* label: () => m.users_title(),
|
|
190
|
+
* fields: [{ id: 'name', label: () => m.user_name(), type: 'text', filterable: true }],
|
|
191
|
+
* api: { list: params => listUsers(params) },
|
|
192
|
+
* })
|
|
193
|
+
*/
|
|
194
|
+
export declare function defineCrud<T extends Record<string, unknown>>(resource: CrudResource<T>): CrudDef<T>;
|
|
195
|
+
/**
|
|
196
|
+
* 在纯客户端(无 SvelteKit SSR)下为 `CrudPage` 创建数据 + 导航控制器。
|
|
197
|
+
*
|
|
198
|
+
* @description `CrudPage` 通过 `nav.navigate(url)` 把搜索/过滤/分页写进 URL query,再调用 `nav.refresh()`
|
|
199
|
+
* 触发列表刷新。本控制器把 query 解析回参数、调用 `crud.api.list` 更新本地 `data`,并把查询串回填地址栏,
|
|
200
|
+
* 不触发整页跳转(避免刷新与登录态丢失)。连续请求只允许最新一轮更新状态(seq + AbortSignal 双保险),
|
|
201
|
+
* 失败时保留已有数据并暴露错误供页面重试。
|
|
202
|
+
*
|
|
203
|
+
* @param crud - `defineCrud` 的返回值。
|
|
204
|
+
* @param options - 加载策略;默认立即加载、失败保留旧数据、回填地址栏。
|
|
205
|
+
* @returns 含响应式 `data`、`nav` 适配器与 `load` / `reload` 的控制器。
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* const controller = createCrudController(userCrud)
|
|
209
|
+
* // <CrudPage crud={userCrud} data={controller.data} nav={controller.nav}
|
|
210
|
+
* // loading={controller.loading} loaded={controller.loaded}
|
|
211
|
+
* // error={controller.error} onRetry={controller.reload} />
|
|
212
|
+
*/
|
|
213
|
+
export declare function createCrudController<T extends Record<string, unknown>>(crud: CrudDef<T>, options?: CrudControllerOptions): CrudController<T>;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @h-ai/ui — CRUD 控制器(SPA 数据 + 导航托管)
|
|
3
|
+
*
|
|
4
|
+
* 为 `CrudPage` 提供两个框架级适配器,业务应用无需在各自项目里重复实现:
|
|
5
|
+
* 1. `defineCrud`:由声明式资源定义生成 `CrudPage` 所需的 `crud` 对象(字段筛选、列映射、默认值),
|
|
6
|
+
* 不依赖 `@h-ai/kit`,纯 SPA / Tauri / Capacitor 均可直接使用。
|
|
7
|
+
* 2. `createCrudController`:在纯客户端(无 SvelteKit SSR)下托管 `CrudPage` 的 `data` 与 `nav`,
|
|
8
|
+
* 把 `CrudPage` 通过 URL query 同步的搜索/过滤/分页参数解析回本地状态并重新拉取列表。
|
|
9
|
+
*
|
|
10
|
+
* 控制器特性:latest-wins(seq + AbortSignal 双保险)、失败保留旧数据、惰性加载、并发去重、
|
|
11
|
+
* 查询串回填地址栏(保留 pathname 与 hash,不整页跳转)。
|
|
12
|
+
*
|
|
13
|
+
* @module crud-controller
|
|
14
|
+
*/
|
|
15
|
+
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
|
16
|
+
/**
|
|
17
|
+
* 解析可能是 i18n 函数的文本。
|
|
18
|
+
*/
|
|
19
|
+
function resolveText(text) {
|
|
20
|
+
return typeof text === 'function' ? text() : text;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 映射分页结果的 `items`,保留 total / page / pageSize。
|
|
24
|
+
*
|
|
25
|
+
* @description 标准 `PaginatedResult<T>` 适配器:业务页面只需转换条目本身,
|
|
26
|
+
* 无需重复搬运分页元数据,配合 `CrudApi.list` 消除 page/pageSize/total 样板。
|
|
27
|
+
*
|
|
28
|
+
* @param result - 后端返回的分页结果。
|
|
29
|
+
* @param map - 单条映射函数。
|
|
30
|
+
* @returns 条目被映射、分页元数据不变的新分页结果。
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* list: async (params) => mapPaginated(await api.listUsers(params), toRow)
|
|
34
|
+
*/
|
|
35
|
+
export function mapPaginated(result, map) {
|
|
36
|
+
return {
|
|
37
|
+
items: result.items.map(map),
|
|
38
|
+
total: result.total,
|
|
39
|
+
page: result.page,
|
|
40
|
+
pageSize: result.pageSize,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 由声明式资源定义生成 `CrudPage` 所需的 `crud` 对象。
|
|
45
|
+
*
|
|
46
|
+
* @description 仅做字段筛选、列映射与默认值收集,不依赖 `@h-ai/kit`,可在纯 SPA 直接使用。
|
|
47
|
+
*
|
|
48
|
+
* @param resource - 资源定义(字段、标签、API)。
|
|
49
|
+
* @returns `CrudPage` 可直接消费的 `crud` 对象。
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* const userCrud = defineCrud<User>({
|
|
53
|
+
* name: 'user',
|
|
54
|
+
* label: () => m.users_title(),
|
|
55
|
+
* fields: [{ id: 'name', label: () => m.user_name(), type: 'text', filterable: true }],
|
|
56
|
+
* api: { list: params => listUsers(params) },
|
|
57
|
+
* })
|
|
58
|
+
*/
|
|
59
|
+
export function defineCrud(resource) {
|
|
60
|
+
const fields = [...resource.fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
61
|
+
return {
|
|
62
|
+
resource,
|
|
63
|
+
getListFields: () => fields.filter(f => f.inList !== false),
|
|
64
|
+
getFilterFields: () => fields.filter(f => f.filterable === true),
|
|
65
|
+
// readonly 仅表示“编辑态禁用”(由 CrudEditPanel 在 edit 模式 disable),新建仍可填写;
|
|
66
|
+
// 因此创建表单只按 inCreate 过滤,不排除 readonly 字段。
|
|
67
|
+
getCreateFields: () => fields.filter(f => f.inCreate !== false),
|
|
68
|
+
getEditFields: () => fields.filter(f => f.inEdit !== false),
|
|
69
|
+
getDetailFields: () => fields.filter(f => f.inDetail !== false),
|
|
70
|
+
toTableColumns: () => fields
|
|
71
|
+
.filter(f => f.inList !== false)
|
|
72
|
+
.map(f => ({
|
|
73
|
+
key: f.id,
|
|
74
|
+
label: resolveText(f.label),
|
|
75
|
+
width: f.width,
|
|
76
|
+
align: f.align,
|
|
77
|
+
render: f.render ? (item) => f.render(item[f.id], item) : undefined,
|
|
78
|
+
})),
|
|
79
|
+
getDefaultValues: () => Object.fromEntries(fields.filter(f => f.defaultValue !== undefined).map(f => [f.id, f.defaultValue])),
|
|
80
|
+
api: resource.api,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 在纯客户端(无 SvelteKit SSR)下为 `CrudPage` 创建数据 + 导航控制器。
|
|
85
|
+
*
|
|
86
|
+
* @description `CrudPage` 通过 `nav.navigate(url)` 把搜索/过滤/分页写进 URL query,再调用 `nav.refresh()`
|
|
87
|
+
* 触发列表刷新。本控制器把 query 解析回参数、调用 `crud.api.list` 更新本地 `data`,并把查询串回填地址栏,
|
|
88
|
+
* 不触发整页跳转(避免刷新与登录态丢失)。连续请求只允许最新一轮更新状态(seq + AbortSignal 双保险),
|
|
89
|
+
* 失败时保留已有数据并暴露错误供页面重试。
|
|
90
|
+
*
|
|
91
|
+
* @param crud - `defineCrud` 的返回值。
|
|
92
|
+
* @param options - 加载策略;默认立即加载、失败保留旧数据、回填地址栏。
|
|
93
|
+
* @returns 含响应式 `data`、`nav` 适配器与 `load` / `reload` 的控制器。
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* const controller = createCrudController(userCrud)
|
|
97
|
+
* // <CrudPage crud={userCrud} data={controller.data} nav={controller.nav}
|
|
98
|
+
* // loading={controller.loading} loaded={controller.loaded}
|
|
99
|
+
* // error={controller.error} onRetry={controller.reload} />
|
|
100
|
+
*/
|
|
101
|
+
export function createCrudController(crud, options = {}) {
|
|
102
|
+
const defaultPageSize = crud.resource.defaultPageSize ?? 10;
|
|
103
|
+
const keepPreviousData = options.keepPreviousData !== false;
|
|
104
|
+
const syncUrl = options.syncUrl !== false;
|
|
105
|
+
const filterIds = crud.getFilterFields().map(f => f.id);
|
|
106
|
+
const data = $state({
|
|
107
|
+
items: [],
|
|
108
|
+
total: 0,
|
|
109
|
+
page: 1,
|
|
110
|
+
pageSize: defaultPageSize,
|
|
111
|
+
filters: {},
|
|
112
|
+
});
|
|
113
|
+
// 最近一次解析出的查询参数(供 refresh 使用)。
|
|
114
|
+
let pending = { search: '', page: 1, pageSize: defaultPageSize };
|
|
115
|
+
// 单调递增请求号;只有最新一轮请求可写回状态(latest-wins)。
|
|
116
|
+
let listRequestSeq = 0;
|
|
117
|
+
// 最新一轮请求的取消控制器;新请求发起前 abort 上一轮。
|
|
118
|
+
let abortController;
|
|
119
|
+
// 并发去重:同参数请求在途时复用同一 Promise。
|
|
120
|
+
let inFlightKey;
|
|
121
|
+
let inFlightPromise;
|
|
122
|
+
let loading = $state(false);
|
|
123
|
+
let loaded = $state(false);
|
|
124
|
+
let error = $state(undefined);
|
|
125
|
+
/** 生成参数的稳定 key(用于并发去重)。 */
|
|
126
|
+
function paramsKey(params) {
|
|
127
|
+
return JSON.stringify(Object.entries(params).sort(([a], [b]) => a.localeCompare(b)));
|
|
128
|
+
}
|
|
129
|
+
/** 把成功响应写回响应式 data,并回填 filters 供筛选栏回显。 */
|
|
130
|
+
function applyResult(result, params) {
|
|
131
|
+
data.items = result.items;
|
|
132
|
+
data.total = result.total;
|
|
133
|
+
data.page = result.page;
|
|
134
|
+
data.pageSize = result.pageSize;
|
|
135
|
+
const filters = { search: params.search ?? '' };
|
|
136
|
+
for (const id of filterIds) {
|
|
137
|
+
if (params[id] !== undefined)
|
|
138
|
+
filters[id] = params[id];
|
|
139
|
+
}
|
|
140
|
+
data.filters = filters;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 按参数拉取列表并写回响应式 data。
|
|
144
|
+
*
|
|
145
|
+
* @description 暴露查询中、已成功加载和错误三种状态;连续请求只允许最新响应更新状态与数据,
|
|
146
|
+
* 旧请求被 abort 且其结果被丢弃;失败时按 keepPreviousData 决定是否保留旧列表。
|
|
147
|
+
*
|
|
148
|
+
* @param params - 搜索、过滤与分页参数。
|
|
149
|
+
* @returns 最新请求结算后完成;错误被保存在控制器中,避免未处理 Promise 拒绝。
|
|
150
|
+
*/
|
|
151
|
+
function fetchList(params) {
|
|
152
|
+
const key = paramsKey(params);
|
|
153
|
+
// 并发去重:同参数请求在途时直接复用,避免重复打后端。
|
|
154
|
+
if (inFlightPromise && inFlightKey === key)
|
|
155
|
+
return inFlightPromise;
|
|
156
|
+
const requestSeq = ++listRequestSeq;
|
|
157
|
+
// 取消上一轮在途请求,实现 latest-wins 的第一重保险。
|
|
158
|
+
abortController?.abort();
|
|
159
|
+
const ac = new AbortController();
|
|
160
|
+
abortController = ac;
|
|
161
|
+
loading = true;
|
|
162
|
+
error = undefined;
|
|
163
|
+
// 不保留旧数据时立即清空,展示查询中占位;保留时维持旧列表避免闪烁。
|
|
164
|
+
if (!keepPreviousData) {
|
|
165
|
+
data.items = [];
|
|
166
|
+
data.total = 0;
|
|
167
|
+
}
|
|
168
|
+
inFlightKey = key;
|
|
169
|
+
const promise = (async () => {
|
|
170
|
+
try {
|
|
171
|
+
const result = await crud.api.list(params, { signal: ac.signal });
|
|
172
|
+
// 旧请求晚返回:seq 已被新请求覆盖时丢弃,绝不污染用户当前看到的结果。
|
|
173
|
+
if (requestSeq !== listRequestSeq)
|
|
174
|
+
return;
|
|
175
|
+
applyResult(result, params);
|
|
176
|
+
loaded = true;
|
|
177
|
+
}
|
|
178
|
+
catch (cause) {
|
|
179
|
+
// 被新请求取代而 abort 的旧请求:静默丢弃,不写错误。
|
|
180
|
+
if (ac.signal.aborted || (cause instanceof Error && cause.name === 'AbortError'))
|
|
181
|
+
return;
|
|
182
|
+
// 列表加载失败(慢响应/重启/超时)时保留上一次结果,绝不用空列表覆盖,避免误显示“0 条记录”;
|
|
183
|
+
// 同时暴露错误给页面:首次失败不能被当成一次成功的空结果。
|
|
184
|
+
if (requestSeq === listRequestSeq)
|
|
185
|
+
error = cause instanceof Error ? cause : new Error(String(cause));
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
if (requestSeq === listRequestSeq) {
|
|
189
|
+
loading = false;
|
|
190
|
+
inFlightPromise = undefined;
|
|
191
|
+
inFlightKey = undefined;
|
|
192
|
+
abortController = undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
})();
|
|
196
|
+
inFlightPromise = promise;
|
|
197
|
+
return promise;
|
|
198
|
+
}
|
|
199
|
+
/** 从 CrudPage 生成的 URL 中解析查询参数。 */
|
|
200
|
+
function parseParams(url) {
|
|
201
|
+
const qs = url.includes('?') ? url.slice(url.indexOf('?') + 1) : '';
|
|
202
|
+
const sp = new SvelteURLSearchParams(qs);
|
|
203
|
+
const params = {
|
|
204
|
+
search: sp.get('search') ?? '',
|
|
205
|
+
page: sp.get('page') ? Number(sp.get('page')) : 1,
|
|
206
|
+
pageSize: sp.get('pageSize') ? Number(sp.get('pageSize')) : defaultPageSize,
|
|
207
|
+
};
|
|
208
|
+
for (const key of ['sortBy', 'sortDirection']) {
|
|
209
|
+
const v = sp.get(key);
|
|
210
|
+
if (v !== null && v !== '')
|
|
211
|
+
params[key] = v;
|
|
212
|
+
}
|
|
213
|
+
for (const id of filterIds) {
|
|
214
|
+
const v = sp.get(id);
|
|
215
|
+
if (v !== null && v !== '')
|
|
216
|
+
params[id] = v;
|
|
217
|
+
}
|
|
218
|
+
return params;
|
|
219
|
+
}
|
|
220
|
+
/** 把查询串回填地址栏,保留 pathname 与 hash,不触发跳转;SSR / 无 history 环境静默跳过。 */
|
|
221
|
+
function writeUrl(url) {
|
|
222
|
+
if (!syncUrl)
|
|
223
|
+
return;
|
|
224
|
+
const his = globalThis.history;
|
|
225
|
+
const loc = globalThis.location;
|
|
226
|
+
if (!his?.replaceState || !loc)
|
|
227
|
+
return;
|
|
228
|
+
const qs = url.includes('?') ? url.slice(url.indexOf('?')) : '';
|
|
229
|
+
try {
|
|
230
|
+
his.replaceState(his.state, '', `${loc.pathname}${qs}${loc.hash}`);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// 隐私模式 / 跨域等限制下忽略,不影响数据加载。
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const nav = {
|
|
237
|
+
pathname: '',
|
|
238
|
+
navigate: (url) => {
|
|
239
|
+
// 仅解析参数并回填地址栏,不做真实跳转。
|
|
240
|
+
pending = parseParams(url);
|
|
241
|
+
writeUrl(url);
|
|
242
|
+
},
|
|
243
|
+
refresh: () => fetchList(pending),
|
|
244
|
+
};
|
|
245
|
+
// 默认立即加载;惰性页签显式关闭,避免挂载时抢占后端资源,在首次激活时调用 load()。
|
|
246
|
+
if (options.autoLoad !== false)
|
|
247
|
+
void fetchList(pending);
|
|
248
|
+
return {
|
|
249
|
+
get data() {
|
|
250
|
+
return data;
|
|
251
|
+
},
|
|
252
|
+
nav,
|
|
253
|
+
load: () => fetchList(pending),
|
|
254
|
+
reload: () => fetchList(pending),
|
|
255
|
+
get loading() {
|
|
256
|
+
return loading;
|
|
257
|
+
},
|
|
258
|
+
get loaded() {
|
|
259
|
+
return loaded;
|
|
260
|
+
},
|
|
261
|
+
get error() {
|
|
262
|
+
return error;
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @h-ai/ui — CRUD 场景组件
|
|
3
3
|
*
|
|
4
|
-
* 提供声明式 CRUD
|
|
4
|
+
* 提供声明式 CRUD 页面组件与框架级控制器:
|
|
5
|
+
* - `CrudPage` 组件:自动渲染列表、搜索过滤、分页、详情/编辑面板、删除确认,以及查询中/失败/空态。
|
|
6
|
+
* - `defineCrud` / `createCrudController`:纯客户端(无 SvelteKit SSR)下托管数据与导航,
|
|
7
|
+
* 业务应用无需自建 CRUD 适配层。
|
|
5
8
|
* @module crud
|
|
6
9
|
*/
|
|
10
|
+
export * from './crud-controller.svelte.js';
|
|
7
11
|
export type * from './crud-types.js';
|
|
8
12
|
export { default as CrudDeleteConfirm } from './CrudDeleteConfirm.svelte';
|
|
9
13
|
export { default as CrudDetailPanel } from './CrudDetailPanel.svelte';
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @h-ai/ui — CRUD 场景组件
|
|
3
3
|
*
|
|
4
|
-
* 提供声明式 CRUD
|
|
4
|
+
* 提供声明式 CRUD 页面组件与框架级控制器:
|
|
5
|
+
* - `CrudPage` 组件:自动渲染列表、搜索过滤、分页、详情/编辑面板、删除确认,以及查询中/失败/空态。
|
|
6
|
+
* - `defineCrud` / `createCrudController`:纯客户端(无 SvelteKit SSR)下托管数据与导航,
|
|
7
|
+
* 业务应用无需自建 CRUD 适配层。
|
|
5
8
|
* @module crud
|
|
6
9
|
*/
|
|
10
|
+
export * from './crud-controller.svelte.js';
|
|
7
11
|
export { default as CrudDeleteConfirm } from './CrudDeleteConfirm.svelte';
|
|
8
12
|
export { default as CrudDetailPanel } from './CrudDetailPanel.svelte';
|
|
9
13
|
export { default as CrudEditPanel } from './CrudEditPanel.svelte';
|
package/dist/messages/en-US.json
CHANGED
|
@@ -262,12 +262,14 @@
|
|
|
262
262
|
"crud_edit": "Edit",
|
|
263
263
|
"crud_detail": "Detail",
|
|
264
264
|
"crud_delete": "Delete",
|
|
265
|
+
"crud_load_failed": "Failed to load",
|
|
266
|
+
"crud_retry": "Retry",
|
|
265
267
|
"crud_save": "Save",
|
|
266
268
|
"crud_cancel": "Cancel",
|
|
267
269
|
"crud_close": "Close",
|
|
268
270
|
"crud_search_placeholder": "Search...",
|
|
269
271
|
"crud_no_data": "No data",
|
|
270
|
-
"crud_loading": "
|
|
272
|
+
"crud_loading": "Querying…",
|
|
271
273
|
"crud_delete_title": "Confirm Delete",
|
|
272
274
|
"crud_delete_message": "Are you sure you want to delete this record? This action cannot be undone.",
|
|
273
275
|
"crud_submit_success": "Operation successful",
|
package/dist/messages/zh-CN.json
CHANGED
|
@@ -262,12 +262,14 @@
|
|
|
262
262
|
"crud_edit": "编辑",
|
|
263
263
|
"crud_detail": "详情",
|
|
264
264
|
"crud_delete": "删除",
|
|
265
|
+
"crud_load_failed": "加载失败",
|
|
266
|
+
"crud_retry": "重试",
|
|
265
267
|
"crud_save": "保存",
|
|
266
268
|
"crud_cancel": "取消",
|
|
267
269
|
"crud_close": "关闭",
|
|
268
270
|
"crud_search_placeholder": "搜索...",
|
|
269
271
|
"crud_no_data": "暂无数据",
|
|
270
|
-
"crud_loading": "
|
|
272
|
+
"crud_loading": "查询中…",
|
|
271
273
|
"crud_delete_title": "确认删除",
|
|
272
274
|
"crud_delete_message": "确定要删除此记录吗?此操作不可恢复。",
|
|
273
275
|
"crud_submit_success": "操作成功",
|
package/dist/toast.svelte.d.ts
CHANGED
|
@@ -42,4 +42,50 @@ declare class ToastState {
|
|
|
42
42
|
* Toast 单例
|
|
43
43
|
*/
|
|
44
44
|
export declare const toast: ToastState;
|
|
45
|
+
/**
|
|
46
|
+
* 弹出操作成功提示(右上角)。
|
|
47
|
+
*
|
|
48
|
+
* @description 统一封装 toast 单例,业务应用无需自建 toast 工具文件即可获得一致的成功反馈。
|
|
49
|
+
*
|
|
50
|
+
* @param message - 用户可见的成功文案(已本地化)。
|
|
51
|
+
* @param duration - 可选停留时长(毫秒),默认 3000。
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* notifySuccess(m.submit_submitted())
|
|
55
|
+
*/
|
|
56
|
+
export declare function notifySuccess(message: string, duration?: number): string;
|
|
57
|
+
/**
|
|
58
|
+
* 弹出操作失败提示(右上角)。
|
|
59
|
+
*
|
|
60
|
+
* @description 接受 `Error` 或字符串;`Error` 取其 message,其余回退到 fallback,
|
|
61
|
+
* 避免各业务页面重复实现错误文案兜底逻辑。
|
|
62
|
+
*
|
|
63
|
+
* @param error - 错误对象或已本地化的错误文案。
|
|
64
|
+
* @param fallback - 当 error 非 Error 且为空时使用的兜底文案。
|
|
65
|
+
* @param duration - 可选停留时长(毫秒),默认 5000。
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* notifyError(error, m.submit_failed())
|
|
69
|
+
*/
|
|
70
|
+
export declare function notifyError(error: unknown, fallback?: string, duration?: number): string;
|
|
71
|
+
/**
|
|
72
|
+
* 弹出信息提示(右上角)。
|
|
73
|
+
*
|
|
74
|
+
* @param message - 用户可见文案(已本地化)。
|
|
75
|
+
* @param duration - 可选停留时长(毫秒),默认 3000。
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* notifyInfo(m.common_saved())
|
|
79
|
+
*/
|
|
80
|
+
export declare function notifyInfo(message: string, duration?: number): string;
|
|
81
|
+
/**
|
|
82
|
+
* 弹出警告提示(右上角)。
|
|
83
|
+
*
|
|
84
|
+
* @param message - 用户可见文案(已本地化)。
|
|
85
|
+
* @param duration - 可选停留时长(毫秒),默认 5000。
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* notifyWarning(m.quota_almost_reached())
|
|
89
|
+
*/
|
|
90
|
+
export declare function notifyWarning(message: string, duration?: number): string;
|
|
45
91
|
export {};
|
package/dist/toast.svelte.js
CHANGED
|
@@ -75,3 +75,64 @@ class ToastState {
|
|
|
75
75
|
* Toast 单例
|
|
76
76
|
*/
|
|
77
77
|
export const toast = new ToastState();
|
|
78
|
+
/** 成功提示默认停留时长(毫秒)。 */
|
|
79
|
+
const SUCCESS_DURATION = 3000;
|
|
80
|
+
/** 失败提示默认停留时长(毫秒),比成功略长便于阅读错误信息。 */
|
|
81
|
+
const ERROR_DURATION = 5000;
|
|
82
|
+
/**
|
|
83
|
+
* 弹出操作成功提示(右上角)。
|
|
84
|
+
*
|
|
85
|
+
* @description 统一封装 toast 单例,业务应用无需自建 toast 工具文件即可获得一致的成功反馈。
|
|
86
|
+
*
|
|
87
|
+
* @param message - 用户可见的成功文案(已本地化)。
|
|
88
|
+
* @param duration - 可选停留时长(毫秒),默认 3000。
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* notifySuccess(m.submit_submitted())
|
|
92
|
+
*/
|
|
93
|
+
export function notifySuccess(message, duration = SUCCESS_DURATION) {
|
|
94
|
+
return toast.success(message, duration);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 弹出操作失败提示(右上角)。
|
|
98
|
+
*
|
|
99
|
+
* @description 接受 `Error` 或字符串;`Error` 取其 message,其余回退到 fallback,
|
|
100
|
+
* 避免各业务页面重复实现错误文案兜底逻辑。
|
|
101
|
+
*
|
|
102
|
+
* @param error - 错误对象或已本地化的错误文案。
|
|
103
|
+
* @param fallback - 当 error 非 Error 且为空时使用的兜底文案。
|
|
104
|
+
* @param duration - 可选停留时长(毫秒),默认 5000。
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* notifyError(error, m.submit_failed())
|
|
108
|
+
*/
|
|
109
|
+
export function notifyError(error, fallback = '', duration = ERROR_DURATION) {
|
|
110
|
+
const message = error instanceof Error
|
|
111
|
+
? error.message
|
|
112
|
+
: (typeof error === 'string' ? error : fallback);
|
|
113
|
+
return toast.error(message || fallback, duration);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 弹出信息提示(右上角)。
|
|
117
|
+
*
|
|
118
|
+
* @param message - 用户可见文案(已本地化)。
|
|
119
|
+
* @param duration - 可选停留时长(毫秒),默认 3000。
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* notifyInfo(m.common_saved())
|
|
123
|
+
*/
|
|
124
|
+
export function notifyInfo(message, duration = SUCCESS_DURATION) {
|
|
125
|
+
return toast.info(message, duration);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 弹出警告提示(右上角)。
|
|
129
|
+
*
|
|
130
|
+
* @param message - 用户可见文案(已本地化)。
|
|
131
|
+
* @param duration - 可选停留时长(毫秒),默认 5000。
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* notifyWarning(m.quota_almost_reached())
|
|
135
|
+
*/
|
|
136
|
+
export function notifyWarning(message, duration = ERROR_DURATION) {
|
|
137
|
+
return toast.warning(message, duration);
|
|
138
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -671,6 +671,18 @@ export interface TabItem {
|
|
|
671
671
|
/** 图标 */
|
|
672
672
|
icon?: string;
|
|
673
673
|
}
|
|
674
|
+
/**
|
|
675
|
+
* 标签页内容上下文
|
|
676
|
+
*
|
|
677
|
+
* 传给 `Tabs` 的 `children` 插槽,支持惰性挂载:只渲染已激活过的页签内容,
|
|
678
|
+
* 不预加载不可见页签。
|
|
679
|
+
*/
|
|
680
|
+
export interface TabsContentContext {
|
|
681
|
+
/** 当前激活的标签 key */
|
|
682
|
+
active: string;
|
|
683
|
+
/** 指定标签是否已被激活过(`lazy` 为 false 时恒为 true) */
|
|
684
|
+
isActivated: (key: string) => boolean;
|
|
685
|
+
}
|
|
674
686
|
/**
|
|
675
687
|
* 标签页属性
|
|
676
688
|
*/
|
|
@@ -687,8 +699,12 @@ export interface TabsProps extends DataAttributes {
|
|
|
687
699
|
class?: string;
|
|
688
700
|
/** 变化事件 */
|
|
689
701
|
onchange?: (key: string) => void;
|
|
690
|
-
/**
|
|
691
|
-
|
|
702
|
+
/** 页签首次激活时触发一次;用于惰性加载对应内容(如调用控制器的 load) */
|
|
703
|
+
onactivate?: (key: string) => void;
|
|
704
|
+
/** 是否惰性挂载页签内容:为 true 时未激活过的页签不渲染,`isActivated` 据此判断 */
|
|
705
|
+
lazy?: boolean;
|
|
706
|
+
/** 内容插槽;参数提供当前激活项与惰性挂载判断 */
|
|
707
|
+
children?: Snippet<[TabsContentContext]>;
|
|
692
708
|
}
|
|
693
709
|
/**
|
|
694
710
|
* 头像属性
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@h-ai/ui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.0-alpha.
|
|
4
|
+
"version": "0.1.0-alpha.50",
|
|
5
5
|
"description": "Hai Framework UI component library for Svelte 5 Runes.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"svelte": "./dist/index.js",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"mermaid": "^11.12.0",
|
|
53
53
|
"shiki": "^4.0.2",
|
|
54
54
|
"tailwind-merge": "^3.5.0",
|
|
55
|
-
"@h-ai/core": "0.1.0-alpha.
|
|
55
|
+
"@h-ai/core": "0.1.0-alpha.50"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@sveltejs/package": "^2.5.7",
|