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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +41 -11
  2. package/auto-import.d.ts +5 -0
  3. package/auto-import.js +4 -0
  4. package/dist/components/compounds/DataTable.svelte +81 -4
  5. package/dist/components/compounds/DataTable.svelte.d.ts +7 -0
  6. package/dist/components/compounds/Modal.svelte +123 -63
  7. package/dist/components/compounds/Pagination.svelte +72 -119
  8. package/dist/components/scenes/ai/markdown-parse.js +12 -4
  9. package/dist/components/scenes/ai/table/AiTableEditor.svelte +4 -2
  10. package/dist/components/scenes/app/SettingsLayout.svelte +111 -0
  11. package/dist/components/scenes/app/SettingsLayout.svelte.d.ts +41 -0
  12. package/dist/components/scenes/app/ThemeColorPicker.svelte +41 -0
  13. package/dist/components/scenes/app/index.d.ts +1 -0
  14. package/dist/components/scenes/app/index.js +1 -0
  15. package/dist/components/scenes/crud/CrudFilterBar.svelte +173 -35
  16. package/dist/components/scenes/crud/CrudFilterBar.svelte.d.ts +7 -1
  17. package/dist/components/scenes/crud/CrudPage.svelte +19 -15
  18. package/dist/components/scenes/error/ErrorPage.svelte +125 -0
  19. package/dist/components/scenes/error/ErrorPage.svelte.d.ts +42 -0
  20. package/dist/components/scenes/error/error-presets.d.ts +24 -0
  21. package/dist/components/scenes/error/error-presets.js +31 -0
  22. package/dist/components/scenes/error/index.d.ts +8 -0
  23. package/dist/components/scenes/error/index.js +8 -0
  24. package/dist/components/scenes/iam/AuthShell.svelte +138 -0
  25. package/dist/components/scenes/iam/AuthShell.svelte.d.ts +45 -0
  26. package/dist/components/scenes/iam/index.d.ts +1 -0
  27. package/dist/components/scenes/iam/index.js +2 -1
  28. package/dist/components/scenes/index.d.ts +1 -0
  29. package/dist/components/scenes/index.js +2 -0
  30. package/dist/messages/en-US.json +20 -1
  31. package/dist/messages/zh-CN.json +20 -1
  32. package/dist/styles/global.css +4 -1
  33. package/dist/theme-config.d.ts +60 -4
  34. package/dist/theme-config.js +77 -6
  35. package/dist/types.d.ts +6 -0
  36. package/package.json +2 -2
@@ -10,7 +10,7 @@
10
10
  <script lang='ts'>
11
11
  import type { PaginationProps } from '../../types.js'
12
12
  import { uiM } from '../../messages.js'
13
- import { cn, getSizeClass } from '../../utils.js'
13
+ import { cn } from '../../utils.js'
14
14
  import BareButton from '../primitives/BareButton.svelte'
15
15
 
16
16
  import Input from '../primitives/Input.svelte'
@@ -36,63 +36,10 @@
36
36
  // 计算总页数(至少 1 页,保证分页栏在空数据时仍可渲染)
37
37
  const totalPages = $derived(Math.max(1, Math.ceil(total / pageSize)))
38
38
 
39
- // 生成页码列表
40
- const pages = $derived(() => {
41
- const result: (number | string)[] = []
42
- const maxVisible = 7
43
-
44
- if (totalPages <= maxVisible) {
45
- for (let i = 1; i <= totalPages; i++) {
46
- result.push(i)
47
- }
48
- }
49
- else {
50
- // 始终显示第一页
51
- result.push(1)
52
-
53
- if (page > 3) {
54
- result.push('...')
55
- }
56
-
57
- // 中间页码
58
- const start = Math.max(2, page - 1)
59
- const end = Math.min(totalPages - 1, page + 1)
60
-
61
- for (let i = start; i <= end; i++) {
62
- result.push(i)
63
- }
64
-
65
- if (page < totalPages - 2) {
66
- result.push('...')
67
- }
68
-
69
- // 始终显示最后一页
70
- result.push(totalPages)
71
- }
72
-
73
- return result
74
- })
75
-
76
- const joinClass = $derived(
77
- cn(
78
- 'join',
79
- className,
80
- ),
81
- )
82
-
83
- const btnClass = $derived(
84
- cn(
85
- 'join-item btn',
86
- getSizeClass(size),
87
- ),
88
- )
89
-
90
39
  const controlSize = $derived(size === 'xs' ? 'xs' : 'sm')
