@h-ai/ui 0.1.0-alpha.61 → 0.1.0-alpha.63

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 (79) hide show
  1. package/README.en.md +93 -8
  2. package/README.ja.md +93 -8
  3. package/README.md +94 -8
  4. package/auto-import.d.ts +3 -0
  5. package/auto-import.js +3 -0
  6. package/dist/components/compounds/Combobox.svelte +19 -6
  7. package/dist/components/compounds/Combobox.svelte.d.ts +2 -2
  8. package/dist/components/compounds/DatePicker.svelte +14 -5
  9. package/dist/components/compounds/DatePicker.svelte.d.ts +2 -2
  10. package/dist/components/compounds/Form.svelte +10 -6
  11. package/dist/components/compounds/FormField.svelte +78 -13
  12. package/dist/components/compounds/FormLayout.svelte +59 -0
  13. package/dist/components/compounds/FormLayout.svelte.d.ts +4 -0
  14. package/dist/components/compounds/TagInput.svelte +1 -1
  15. package/dist/components/compounds/index.d.ts +1 -0
  16. package/dist/components/compounds/index.js +1 -0
  17. package/dist/components/primitives/BareInput.svelte +7 -1
  18. package/dist/components/primitives/Checkbox.svelte +8 -2
  19. package/dist/components/primitives/Input.svelte +22 -11
  20. package/dist/components/primitives/Select.svelte +77 -18
  21. package/dist/components/primitives/Textarea.svelte +27 -13
  22. package/dist/components/primitives/Textarea.svelte.d.ts +1 -1
  23. package/dist/components/scenes/app/FeedbackModal.svelte +29 -19
  24. package/dist/components/scenes/app/FeedbackModal.svelte.d.ts +2 -2
  25. package/dist/components/scenes/crud/CrudEditPanel.svelte +148 -118
  26. package/dist/components/scenes/crud/CrudEditPanel.svelte.d.ts +4 -25
  27. package/dist/components/scenes/crud/CrudPage.svelte +5 -41
  28. package/dist/components/scenes/crud/CrudPage.svelte.d.ts +2 -53
  29. package/dist/components/scenes/crud/crud-controller.svelte.d.ts +4 -1
  30. package/dist/components/scenes/crud/crud-form-validation.d.ts +4 -0
  31. package/dist/components/scenes/crud/crud-form-validation.js +39 -0
  32. package/dist/components/scenes/crud/crud-types.d.ts +9 -42
  33. package/dist/components/scenes/guide/GuideHost.svelte +111 -0
  34. package/dist/components/scenes/guide/GuideHost.svelte.d.ts +16 -0
  35. package/dist/components/scenes/guide/HelpCenter.svelte +94 -0
  36. package/dist/components/scenes/guide/HelpCenter.svelte.d.ts +13 -0
  37. package/dist/components/scenes/guide/index.d.ts +2 -0
  38. package/dist/components/scenes/guide/index.js +2 -0
  39. package/dist/components/scenes/iam/AuthPanel.svelte +55 -47
  40. package/dist/components/scenes/iam/ChangePasswordForm.svelte +57 -59
  41. package/dist/components/scenes/iam/ForgotPasswordForm.svelte +89 -84
  42. package/dist/components/scenes/iam/LoginForm.svelte +134 -132
  43. package/dist/components/scenes/iam/PasswordInput.svelte +25 -7
  44. package/dist/components/scenes/iam/PasswordInput.svelte.d.ts +2 -2
  45. package/dist/components/scenes/iam/RegisterForm.svelte +95 -97
  46. package/dist/components/scenes/iam/ResetPasswordForm.svelte +83 -85
  47. package/dist/components/scenes/iam/UserProfile.svelte +9 -5
  48. package/dist/components/scenes/iam/auth-error-message.d.ts +2 -4
  49. package/dist/components/scenes/index.d.ts +1 -0
  50. package/dist/components/scenes/index.js +1 -0
  51. package/dist/components/scenes/storage/FileUpload.svelte +1 -1
  52. package/dist/components/scenes/storage/ImageUpload.svelte +1 -0
  53. package/dist/components/scenes/types.d.ts +9 -9
  54. package/dist/guide-storage.d.ts +6 -0
  55. package/dist/guide-storage.js +67 -0
  56. package/dist/guide-types.d.ts +78 -0
  57. package/dist/guide-types.js +1 -0
  58. package/dist/guide.d.ts +3 -0
  59. package/dist/guide.js +3 -0
  60. package/dist/guide.svelte.d.ts +8 -0
  61. package/dist/guide.svelte.js +450 -0
  62. package/dist/i18n.svelte.d.ts +2 -1
  63. package/dist/i18n.svelte.js +2 -1
  64. package/dist/index.d.ts +2 -0
  65. package/dist/index.js +2 -0
  66. package/dist/internal/form-context.d.ts +12 -0
  67. package/dist/internal/form-context.js +9 -0
  68. package/dist/internal/form-validation.svelte.d.ts +16 -0
  69. package/dist/internal/form-validation.svelte.js +76 -0
  70. package/dist/internal/locale.d.ts +2 -0
  71. package/dist/internal/locale.js +22 -0
  72. package/dist/messages/en-US.json +30 -1
  73. package/dist/messages/zh-CN.json +30 -1
  74. package/dist/messages.d.ts +2 -1
  75. package/dist/messages.js +6 -4
  76. package/dist/styles/guide.css +88 -0
  77. package/dist/types.d.ts +57 -10
  78. package/package.json +13 -2
  79. package/vite.js +1 -0
