@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.
- package/README.en.md +93 -8
- package/README.ja.md +93 -8
- package/README.md +94 -8
- package/auto-import.d.ts +3 -0
- package/auto-import.js +3 -0
- package/dist/components/compounds/Combobox.svelte +19 -6
- package/dist/components/compounds/Combobox.svelte.d.ts +2 -2
- package/dist/components/compounds/DatePicker.svelte +14 -5
- package/dist/components/compounds/DatePicker.svelte.d.ts +2 -2
- package/dist/components/compounds/Form.svelte +10 -6
- package/dist/components/compounds/FormField.svelte +78 -13
- package/dist/components/compounds/FormLayout.svelte +59 -0
- package/dist/components/compounds/FormLayout.svelte.d.ts +4 -0
- package/dist/components/compounds/TagInput.svelte +1 -1
- package/dist/components/compounds/index.d.ts +1 -0
- package/dist/components/compounds/index.js +1 -0
- package/dist/components/primitives/BareInput.svelte +7 -1
- package/dist/components/primitives/Checkbox.svelte +8 -2
- package/dist/components/primitives/Input.svelte +22 -11
- package/dist/components/primitives/Select.svelte +77 -18
- package/dist/components/primitives/Textarea.svelte +27 -13
- package/dist/components/primitives/Textarea.svelte.d.ts +1 -1
- package/dist/components/scenes/app/FeedbackModal.svelte +29 -19
- package/dist/components/scenes/app/FeedbackModal.svelte.d.ts +2 -2
- package/dist/components/scenes/crud/CrudEditPanel.svelte +148 -118
- package/dist/components/scenes/crud/CrudEditPanel.svelte.d.ts +4 -25
- package/dist/components/scenes/crud/CrudPage.svelte +5 -41
- package/dist/components/scenes/crud/CrudPage.svelte.d.ts +2 -53
- package/dist/components/scenes/crud/crud-controller.svelte.d.ts +4 -1
- package/dist/components/scenes/crud/crud-form-validation.d.ts +4 -0
- package/dist/components/scenes/crud/crud-form-validation.js +39 -0
- package/dist/components/scenes/crud/crud-types.d.ts +9 -42
- package/dist/components/scenes/guide/GuideHost.svelte +111 -0
- package/dist/components/scenes/guide/GuideHost.svelte.d.ts +16 -0
- package/dist/components/scenes/guide/HelpCenter.svelte +94 -0
- package/dist/components/scenes/guide/HelpCenter.svelte.d.ts +13 -0
- package/dist/components/scenes/guide/index.d.ts +2 -0
- package/dist/components/scenes/guide/index.js +2 -0
- package/dist/components/scenes/iam/AuthPanel.svelte +55 -47
- package/dist/components/scenes/iam/ChangePasswordForm.svelte +57 -59
- package/dist/components/scenes/iam/ForgotPasswordForm.svelte +89 -84
- package/dist/components/scenes/iam/LoginForm.svelte +134 -132
- package/dist/components/scenes/iam/PasswordInput.svelte +25 -7
- package/dist/components/scenes/iam/PasswordInput.svelte.d.ts +2 -2
- package/dist/components/scenes/iam/RegisterForm.svelte +95 -97
- package/dist/components/scenes/iam/ResetPasswordForm.svelte +83 -85
- package/dist/components/scenes/iam/UserProfile.svelte +9 -5
- package/dist/components/scenes/iam/auth-error-message.d.ts +2 -4
- package/dist/components/scenes/index.d.ts +1 -0
- package/dist/components/scenes/index.js +1 -0
- package/dist/components/scenes/storage/FileUpload.svelte +1 -1
- package/dist/components/scenes/storage/ImageUpload.svelte +1 -0
- package/dist/components/scenes/types.d.ts +9 -9
- package/dist/guide-storage.d.ts +6 -0
- package/dist/guide-storage.js +67 -0
- package/dist/guide-types.d.ts +78 -0
- package/dist/guide-types.js +1 -0
- package/dist/guide.d.ts +3 -0
- package/dist/guide.js +3 -0
- package/dist/guide.svelte.d.ts +8 -0
- package/dist/guide.svelte.js +450 -0
- package/dist/i18n.svelte.d.ts +2 -1
- package/dist/i18n.svelte.js +2 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/internal/form-context.d.ts +12 -0
- package/dist/internal/form-context.js +9 -0
- package/dist/internal/form-validation.svelte.d.ts +16 -0
- package/dist/internal/form-validation.svelte.js +76 -0
- package/dist/internal/locale.d.ts +2 -0
- package/dist/internal/locale.js +22 -0
- package/dist/messages/en-US.json +30 -1
- package/dist/messages/zh-CN.json +30 -1
- package/dist/messages.d.ts +2 -1
- package/dist/messages.js +6 -4
- package/dist/styles/guide.css +88 -0
- package/dist/types.d.ts +57 -10
- package/package.json +13 -2
- 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:
|
|
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: () =>
|
|
124
|
-
getFilterFields: () =>
|
|
125
|
-
getCreateFields: () =>
|
|
126
|
-
getEditFields: () =>
|
|
127
|
-
getDetailFields: () =>
|
|
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;
|
|
@@ -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
|
|
24
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
56
|
+
notice = 'auth_registration_pending'
|
|
52
57
|
}
|
|
53
58
|
else {
|
|
54
|
-
|
|
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 {
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
{#if
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
77
|
-
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
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={
|
|
83
|
-
oninput={(e) => {
|
|
84
|
-
placeholder={uiM('
|
|
97
|
+
value={newPassword}
|
|
98
|
+
oninput={(e) => { newPassword = e.currentTarget.value }}
|
|
99
|
+
placeholder={uiM('change_password_new_placeholder')}
|
|
85
100
|
{disabled}
|
|
86
|
-
error={errors.
|
|
87
|
-
showStrength={
|
|
101
|
+
error={errors.newPassword}
|
|
102
|
+
showStrength={showPasswordStrength}
|
|
103
|
+
minLength={minPasswordLength}
|
|
88
104
|
/>
|
|
89
|
-
</
|
|
90
|
-
{/if}
|
|
105
|
+
</FormField>
|
|
91
106
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
122
|
+
{#if errors.general}
|
|
123
|
+
<Alert variant='error'>
|
|
124
|
+
{errors.general}
|
|
125
|
+
</Alert>
|
|
126
|
+
{/if}
|
|
130
127
|
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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>
|