91
40
  const jumperControlSize = 'xs'
92
- const wrapperClass = $derived(size === 'xs' ? 'flex flex-wrap items-center justify-center gap-1.5 text-xs' : 'flex flex-wrap items-center justify-center gap-2.5 text-sm')
93
- const totalClass = $derived(size === 'xs' ? 'text-xs text-base-content/70' : 'text-sm text-base-content/70')
94
- const sizeChangerClass = $derived(size === 'xs' ? 'w-22 shrink-0' : 'w-24 shrink-0')
95
- const jumperWrapClass = $derived(size === 'xs' ? 'flex min-w-fit items-center gap-1.5 whitespace-nowrap' : 'flex min-w-fit items-center gap-1.5 whitespace-nowrap')
41
+ const sizeChangerClass = $derived(size === 'xs' ? 'w-17 shrink-0' : 'w-18 shrink-0')
42
+ const jumperWrapClass = $derived(size === 'xs' ? 'flex min-w-fit items-center gap-1.5 whitespace-nowrap' : 'flex min-w-fit items-center gap-2 whitespace-nowrap')
96
43
  const jumperInputClass = $derived(
97
44
  size === 'xs'
98
45
  ? 'w-[2.25rem] shrink-0 [&>.fieldset]:m-0 [&>.fieldset]:min-w-0'
@@ -122,77 +69,83 @@
122
69
  onpagesizechange?.(next)
123
70
  }
124
71
  }
72
+
73
+ // 统一 table 风格:首/上/下/末 导航按钮样式(等比例缩小)
74
+ const navBtnClass = $derived(
75
+ size === 'xs'
76
+ ? 'inline-flex h-6 w-6 items-center justify-center rounded-md border border-base-content/12 text-base-content/55 transition-colors hover:bg-base-content/5 hover:text-base-content disabled:pointer-events-none disabled:opacity-40'
77
+ : 'inline-flex h-7 w-7 items-center justify-center rounded-md border border-base-content/12 text-base-content/55 transition-colors hover:bg-base-content/5 hover:text-base-content disabled:pointer-events-none disabled:opacity-40',
78
+ )
79
+ const navIconClass = $derived(size === 'xs' ? 'size-3.5' : 'size-3.5')
80
+ const tableTextClass = $derived(size === 'xs' ? 'text-xs text-base-content/60' : 'text-sm text-base-content/60')
81
+ const rowGapClass = $derived(size === 'xs' ? 'flex flex-wrap items-center gap-3 sm:gap-4' : 'flex flex-wrap items-center gap-4 sm:gap-6')
82
+ const pageInfoText = $derived(
83
+ (labels.pageInfo ?? uiM('pagination_page_info'))
84
+ .replace('{page}', String(page))
85
+ .replace('{total}', String(totalPages)),
86
+ )
125
87
  </script>
126
88
 