@@ -5,7 +5,8 @@
5
5
  * @module crud-types
6
6
  */
7
7
  import type { Snippet } from 'svelte';
8
- import type { DataAttributes, Size } from '../../../types.js';
8
+ import type { DataAttributes, FormLayoutOptions, Size } from '../../../types.js';
9
+ import type { CrudFieldDef } from './crud-controller.svelte.js';
9
10
  /** CrudPage 权限控制 */
10
11
  export interface CrudPermissions {
11
12
  /** 允许新建 */
@@ -20,7 +21,7 @@ export type CrudFormVariant = 'drawer' | 'modal';
20
21
  /** 列表显示密度 */
21
22
  export type CrudDensity = 'normal' | 'compact';
22
23
  /** 新建/编辑表单展示配置 */
23
- export interface CrudFormConfig {
24
+ export interface CrudFormConfig extends FormLayoutOptions {
24
25
  /** 展示形式:'drawer'(抽屉,默认)或 'modal'(弹出窗口) */
25
26
  variant?: CrudFormVariant;
26
27
  /** 抽屉尺寸预设(variant='drawer',默认 '2xl') */
@@ -69,41 +70,7 @@ export interface CrudPageProps<T = Record<string, unknown>> extends DataAttribut
69
70
  name: string;
70
71
  label: string | (() => string);
71
72
  keyField?: string;
72
- fields: Array<{
73
- id: string;
74
- label: string | (() => string);
75
- type: string;
76
- inList?: boolean;
77
- filterable?: boolean;
78
- readonly?: boolean;
79
- inCreate?: boolean;
80
- inEdit?: boolean;
81
- inDetail?: boolean;
82
- options?: Array<{
83
- label: string;
84
- value: string | number | boolean;
85
- }> | (() => Array<{
86
- label: string;
87
- value: string | number | boolean;
88
- }>);
89
- validation?: {
90
- required?: boolean;
91
- min?: number;
92
- max?: number;
93
- pattern?: string;
94
- message?: string;
95
- };
96
- width?: string;
97
- align?: 'left' | 'center' | 'right';
98
- render?: (value: unknown, item: Record<string, unknown>) => string;
99
- placeholder?: string | (() => string);
100
- /** 日期范围起始值 key;默认 `${id}Start`。 */
101
- startKey?: string;
102
- /** 日期范围结束值 key;默认 `${id}End`。 */
103
- endKey?: string;
104
- defaultValue?: unknown;
105
- order?: number;
106
- }>;
73
+ fields: CrudFieldDef[];
107
74
  defaultPageSize?: number;
108
75
  searchable?: boolean;
109
76
  searchPlaceholder?: string | (() => string);
@@ -120,11 +87,11 @@ export interface CrudPageProps<T = Record<string, unknown>> extends DataAttribut
120
87
  remove?: (id: string) => Promise<void>;
121
88
  };
122
89
  };
123
- getListFields: () => Array<Record<string, unknown>>;
124
- getFilterFields: () => Array<Record<string, unknown>>;
125
- getCreateFields: () => Array<Record<string, unknown>>;
126
- getEditFields: () => Array<Record<string, unknown>>;
127
- getDetailFields: () => Array<Record<string, unknown>>;
90
+ getListFields: () => CrudFieldDef[];
91
+ getFilterFields: () => CrudFieldDef[];
92
+ getCreateFields: () => CrudFieldDef[];
93
+ getEditFields: () => CrudFieldDef[];
94
+ getDetailFields: () => CrudFieldDef[];
128
95
  toTableColumns: () => Array<{
129
96
  key: string;
130
97
  label: string;
@@ -0,0 +1,111 @@
1
+ <script lang='ts'>
2
+ import type { GuideController } from '../../../guide-types.js'
3
+ import type { DataAttributes } from '../../../types.js'
4
+ import { onMount, untrack } from 'svelte'
5
+ import { resolveGuideText } from '../../../guide.svelte.js'
6
+ import { getUILocale, uiM } from '../../../messages.js'
7
+ import { getDataAttributes } from '../../../utils.js'
8
+ import Alert from '../../compounds/Alert.svelte'
9
+ import Modal from '../../compounds/Modal.svelte'
10
+ import Button from '../../primitives/Button.svelte'
11
+ import HelpCenter from './HelpCenter.svelte'
12
+ import 'driver.js/dist/driver.css'
13
+ import '../../../styles/guide.css'
14
+
15
+ interface Props {
16
+ guide: GuideController
17
+ locale?: string
18
+ /** 非敏感的用户标识,账号切换后独立记录引导偏好。 */
19
+ scope?: string
20
+ /** 挂载后提示首次使用者,不会直接打断操作或自动跳转。 */
21
+ autoStart?: string
22
+ }
23
+
24
+ const { guide, locale = getUILocale(), scope = 'anonymous', autoStart, ...restProps }: Props & DataAttributes = $props()
25
+ const dataAttributes = $derived(getDataAttributes(restProps))
26
+ let ready = $state(false)
27
+ const offered = $derived.by(() => {
28
+ void locale
29
+ const tour = guide.offeredTour
30
+ return tour ? { id: tour.id, title: resolveGuideText(tour.title), description: resolveGuideText(tour.description) } : null
31
+ })
32
+ const text = $derived.by(() => ({
33
+ welcome: uiM('guide_welcome', undefined, locale),
34
+ start: uiM('guide_start', undefined, locale),
35
+ later: uiM('guide_later', undefined, locale),
36
+ dismiss: uiM('guide_dismiss', undefined, locale),
37
+ help: uiM('guide_help', undefined, locale),
38
+ close: uiM('guide_close', undefined, locale),
39
+ loading: uiM('guide_loading', undefined, locale),
40
+ }))
41
+
42
+ $effect(() => {
43
+ const currentLocale = locale
44
+ untrack(() => guide.setLocale(currentLocale))
45
+ })
46
+ $effect(() => {
47
+ const currentScope = scope
48
+ const tourId = autoStart
49
+ if (ready) {
50
+ untrack(() => {
51
+ guide.setScope(currentScope)
52
+ if (tourId)
53
+ guide.startIfNeeded(tourId)
54
+ })
55
+ }
56
+ })
57
+ $effect(() => {
58
+ const pathname = guide.pathname
59
+ untrack(() => guide.handleNavigation(pathname))
60
+ })
61
+ onMount(() => {
62
+ let alive = true
63
+ guide.setScope(scope)
64
+ guide.setLocale(locale)
65
+ void guide.resume().then(() => {
66
+ if (alive)
67
+ ready = true
68
+ })
69
+ return () => {
70
+ alive = false
71
+ guide.stop()
72
+ }
73
+ })
74
+ </script>
75
+
76
+ <div {...dataAttributes} class='contents'>
77
+
78
+ {#if offered}
79
+ <aside class='hai-guide-offer fixed bottom-4 right-4 z-[1150] w-96 max-w-[calc(100vw-2rem)] rounded-2xl border border-base-content/10 bg-base-100 p-5 text-base-content shadow-(--shadow-overlay)' aria-label={text.welcome} data-testid='guide-offer'>
80
+ <p class='text-xs font-medium text-primary'>{text.welcome}</p>
81
+ <h2 class='mt-2 text-base font-semibold'>{offered.title}</h2>
82
+ <p class='mt-2 text-sm leading-6 text-base-content/65'>{offered.description}</p>
83
+ <div class='mt-4 flex flex-wrap gap-2'>
84
+ <Button variant='primary' size='sm' onclick={() => offered && void guide.start(offered.id)} data-testid='guide-start'>{text.start}</Button>
85
+ <Button variant='ghost' size='sm' onclick={() => guide.defer()} data-testid='guide-later'>{text.later}</Button>
86
+ <Button variant='ghost' size='sm' onclick={() => guide.dismiss()} data-testid='guide-dismiss'>{text.dismiss}</Button>
87
+ </div>
88
+ </aside>
89
+ {/if}
90
+
91
+ {#if guide.busy}
92
+ <div class='hai-guide-loading fixed bottom-4 left-1/2 z-[1000000001] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-3 rounded-xl border border-base-content/10 bg-base-100 px-4 py-2 text-sm text-base-content shadow-lg' role='status' data-testid='guide-loading'>
93
+ <span class='loading loading-spinner loading-xs'></span>{text.loading}
94
+ <Button size='sm' variant='ghost' onclick={() => guide.stop()} data-testid='guide-cancel'>{text.close}</Button>
95
+ </div>
96
+ {/if}
97
+
98
+ {#if guide.error}
99
+ <div class='fixed bottom-4 right-4 z-[1500] max-w-[calc(100vw-2rem)]' data-testid='guide-error'>
100
+ <Alert variant='warning'>
101
+ <div class='flex items-center gap-3'><span>{guide.error}</span><Button size='sm' variant='ghost' onclick={() => guide.clearError()}>{text.close}</Button></div>
102
+ </Alert>
103
+ </div>
104
+ {/if}
105
+
106
+ {#if guide.helpOpen}
107
+ <Modal open title={text.help} size='4xl' height='min(80dvh, 48rem)' bodyClass='p-0' onclose={() => guide.closeHelp()} data-testid='guide-help'>
108
+ <HelpCenter articles={guide.articles} tours={guide.tours} selectedId={guide.articleId} {locale} onstart={id => void guide.start(id)} />
109
+ </Modal>
110
+ {/if}
111
+ </div>
@@ -0,0 +1,16 @@
1
+ import type { GuideController } from '../../../guide-types.js';
2
+ import type { DataAttributes } from '../../../types.js';
3
+ import 'driver.js/dist/driver.css';
4
+ import '../../../styles/guide.css';
5
+ interface Props {
6
+ guide: GuideController;
7
+ locale?: string;
8
+ /** 非敏感的用户标识,账号切换后独立记录引导偏好。 */
9
+ scope?: string;
10
+ /** 挂载后提示首次使用者,不会直接打断操作或自动跳转。 */
11
+ autoStart?: string;
12
+ }
13
+ type $$ComponentProps = Props & DataAttributes;
14
+ declare const GuideHost: import("svelte").Component<$$ComponentProps, {}, "">;
15
+ type GuideHost = ReturnType<typeof GuideHost>;
16
+ export default GuideHost;
@@ -0,0 +1,94 @@
1
+ <script lang='ts'>
2
+ import type { GuideArticle, GuideTour } from '../../../guide-types.js'
3
+ import type { DataAttributes } from '../../../types.js'
4
+ import { resolveGuideText } from '../../../guide.svelte.js'
5
+ import { getUILocale, uiM } from '../../../messages.js'
6
+ import { getDataAttributes } from '../../../utils.js'
7
+ import BareButton from '../../primitives/BareButton.svelte'
8
+ import Button from '../../primitives/Button.svelte'
9
+ import Input from '../../primitives/Input.svelte'
10
+
11
+ interface Props {
12
+ articles: readonly GuideArticle[]
13
+ tours?: readonly GuideTour[]
14
+ locale?: string
15
+ selectedId?: string | null
16
+ onstart?: (tourId: string) => void
17
+ }
18
+ const { articles, tours = [], locale = getUILocale(), selectedId = null, onstart, ...restProps }: Props & DataAttributes = $props()
19
+ const dataAttributes = $derived(getDataAttributes(restProps))
20
+ let search = $state('')
21
+ const searchId = $props.id()
22
+ let currentId = $derived(selectedId)
23
+ // 显式依赖宿主语言,确保回调返回的业务翻译也重新求值。
24
+ const localized = $derived.by(() => {
25
+ void locale
26
+ return articles.filter(article => !article.enabled || article.enabled()).map(article => ({
27
+ ...article,
28
+ title: resolveGuideText(article.title),
29
+ description: resolveGuideText(article.description),
30
+ category: resolveGuideText(article.category),
31
+ content: resolveGuideText(article.content),
32
+ }))
33
+ })
34
+ const availableTours = $derived.by(() => {
35
+ void locale
36
+ return tours.filter(tour => !tour.enabled || tour.enabled()).map(tour => ({ id: tour.id, title: resolveGuideText(tour.title) }))
37
+ })
38
+ const visible = $derived(localized.filter(article => (`${article.title} ${article.description} ${article.category} ${article.content}`).toLowerCase().includes(search.trim().toLowerCase())))
39
+ const selected = $derived(visible.find(article => article.id === currentId) ?? visible[0])
40
+ const markdown = $derived(selected ? import('../ai/MarkdownRenderer.svelte') : null)
41
+ const text = $derived.by(() => ({
42
+ search: uiM('guide_search', undefined, locale),
43
+ empty: uiM('guide_empty', undefined, locale),
44
+ tours: uiM('guide_tours', undefined, locale),
45
+ start: uiM('guide_start', undefined, locale),
46
+ loading: uiM('guide_loading', undefined, locale),
47
+ failed: uiM('guide_startFailed', undefined, locale),
48
+ }))
49
+ </script>
50
+
51
+ <div data-testid='help-center' {...dataAttributes} class='flex min-h-0 flex-col text-base-content md:flex-row'>
52
+ <nav class='shrink-0 border-b border-base-content/8 p-4 md:w-64 md:border-b-0 md:border-r' aria-label={text.search}>
53
+ <label class='sr-only' for={searchId}>{text.search}</label>
54
+ <Input id={searchId} bind:value={search} placeholder={text.search} data-testid='guide-search' />
55
+ <div class='mt-3 max-h-40 space-y-1 overflow-y-auto md:max-h-[50dvh]'>
56
+ {#each visible as article (article.id)}
57
+ <BareButton class={`w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-base-content/5 ${selected?.id === article.id ? 'bg-primary/10 font-semibold text-primary' : ''}`} onclick={() => currentId = article.id} data-testid={`guide-article-${article.id}`}>
58
+ {#if article.category}<span class='mb-1 block text-xs text-base-content/50'>{article.category}</span>{/if}
59
+ <span class='block'>{article.title}</span>
60
+ </BareButton>
61
+ {/each}
62
+ </div>
63
+ {#if onstart && availableTours.length}
64
+ <p class='mt-5 text-xs font-medium text-base-content/50'>{text.tours}</p>
65
+ <div class='mt-2 flex flex-wrap gap-2 md:flex-col'>
66
+ {#each availableTours as tour (tour.id)}
67
+ <Button variant='ghost' size='sm' onclick={() => onstart?.(tour.id)} data-testid={`guide-tour-${tour.id}`}>{tour.title}</Button>
68
+ {/each}
69
+ </div>
70
+ {/if}
71
+ </nav>
72
+ <article class='min-w-0 flex-1 p-5 md:p-7' data-testid='guide-article-content'>
73
+ {#if selected}
74
+ <h2 class='text-xl font-semibold'>{selected.title}</h2>
75
+ {#if selected.description}<p class='mt-2 text-sm text-base-content/60'>{selected.description}</p>{/if}
76
+ {#if selected.tourId && availableTours.some(tour => tour.id === selected.tourId) && onstart}
77
+ <Button variant='primary' size='sm' class='mt-4' onclick={() => onstart?.(selected!.tourId!)} data-testid='guide-article-start'>{text.start}</Button>
78
+ {/if}
79
+ <div class='mt-5'>
80
+ {#await markdown}
81
+ <p class='text-sm text-base-content/50' role='status'>{text.loading}</p>
82
+ {:then component}
83
+ {#if component}
84
+ <component.default content={selected.content} allowHtmlTags={false} showCopyButton={false} />
85
+ {/if}
86
+ {:catch}
87
+ <p class='text-sm text-error' role='alert'>{text.failed}</p>
88
+ {/await}
89
+ </div>
90
+ {:else}
91
+ <p class='py-8 text-center text-sm text-base-content/50'>{text.empty}</p>
92
+ {/if}
93
+ </article>
94
+ </div>
@@ -0,0 +1,13 @@
1
+ import type { GuideArticle, GuideTour } from '../../../guide-types.js';
2
+ import type { DataAttributes } from '../../../types.js';
3
+ interface Props {
4
+ articles: readonly GuideArticle[];
5
+ tours?: readonly GuideTour[];
6
+ locale?: string;
7
+ selectedId?: string | null;
8
+ onstart?: (tourId: string) => void;
9
+ }
10
+ type $$ComponentProps = Props & DataAttributes;
11
+ declare const HelpCenter: import("svelte").Component<$$ComponentProps, {}, "">;
12
+ type HelpCenter = ReturnType<typeof HelpCenter>;
13
+ export default HelpCenter;
@@ -0,0 +1,2 @@
1
+ export { default as GuideHost } from './GuideHost.svelte';
2
+ export { default as HelpCenter } from './HelpCenter.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as GuideHost } from './GuideHost.svelte';
2
+ export { default as HelpCenter } from './HelpCenter.svelte';
@@ -1,6 +1,7 @@
1
1
  <!-- 配置驱动认证场景;网络、通知及 Token 存储由 actions 注入。 -->
2
2
  <script lang='ts'>
3
- import type { HaiResult } from '@h-ai/core'
3
+ import type { HaiError, HaiResult } from '@h-ai/core'
4
+ import type { UIMessageKey } from '../../../messages.js'
4
5
  import type { AuthLoginMethod, AuthPanelMode, AuthPanelProps } from '../types.js'
5
6
  import { onMount, untrack } from 'svelte'
6
7
  import { uiM } from '../../../messages.js'
@@ -16,12 +17,14 @@
16
17
  import RegisterForm from './RegisterForm.svelte'
17
18
  import ResetPasswordForm from './ResetPasswordForm.svelte'
18
19
 
19
- const { config, actions, initialMode = 'login', resetToken = '', loginUrl, registerUrl, forgotPasswordUrl, onauthenticated, error = '', onretry, loginHeader, loginFields, loginFooter, registerHeader, registerFields, registerFooter, ...restProps }: AuthPanelProps = $props()
20
+ const { config, actions, columns = 1, labelPosition = 'top', labelWidth = '8rem', initialMode = 'login', resetToken = '', loginUrl, registerUrl, forgotPasswordUrl, onauthenticated, error = '', onretry, loginHeader, loginFields, loginFooter, registerHeader, registerFields, registerFooter, ...restProps }: AuthPanelProps = $props()
20
21
  const dataAttributes = $derived(getDataAttributes(restProps))
21
22
  let mode = $state<AuthPanelMode>(untrack(() => initialMode))
22
23
  let loading = $state(false)
23
- let errors = $state<Record<string, string>>({})
24
- let notice = $state('')
24
+ let actionError = $state<Pick<HaiError, 'code' | 'message'>>()
25
+ let networkFailed = $state(false)
26
+ const errors = $derived<Record<string, string>>(actionError ? { general: authErrorMessage(actionError) } : networkFailed ? { general: uiM('auth_network_error') } : {})
27
+ let notice = $state<UIMessageKey | ''>('')
25
28
  // SSR 表单先显示但不可编辑;客户端绑定事件后开放输入,避免接管时丢失早到的输入。
26
29
  let interactive = $state(false)
27
30
  onMount(() => {
@@ -33,13 +36,15 @@
33
36
 
34
37
  function switchMode(next: AuthPanelMode) {
35
38
  mode = next
36
- errors = {}
39
+ actionError = undefined
40
+ networkFailed = false
37
41
  notice = ''
38
42
  }
39
- async function run(operation: () => Promise<HaiResult<unknown>>, authenticate = false, successNotice = '') {
43
+ async function run(operation: () => Promise<HaiResult<unknown>>, authenticate = false, successNotice: UIMessageKey | '' = '') {
40
44
  if (loading)
41
45
  return
42
- errors = {}
46
+ actionError = undefined
47
+ networkFailed = false
43
48
  notice = ''
44
49
  loading = true
45
50
  try {
@@ -48,10 +53,10 @@
48
53
  if (mode === 'register' && result.error.code === 'hai:iam:004') {
49
54
  // 默认禁用的新账户已完成注册,提示等待启用,避免用户重复注册。
50
55
  switchMode('login')
51
- notice = uiM('auth_registration_pending')
56
+ notice = 'auth_registration_pending'
52
57
  }
53
58
  else {
54
- errors = { general: authErrorMessage(result.error) }
59
+ actionError = result.error
55
60
  }
56
61
  }
57
62
  else if (authenticate) {
@@ -61,7 +66,7 @@
61
66
  notice = successNotice
62
67
  }
63
68
  }
64
- catch { errors = { general: uiM('auth_network_error') } }
69
+ catch { networkFailed = true }
65
70
  finally { loading = false }
66
71
  }
67
72
  </script>
@@ -80,44 +85,47 @@
80
85
 
81
86
  <AuthShell {...dataAttributes} {...config?.ui} title={config?.ui.title || title} brandTitle={config?.ui.brandTitle || uiM('auth_brand')}>
82
87
  <fieldset class='m-0 min-w-0 border-0 p-0' disabled={!interactive}>
83
- {#if error}
84
- <Alert variant='error'>{error}</Alert>
85
- {#if onretry}<Button class='mt-4 w-full' onclick={onretry}>{uiM('crud_retry')}</Button>{/if}
86
- {:else if !config}
87
- <div class='flex justify-center' role='status'><Spinner /></div>
88
- {:else if !available}
89
- <Alert variant='warning'>{uiM('auth_unavailable')}</Alert>
90
- <div class='mt-4 text-center'>{@render link('login', uiM('login_submit'), loginUrl)}</div>
91
- {:else}
92
- {#if notice}<Alert variant='success' class='mb-4'>{notice}</Alert>{/if}
93
- {#if mode === 'login'}
94
- <LoginForm {methods} {loading} {errors} header={loginHeader} extraFields={loginFields} showRememberMe={false} showForgotPassword={config.passwordReset.enabled} {forgotPasswordUrl}
95
- onforgotpassword={forgotPasswordUrl ? undefined : () => switchMode('forgot')}
96
- otpLength={config.otp.length} resendInterval={config.otp.resendInterval} onsendotp={actions.sendOtp}
97
- agreements={config.agreements.showOnLogin ? config.agreements : undefined}
98
- onsubmit={data => run(() => actions.login({ identifier: data.username, password: data.password, method: data.method, code: data.code }), true)}>
99
- {#snippet footer()}
100
- {#if loginFooter}{@render loginFooter()}{/if}
101
- {#if config?.register.enabled}<div class='text-center text-sm'>{uiM('login_no_account')} {@render link('register', uiM('login_register_now'), registerUrl)}</div>{/if}
102
- {/snippet}
103
- </LoginForm>
104
- {:else if mode === 'register'}
105
- <RegisterForm {loading} {errors} header={registerHeader} extraFields={registerFields} fields={config.passwordReset.mode === 'phone' ? ['username', 'phone', 'password'] : ['username', 'email', 'password']} passwordPolicy={config.password} minPasswordLength={config.password.minLength}
106
- agreements={config.agreements.showOnRegister ? config.agreements : undefined} onsubmit={data => run(() => actions.register({ username: data.username || '', password: data.password, email: data.email, phone: data.phone, displayName: data.nickname }), true)}>
107
- {#snippet footer()}
108
- {#if registerFooter}{@render registerFooter()}{/if}
109
- <div class='text-center'>{@render link('login', uiM('register_login_now'), loginUrl)}</div>
110
- {/snippet}
111
- </RegisterForm>
112
- {:else if mode === 'forgot'}
113
- <ForgotPasswordForm {loading} {errors} mode={config.passwordReset.mode ?? 'email'} onsubmit={data => run(() => actions.requestPasswordReset(data.email || data.phone || ''), false, uiM('auth_reset_sent'))}>
114
- {#snippet footer()}<div class='flex justify-between gap-3'>{@render link('login', uiM('login_submit'), loginUrl)}{@render link('reset', uiM('auth_have_token'))}</div>{/snippet}
115
- </ForgotPasswordForm>
116
- {:else}
117
- <ResetPasswordForm {loading} {errors} showCode={!resetToken} passwordPolicy={config.password} minPasswordLength={config.password.minLength}
118
- onsubmit={data => run(() => actions.confirmPasswordReset(resetToken || data.code || '', data.newPassword), false, uiM('auth_reset_done'))} />
88
+ <!-- 固定内容包装节点,避免 fieldset 插入通知时丢失容器查询子树的布局。 -->
89
+ <div class='min-w-0'>
90
+ {#if error}
91
+ <Alert variant='error'>{error}</Alert>
92
+ {#if onretry}<Button class='mt-4 w-full' onclick={onretry}>{uiM('crud_retry')}</Button>{/if}
93
+ {:else if !config}
94
+ <div class='flex justify-center' role='status'><Spinner /></div>
95
+ {:else if !available}
96
+ <Alert variant='warning'>{uiM('auth_unavailable')}</Alert>
119
97
  <div class='mt-4 text-center'>{@render link('login', uiM('login_submit'), loginUrl)}</div>
98
+ {:else}
99
+ {#if notice}<Alert variant='success' class='mb-4'>{uiM(notice)}</Alert>{/if}
100
+ {#if mode === 'login'}
101
+ <LoginForm {columns} {labelPosition} {labelWidth} {methods} {loading} {errors} header={loginHeader} extraFields={loginFields} showRememberMe={false} showForgotPassword={config.passwordReset.enabled} {forgotPasswordUrl}
102
+ onforgotpassword={forgotPasswordUrl ? undefined : () => switchMode('forgot')}
103
+ otpLength={config.otp.length} resendInterval={config.otp.resendInterval} onsendotp={actions.sendOtp}
104
+ agreements={config.agreements.showOnLogin ? config.agreements : undefined}
105
+ onsubmit={data => run(() => actions.login({ identifier: data.username, password: data.password, method: data.method, code: data.code }), true)}>
106
+ {#snippet footer()}
107
+ {#if loginFooter}{@render loginFooter()}{/if}
108
+ {#if config?.register.enabled}<div class='text-center text-sm'>{uiM('login_no_account')} {@render link('register', uiM('login_register_now'), registerUrl)}</div>{/if}
109
+ {/snippet}
110
+ </LoginForm>
111
+ {:else if mode === 'register'}
112
+ <RegisterForm {columns} {labelPosition} {labelWidth} {loading} {errors} header={registerHeader} extraFields={registerFields} fields={config.passwordReset.mode === 'phone' ? ['username', 'phone', 'password'] : ['username', 'email', 'password']} passwordPolicy={config.password} minPasswordLength={config.password.minLength}
113
+ agreements={config.agreements.showOnRegister ? config.agreements : undefined} onsubmit={data => run(() => actions.register({ username: data.username || '', password: data.password, email: data.email, phone: data.phone, displayName: data.nickname }), true)}>
114
+ {#snippet footer()}
115
+ {#if registerFooter}{@render registerFooter()}{/if}
116
+ <div class='text-center'>{@render link('login', uiM('register_login_now'), loginUrl)}</div>
117
+ {/snippet}
118
+ </RegisterForm>
119
+ {:else if mode === 'forgot'}
120
+ <ForgotPasswordForm {columns} {labelPosition} {labelWidth} {loading} {errors} mode={config.passwordReset.mode ?? 'email'} onsubmit={data => run(() => actions.requestPasswordReset(data.email || data.phone || ''), false, 'auth_reset_sent')}>
121
+ {#snippet footer()}<div class='flex justify-between gap-3'>{@render link('login', uiM('login_submit'), loginUrl)}{@render link('reset', uiM('auth_have_token'))}</div>{/snippet}
122
+ </ForgotPasswordForm>
123
+ {:else}
124
+ <ResetPasswordForm {columns} {labelPosition} {labelWidth} {loading} {errors} showCode={!resetToken} passwordPolicy={config.password} minPasswordLength={config.password.minLength}
125
+ onsubmit={data => run(() => actions.confirmPasswordReset(resetToken || data.code || '', data.newPassword), false, 'auth_reset_done')} />
126
+ <div class='mt-4 text-center'>{@render link('login', uiM('login_submit'), loginUrl)}</div>
127
+ {/if}
120
128
  {/if}
121
- {/if}
129
+ </div>
122
130
  </fieldset>
123
131
  </AuthShell>
@@ -8,12 +8,17 @@
8
8
  import { uiM } from '../../../messages.js'
9
9
  import { cn, getDataAttributes } from '../../../utils.js'
10
10
  import Alert from '../../compounds/Alert.svelte'
11
+ import FormField from '../../compounds/FormField.svelte'
12
+ import FormLayout from '../../compounds/FormLayout.svelte'
11
13
  import Button from '../../primitives/Button.svelte'
12
14
  import { arePasswordsEqual, passwordPolicyError, passwordPolicyHint } from './password-utils.js'
13
15
  import PasswordInput from './PasswordInput.svelte'
14
16
 
15
17
  const {
16
18
  loading = false,
19
+ columns = 1,
20
+ labelPosition = 'top',
21
+ labelWidth = '8rem',
17
22
  disabled = false,
18
23
  requireOldPassword = true,
19
24
  showPasswordStrength = true,
@@ -73,71 +78,64 @@
73
78
  </script>
74
79
 
75
80
  <form {...dataAttributes} class={formClass} onsubmit={handleSubmit}>
76
- {#if requireOldPassword}
77
- <div class='space-y-1.5'>
78
- <label class='text-sm font-medium text-base-content/70' for='old-password'>
79
- {uiM('change_password_old')}
80
- </label>
81
+ <FormLayout {columns} {labelPosition} {labelWidth}>
82
+ {#if requireOldPassword}
83
+ <FormField for='old-password' label={uiM('change_password_old')}>
84
+ <PasswordInput
85
+ value={oldPassword}
86
+ oninput={(e) => { oldPassword = e.currentTarget.value }}
87
+ placeholder={uiM('change_password_old_placeholder')}
88
+ {disabled}
89
+ error={errors.oldPassword}
90
+ showStrength={false}
91
+ />
92
+ </FormField>
93
+ {/if}
94
+
95
+ <FormField for='new-password' label={uiM('change_password_new')}>
81
96
  <PasswordInput
82
- value={oldPassword}
83
- oninput={(e) => { oldPassword = e.currentTarget.value }}
84
- placeholder={uiM('change_password_old_placeholder')}
97
+ value={newPassword}
98
+ oninput={(e) => { newPassword = e.currentTarget.value }}
99
+ placeholder={uiM('change_password_new_placeholder')}
85
100
  {disabled}
86
- error={errors.oldPassword}
87
- showStrength={false}
101
+ error={errors.newPassword}
102
+ showStrength={showPasswordStrength}
103
+ minLength={minPasswordLength}
88
104
  />
89
- </div>
90
- {/if}
105
+ </FormField>
91
106
 
92
- <div class='space-y-1.5'>
93
- <label class='text-sm font-medium text-base-content/70' for='new-password'>
94
- {uiM('change_password_new')}
95
- </label>
96
- <PasswordInput
97
- value={newPassword}
98
- oninput={(e) => { newPassword = e.currentTarget.value }}
99
- placeholder={uiM('change_password_new_placeholder')}
100
- {disabled}
101
- error={errors.newPassword}
102
- showStrength={showPasswordStrength}
103
- minLength={minPasswordLength}
104
- />
105
- </div>
106
-
107
- <div class='space-y-1.5'>
108
- <label class='text-sm font-medium text-base-content/70' for='confirm-password'>
109
- {uiM('change_password_confirm')}
110
- </label>
111
- <PasswordInput
112
- value={confirmPassword}
113
- oninput={(e) => { confirmPassword = e.currentTarget.value }}
114
- placeholder={uiM('change_password_confirm_placeholder')}
115
- {disabled}
116
- error={errors.confirmPassword || (!passwordsMatch && confirmPassword ? uiM('change_password_mismatch') : '')}
117
- showStrength={false}
118
- />
119
- </div>
107
+ <FormField for='confirm-password' label={uiM('change_password_confirm')}>
108
+ <PasswordInput
109
+ value={confirmPassword}
110
+ oninput={(e) => { confirmPassword = e.currentTarget.value }}
111
+ placeholder={uiM('change_password_confirm_placeholder')}
112
+ {disabled}
113
+ error={errors.confirmPassword || (!passwordsMatch && confirmPassword ? uiM('change_password_mismatch') : '')}
114
+ showStrength={false}
115
+ />
116
+ </FormField>
120
117
 
121
- <div class='rounded-lg bg-base-content/3 px-3.5 py-2.5 text-xs text-base-content/50'>
122
- {uiM('change_password_relogin_hint')}
123
- </div>
118
+ <div class='rounded-lg bg-base-content/3 px-3.5 py-2.5 text-xs text-base-content/50'>
119
+ {uiM('change_password_relogin_hint')}
120
+ </div>
124
121
 
125
- {#if errors.general}
126
- <Alert variant='error'>
127
- {errors.general}
128
- </Alert>
129
- {/if}
122
+ {#if errors.general}
123
+ <Alert variant='error'>
124
+ {errors.general}
125
+ </Alert>
126
+ {/if}
130
127
 
131
- {#if passwordPolicy}<p class='text-xs text-base-content/60'>{passwordPolicyHint(passwordPolicy)}</p>{/if}
132
- {#if policyError}<p role='alert' class='text-sm text-error'>{policyError}</p>{/if}
128
+ {#if passwordPolicy}<p class='text-xs text-base-content/60'>{passwordPolicyHint(passwordPolicy)}</p>{/if}
129
+ {#if policyError}<p role='alert' class='text-sm text-error'>{policyError}</p>{/if}
133
130
 
134
- <Button
135
- type='submit'
136
- variant='primary'
137
- class='w-full sm:w-auto sm:min-w-40'
138
- disabled={loading || disabled || !canSubmit || Boolean(policyError)}
139
- {loading}
140
- >
141
- {submitText || uiM('change_password_submit')}
142
- </Button>
131
+ <Button
132
+ type='submit'
133
+ variant='primary'
134
+ class='w-full sm:w-auto sm:min-w-40'
135
+ disabled={loading || disabled || !canSubmit || Boolean(policyError)}
136
+ {loading}
137
+ >
138
+ {submitText || uiM('change_password_submit')}
139
+ </Button>
140
+ </FormLayout>
143
141
  </form>