127
- <div class={wrapperClass}>
89
+ <div class={cn('flex flex-wrap items-center justify-between gap-4', className)}>
128
90
  {#if showTotal}
129
- <span class={totalClass}>
91
+ <span class={tableTextClass}>
130
92
  {(labels.total ?? uiM('pagination_total')).replace('{count}', String(total))}
131
93
  </span>
94
+ {:else}
95
+ <span></span>
132
96
  {/if}
133
97
 
134
- {#if showSizeChanger}
135
- <div class={sizeChangerClass}>
136
- <Select
137
- size={controlSize}
138
- value={String(pageSize)}
139
- onchange={handlePageSizeChange}
140
- >
141
- {#each pageSizeOptions as opt (opt)}
142
- <option value={String(opt)}>
143
- {(labels.pageSize ?? uiM('pagination_page_size')).replace('{size}', String(opt))}
144
- </option>
145
- {/each}
146
- </Select>
98
+ <div class={rowGapClass}>
99
+ {#if showSizeChanger}
100
+ <div class='flex items-center gap-2'>
101
+ <span class={tableTextClass}>{labels.rowsPerPage ?? uiM('pagination_rows_per_page')}</span>
102
+ <div class={sizeChangerClass}>
103
+ <Select
104
+ size={controlSize}
105
+ value={String(pageSize)}
106
+ onchange={handlePageSizeChange}
107
+ >
108
+ {#each pageSizeOptions as opt (opt)}
109
+ <option value={String(opt)}>{opt}</option>
110
+ {/each}
111
+ </Select>
112
+ </div>
113
+ </div>
114
+ {/if}
115
+
116
+ <span class={tableTextClass}>{pageInfoText}</span>
117
+
118
+ <div class='flex items-center gap-1'>
119
+ <BareButton class={navBtnClass} disabled={page === 1} onclick={() => goToPage(1)} ariaLabel='First page'>
120
+ <span class={cn('icon-[tabler--chevrons-left]', navIconClass)}></span>
121
+ </BareButton>
122
+ <BareButton class={navBtnClass} disabled={page === 1} onclick={() => goToPage(page - 1)} ariaLabel='Previous page'>
123
+ <span class={cn('icon-[tabler--chevron-left]', navIconClass)}></span>
124
+ </BareButton>
125
+ <BareButton class={navBtnClass} disabled={page === totalPages} onclick={() => goToPage(page + 1)} ariaLabel='Next page'>
126
+ <span class={cn('icon-[tabler--chevron-right]', navIconClass)}></span>
127
+ </BareButton>
128
+ <BareButton class={navBtnClass} disabled={page === totalPages} onclick={() => goToPage(totalPages)} ariaLabel='Last page'>
129
+ <span class={cn('icon-[tabler--chevrons-right]', navIconClass)}></span>
130
+ </BareButton>
147
131
  </div>
148
- {/if}
149
-
150
- <div class={joinClass}>
151
- <BareButton
152
- class={btnClass}
153
- disabled={page === 1}
154
- onclick={() => goToPage(page - 1)}
155
- >
156
- «
157
- </BareButton>
158
-
159
- {#each pages() as p, index (typeof p === 'number' ? p : `ellipsis-${index}`)}
160
- {#if typeof p === 'number'}
161
- <BareButton
162
- class={cn(btnClass, page === p && 'btn-active')}
163
- onclick={() => goToPage(p)}
164
- >
165
- {p}
166
- </BareButton>
167
- {:else}
168
- <BareButton class={cn(btnClass, 'btn-disabled')} disabled>...</BareButton>
169
- {/if}
170
- {/each}
171
-
172
- <BareButton
173
- class={btnClass}
174
- disabled={page === totalPages}
175
- onclick={() => goToPage(page + 1)}
176
- >
177
- »
178
- </BareButton>
179
- </div>
180
132
 
181
- {#if showJumper}
182
- <div class={jumperWrapClass}>
183
- <span class={totalClass}>{labels.jumpTo ?? uiM('pagination_jump_to')}</span>
184
- <div class={jumperInputClass}>
185
- <Input
186
- type='number'
187
- size={jumperControlSize}
188
- class='h-7 w-full rounded-md [&_input]:px-1.5 [&_input]:text-center [&_input]:text-[12px]'
189
- min={1}
190
- max={totalPages}
191
- bind:value={jumperValue}
192
- onkeydown={(e: KeyboardEvent & { currentTarget: HTMLInputElement }) => e.key === 'Enter' && handleJump()}
193
- />
133
+ {#if showJumper}
134
+ <div class={jumperWrapClass}>
135
+ <span class={tableTextClass}>{labels.jumpTo ?? uiM('pagination_jump_to')}</span>
136
+ <div class={jumperInputClass}>
137
+ <Input
138
+ type='number'
139
+ size={jumperControlSize}
140
+ class='h-7 w-full rounded-md [&_input]:px-1.5 [&_input]:text-center [&_input]:text-[12px]'
141
+ min={1}
142
+ max={totalPages}
143
+ bind:value={jumperValue}
144
+ onkeydown={(e: KeyboardEvent & { currentTarget: HTMLInputElement }) => e.key === 'Enter' && handleJump()}
145
+ />
146
+ </div>
147
+ <span class={tableTextClass}>{labels.page ?? uiM('pagination_page')}</span>
194
148
  </div>
195
- <span class={totalClass}>{labels.page ?? uiM('pagination_page')}</span>
196
- </div>
197
- {/if}
149
+ {/if}
150
+ </div>
198
151
  </div>
@@ -24,6 +24,16 @@ function escapeHtml(text) {
24
24
  .replace(/"/g, '&quot;')
25
25
  .replace(/'/g, '&#39;');
26
26
  }
27
+ function createTableCellAttrs(cell) {
28
+ const attrs = [];
29
+ if (cell.align) {
30
+ attrs.push(`style="text-align:${cell.align}"`);
31
+ }
32
+ if (cell.text.trim()) {
33
+ attrs.push(`title="${escapeHtml(cell.text)}"`);
34
+ }
35
+ return attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
36
+ }
27
37
  /**
28
38
  * 创建自定义渲染器对象
29
39
  *
@@ -91,15 +101,13 @@ function createRendererObject(options) {
91
101
  table(token) {
92
102
  let headerHtml = '';
93
103
  for (const cell of token.header) {
94
- const align = cell.align ? ` style="text-align:${cell.align}"` : '';
95
- headerHtml += `<th${align}>${this.parser.parseInline(cell.tokens)}</th>`;
104
+ headerHtml += `<th${createTableCellAttrs(cell)}>${this.parser.parseInline(cell.tokens)}</th>`;
96
105
  }
97
106
  let bodyHtml = '';
98
107
  for (const row of token.rows) {
99
108
  let rowHtml = '';
100
109
  for (const cell of row) {
101
- const align = cell.align ? ` style="text-align:${cell.align}"` : '';
102
- rowHtml += `<td${align}>${this.parser.parseInline(cell.tokens)}</td>`;
110
+ rowHtml += `<td${createTableCellAttrs(cell)}>${this.parser.parseInline(cell.tokens)}</td>`;
103
111
  }
104
112
  bodyHtml += `<tr>${rowHtml}</tr>`;
105
113
  }
@@ -1097,7 +1097,7 @@
1097
1097
  <thead>
1098
1098
  <tr>
1099
1099
  {#each tableColumns as column (column.key)}
1100
- <th>{column.label}</th>
1100
+ <th title={column.label}>{column.label}</th>
1101
1101
  {/each}
1102
1102
  {#if editable}
1103
1103
  <th class='hai-ai-table-action-head'>{uiM('data_table_actions')}</th>
@@ -1122,11 +1122,13 @@
1122
1122
  ondrop={event => handleRowDrop(row.row_id, event)}
1123
1123
  >
1124
1124
  {#each tableColumns as column (column.key)}
1125
+ {@const cellInputValue = getCellInputValue(row, column.key)}
1125
1126
  <td>
1126
1127
  <input
1127
1128
  class={resolveCellInputClass(column.type)}
1128
1129
  type={column.type === 'number' ? 'number' : 'text'}
1129
- value={getCellInputValue(row, column.key)}
1130
+ value={cellInputValue}
1131
+ title={cellInputValue}
1130
1132
  disabled={!editable}
1131
1133
  oninput={event => handleCellInput(row.row_id, column, event)}
1132
1134
  />
@@ -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';