@h-ai/ui 0.1.0-alpha.60 → 0.1.0-alpha.62
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 +62 -1
- package/README.ja.md +62 -1
- package/README.md +63 -1
- package/auto-import.d.ts +2 -0
- package/auto-import.js +2 -0
- 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 +45 -38
- 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/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/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/messages/en-US.json +18 -1
- package/dist/messages/zh-CN.json +18 -1
- package/dist/messages.d.ts +2 -1
- package/dist/messages.js +3 -2
- package/dist/styles/guide.css +88 -0
- package/package.json +8 -2
- package/vite.js +1 -0
package/README.en.md
CHANGED
|
@@ -248,7 +248,14 @@ For `BareInput` with `type="file"`, read `event.currentTarget.files` in `onchang
|
|
|
248
248
|
| ------------ | ----------- | ----------------------------------------------- |
|
|
249
249
|
| `PageHeader` | Page header | `title`, `description`; supports `actions` slot |
|
|
250
250
|
|
|
251
|
-
### Scenes (
|
|
251
|
+
### Scenes (36)
|
|
252
|
+
|
|
253
|
+
#### Guide (2)
|
|
254
|
+
|
|
255
|
+
| Component | Description | Key props |
|
|
256
|
+
| --- | --- | --- |
|
|
257
|
+
| `GuideHost` | First-use invitations, tours and help dialog host | `guide`, `locale`, `scope`, `autoStart` |
|
|
258
|
+
| `HelpCenter` | Standalone searchable help center | `articles`, `tours`, `locale`, `selectedId`, `onstart` |
|
|
252
259
|
|
|
253
260
|
#### App (7)
|
|
254
261
|
|
|
@@ -752,8 +759,62 @@ agreements:
|
|
|
752
759
|
|
|
753
760
|
AuthPanel accepts public IAM config and actions for password/LDAP/email/SMS login, registration and recovery. config.ui supplies layout, branding and images. LoginForm supports methods, onsendotp, otpLength and resendInterval; password forms accept passwordPolicy. Network and token storage remain in actions. Known IAM authentication errors use the current UI language; custom application messages are preserved.
|
|
754
761
|
|
|
762
|
+
AuthPanel disables native form controls during SSR and enables input after client mount binds events, preventing early input from being lost during hydration. Applications need no extra readiness code. E2E tests wait for editable controls and cover delayed client scripts.
|
|
763
|
+
|
|
755
764
|
[完整 IAM 配置 / IAM configuration](../iam/README.en.md)
|
|
756
765
|
|
|
766
|
+
## Onboarding and help center
|
|
767
|
+
|
|
768
|
+
Import `createGuide`, `GuideHost` and `HelpCenter` from `@h-ai/ui/guide`. No init/close is required. The tour engine loads when a tour starts; controls follow the current theme and include Chinese/English translations. Business text uses application i18n callbacks.
|
|
769
|
+
|
|
770
|
+
The same entry exports `resolveGuideText(text?)` to evaluate strings or i18n callbacks; host components resolve text automatically.
|
|
771
|
+
|
|
772
|
+
Create the controller inside a layout component, register tours with id/title/steps and optional route/version, then mount `<GuideHost {guide} {locale} scope={user.id} autoStart='overview' />`. Add `data-guide` to existing target components. Supply the application's NavAdapter for SPA navigation.
|
|
773
|
+
|
|
774
|
+
```svelte
|
|
775
|
+
<script lang='ts'>
|
|
776
|
+
import { createGuide, GuideHost } from '@h-ai/ui/guide'
|
|
777
|
+
|
|
778
|
+
const guide = createGuide({
|
|
779
|
+
nav,
|
|
780
|
+
persistKey: 'my-app-guide',
|
|
781
|
+
tours: [{
|
|
782
|
+
id: 'users',
|
|
783
|
+
version: 1,
|
|
784
|
+
route: '/users',
|
|
785
|
+
title: () => m.guide_users_title(),
|
|
786
|
+
description: () => m.guide_users_description(),
|
|
787
|
+
steps: [{
|
|
788
|
+
target: 'users-create',
|
|
789
|
+
title: () => m.guide_create_title(),
|
|
790
|
+
description: () => m.guide_create_description(),
|
|
791
|
+
}],
|
|
792
|
+
}],
|
|
793
|
+
articles: [{
|
|
794
|
+
id: 'users-help',
|
|
795
|
+
title: () => m.guide_users_title(),
|
|
796
|
+
content: () => m.guide_users_markdown(),
|
|
797
|
+
tourId: 'users',
|
|
798
|
+
}],
|
|
799
|
+
})
|
|
800
|
+
</script>
|
|
801
|
+
|
|
802
|
+
<GuideHost {guide} {locale} scope={user.id} autoStart='users' />
|
|
803
|
+
<Button onclick={() => guide.openHelp()}>{m.help()}</Button>
|
|
804
|
+
<!-- 在目标页面为现有按钮添加锚点,无需额外包装。 -->
|
|
805
|
+
<Button data-guide='users-create' onclick={createUser}>{m.create_user()}</Button>
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
`start(id)` returns Promise<boolean> and always allows replay; failure returns false and sets error. `startIfNeeded(id)` returns boolean and offers a first-use invitation. `openHelp(articleId?)` opens searchable Markdown help; `stop()` cancels without marking completion; `reset(id)` clears the current user's preference.
|
|
809
|
+
|
|
810
|
+
The close button, Escape, and overlay clicks cancel pending highlighting and clear `busy` even before the first positioning frame completes. A pending `start()` resolves to false without recording completion.
|
|
811
|
+
|
|
812
|
+
Persistence supports cookie (default), localStorage and memory, with an in-instance fallback when storage is restricted. Preferences are isolated by user and version. Later delays reminders for one day; Cookie preferences expire after one year. The default full-page navigation uses a short-lived sessionStorage record to start on the destination page.
|
|
813
|
+
|
|
814
|
+
Use `() => m.key()` for localized content and pass the current locale. Steps render as plain text; help uses the existing safe Markdown renderer with raw HTML disabled. Targets wait up to 3000ms by default; before(signal) prepares tabs or drawers, optional skips nonessential missing targets, and missing required targets stop the tour. Unmounting cancels pending work. Never share controllers across SSR users.
|
|
815
|
+
|
|
816
|
+
HelpCenter can also render a standalone page using articles, optional tours, locale, selectedId and onstart. The admin-console example and CLI fullstack Web/App/Desktop include first-use invitations, help and profile tours by default. See the [complete integration guide](../cli/templates/skills/hai-ui/guide.md).
|
|
817
|
+
|
|
757
818
|
## Other exports
|
|
758
819
|
|
|
759
820
|
```ts
|
package/README.ja.md
CHANGED
|
@@ -248,7 +248,14 @@ components/
|
|
|
248
248
|
| -------------- | -------------- | ---------------------------------------------- |
|
|
249
249
|
| `PageHeader` | ページヘッダー | `title`, `description`、`actions` スロット対応 |
|
|
250
250
|
|
|
251
|
-
### Scenes:用途別コンポーネント(
|
|
251
|
+
### Scenes:用途別コンポーネント(36)
|
|
252
|
+
|
|
253
|
+
#### Guide:案内・ヘルプ(2)
|
|
254
|
+
|
|
255
|
+
| コンポーネント | 説明 | 主なプロパティ |
|
|
256
|
+
| --- | --- | --- |
|
|
257
|
+
| `GuideHost` | 初回案内・ガイド・ヘルプダイアログ | `guide`, `locale`, `scope`, `autoStart` |
|
|
258
|
+
| `HelpCenter` | 独立して使える検索付きヘルプ | `articles`, `tours`, `locale`, `selectedId`, `onstart` |
|
|
252
259
|
|
|
253
260
|
#### App:アプリ全体(7)
|
|
254
261
|
|
|
@@ -752,8 +759,62 @@ agreements:
|
|
|
752
759
|
|
|
753
760
|
AuthPanel に IAM 公開設定と actions を渡すと、複数方式のログイン、登録、復旧、規約を利用できます。config.ui でレイアウトとブランド画像を指定し、パスワードフォームは passwordPolicy を受け取ります。通信と Token 保存は actions の責務です。既知の IAM 認証エラーは現在の UI 言語で表示し、アプリ独自のメッセージはそのまま保持します。
|
|
754
761
|
|
|
762
|
+
AuthPanel は SSR 中のフォーム操作を無効にし、クライアントのマウントとイベント登録が完了してから入力を有効にします。ハイドレーションによる入力の消失を防ぎ、アプリ側の追加の準備処理は不要です。E2E では入力可能な状態を待ち、クライアントスクリプトの読み込みが遅れる場合も検証します。
|
|
763
|
+
|
|
755
764
|
[完整 IAM 配置 / IAM configuration](../iam/README.ja.md)
|
|
756
765
|
|
|
766
|
+
## 初回ガイドとヘルプセンター
|
|
767
|
+
|
|
768
|
+
`@h-ai/ui/guide` から `createGuide`、`GuideHost`、`HelpCenter` をインポートします。init/close は不要です。ガイド開始時にエンジンを読み込み、既存テーマと中国語・英語の共通文言を使用します。業務内容はアプリの翻訳関数を使います。
|
|
769
|
+
|
|
770
|
+
同じ入口の `resolveGuideText(text?)` は文字列や i18n コールバックを評価します。ホストコンポーネントは文言を自動で解析します。
|
|
771
|
+
|
|
772
|
+
レイアウト内でコントローラーを作成し、id/title/steps と任意の route/version を登録します。`<GuideHost {guide} {locale} scope={user.id} autoStart='overview' />` を配置し、対象コンポーネントに data-guide を付けます。SPA は既存の NavAdapter を提供してください。
|
|
773
|
+
|
|
774
|
+
```svelte
|
|
775
|
+
<script lang='ts'>
|
|
776
|
+
import { createGuide, GuideHost } from '@h-ai/ui/guide'
|
|
777
|
+
|
|
778
|
+
const guide = createGuide({
|
|
779
|
+
nav,
|
|
780
|
+
persistKey: 'my-app-guide',
|
|
781
|
+
tours: [{
|
|
782
|
+
id: 'users',
|
|
783
|
+
version: 1,
|
|
784
|
+
route: '/users',
|
|
785
|
+
title: () => m.guide_users_title(),
|
|
786
|
+
description: () => m.guide_users_description(),
|
|
787
|
+
steps: [{
|
|
788
|
+
target: 'users-create',
|
|
789
|
+
title: () => m.guide_create_title(),
|
|
790
|
+
description: () => m.guide_create_description(),
|
|
791
|
+
}],
|
|
792
|
+
}],
|
|
793
|
+
articles: [{
|
|
794
|
+
id: 'users-help',
|
|
795
|
+
title: () => m.guide_users_title(),
|
|
796
|
+
content: () => m.guide_users_markdown(),
|
|
797
|
+
tourId: 'users',
|
|
798
|
+
}],
|
|
799
|
+
})
|
|
800
|
+
</script>
|
|
801
|
+
|
|
802
|
+
<GuideHost {guide} {locale} scope={user.id} autoStart='users' />
|
|
803
|
+
<Button onclick={() => guide.openHelp()}>{m.help()}</Button>
|
|
804
|
+
<!-- 在目标页面为现有按钮添加锚点,无需额外包装。 -->
|
|
805
|
+
<Button data-guide='users-create' onclick={createUser}>{m.create_user()}</Button>
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
start(id) は Promise<boolean> を返し、手動で再表示できます。失敗時は false と error を設定します。startIfNeeded(id) は初回案内の有無を boolean で返します。openHelp(articleId?) は検索付き Markdown ヘルプを開き、stop() は完了として記録せず終了し、reset(id) は現在のユーザーの状態を削除します。
|
|
809
|
+
|
|
810
|
+
閉じるボタン、Escape、オーバーレイのクリックは、最初の位置決めフレームが完了する前でも待機と `busy` 状態を解除します。開始中の `start()` は false を返し、完了として記録しません。
|
|
811
|
+
|
|
812
|
+
保存先は cookie(既定)、localStorage、memory です。保存が制限された環境ではインスタンス内のメモリーに戻ります。ユーザーとバージョンごとに状態を分け、後で表示は一日延期、Cookie は一年保存します。既定のページ遷移は短期 sessionStorage 記録で移動先から開始します。
|
|
813
|
+
|
|
814
|
+
文言は `() => m.key()` で遅延評価し、現在の locale を渡します。説明はプレーンテキスト、ヘルプは安全な MarkdownRenderer を使用し、生 HTML を無効にします。要素の待機は既定で 3000ms。before(signal) でタブ等を準備し、optional の要素のみ省略できます。必要な要素がなければ終了し、アンマウント時に処理を解除します。SSR ユーザー間でコントローラーを共有しないでください。
|
|
815
|
+
|
|
816
|
+
HelpCenter は articles、任意の tours、locale、selectedId、onstart で独立ページにも配置できます。admin-console と CLI fullstack Web/App/Desktop は初回案内、ヘルプ、プロフィールガイドを標準搭載します。[詳細ガイド](../cli/templates/skills/hai-ui/guide.md)を参照してください。
|
|
817
|
+
|
|
757
818
|
## その他のエクスポート
|
|
758
819
|
|
|
759
820
|
```ts
|
package/README.md
CHANGED
|
@@ -248,7 +248,14 @@ components/
|
|
|
248
248
|
| ------------ | -------- | ------------------------------------------- |
|
|
249
249
|
| `PageHeader` | 页面头部 | `title`, `description`,支持 `actions` 插槽 |
|
|
250
250
|
|
|
251
|
-
### 场景组件 Scenes(
|
|
251
|
+
### 场景组件 Scenes(36 个)
|
|
252
|
+
|
|
253
|
+
#### Guide 引导与帮助(2 个)
|
|
254
|
+
|
|
255
|
+
| 组件 | 描述 | 主要属性 |
|
|
256
|
+
| --- | --- | --- |
|
|
257
|
+
| `GuideHost` | 首次邀请、页面导览与帮助弹窗宿主 | `guide`, `locale`, `scope`, `autoStart` |
|
|
258
|
+
| `HelpCenter` | 可独立使用的帮助中心 | `articles`, `tours`, `locale`, `selectedId`, `onstart` |
|
|
252
259
|
|
|
253
260
|
#### App 应用级(7 个)
|
|
254
261
|
|
|
@@ -711,6 +718,8 @@ agreements:
|
|
|
711
718
|
|
|
712
719
|
AuthPanel 直接接收 IAM 白名单配置和 actions,内置登录(密码/LDAP/邮件/短信)、注册、找回、重置及协议展示。使用 `config.ui` 配置卡片/分栏、标题、品牌、Logo 和配图。`LoginForm` 提供 methods、onsendotp、otpLength、resendInterval;密码表单接受 passwordPolicy。组件只渲染与交互,网络和 Token 存储通过 actions 注入。认证场景按公开 IAM 错误码显示当前 UI 语言的提示,应用自定义错误保留原文。
|
|
713
720
|
|
|
721
|
+
AuthPanel 在 SSR 阶段禁用原生表单控件,客户端挂载并绑定事件后自动开放输入,避免接管时丢失早到的输入。应用无需额外的就绪适配;E2E 等待控件可编辑,并覆盖客户端脚本延迟加载的场景。
|
|
722
|
+
|
|
714
723
|
[完整 IAM 配置 / IAM configuration](../iam/README.md)
|
|
715
724
|
|
|
716
725
|
### createLocaleStore
|
|
@@ -771,6 +780,59 @@ import {
|
|
|
771
780
|
上例的 subscribe 为应用自身的 $state,应用包装 register 动作校验/提交扩展业务数据;如需单独组合表单,使用 `<RegisterForm> {#snippet extraFields()}...{/snippet} </RegisterForm>`。扩展里的按钮须为 `type="button"`,避免误提交。
|
|
772
781
|
|
|
773
782
|
|
|
783
|
+
## 新手引导与帮助中心
|
|
784
|
+
|
|
785
|
+
`@h-ai/ui/guide` 提供 `createGuide`、`GuideHost`、`HelpCenter` 和相关类型。无需 init/close;引擎在开始导览时按需加载,浮层复用当前主题。组件内置中英文按钮、进度和错误提示,业务内容使用应用 i18n。
|
|
786
|
+
|
|
787
|
+
同一入口的 `resolveGuideText(text?)` 可解析字符串或 i18n 回调;宿主组件已自动解析文案。
|
|
788
|
+
|
|
789
|
+
下例放在应用布局中;`nav` 是应用现有 NavAdapter,`locale`、`user` 与翻译函数 `m` 来自应用,翻译 key 需在应用消息中定义。目标按钮放在 `/users` 页面。
|
|
790
|
+
|
|
791
|
+
```svelte
|
|
792
|
+
<script lang='ts'>
|
|
793
|
+
import { createGuide, GuideHost } from '@h-ai/ui/guide'
|
|
794
|
+
|
|
795
|
+
const guide = createGuide({
|
|
796
|
+
nav,
|
|
797
|
+
persistKey: 'my-app-guide',
|
|
798
|
+
tours: [{
|
|
799
|
+
id: 'users',
|
|
800
|
+
version: 1,
|
|
801
|
+
route: '/users',
|
|
802
|
+
title: () => m.guide_users_title(),
|
|
803
|
+
description: () => m.guide_users_description(),
|
|
804
|
+
steps: [{
|
|
805
|
+
target: 'users-create',
|
|
806
|
+
title: () => m.guide_create_title(),
|
|
807
|
+
description: () => m.guide_create_description(),
|
|
808
|
+
}],
|
|
809
|
+
}],
|
|
810
|
+
articles: [{
|
|
811
|
+
id: 'users-help',
|
|
812
|
+
title: () => m.guide_users_title(),
|
|
813
|
+
content: () => m.guide_users_markdown(),
|
|
814
|
+
tourId: 'users',
|
|
815
|
+
}],
|
|
816
|
+
})
|
|
817
|
+
</script>
|
|
818
|
+
|
|
819
|
+
<GuideHost {guide} {locale} scope={user.id} autoStart='users' />
|
|
820
|
+
<Button onclick={() => guide.openHelp()}>{m.help()}</Button>
|
|
821
|
+
<!-- 在目标页面为现有按钮添加锚点,无需额外包装。 -->
|
|
822
|
+
<Button data-guide='users-create' onclick={createUser}>{m.create_user()}</Button>
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
- `start(id)` 返回 `Promise<boolean>`,允许手动重看;失败返回 false 并设置 `error`。`startIfNeeded(id)` 返回 boolean 并显示首次邀请,通常由 autoStart 处理。
|
|
826
|
+
- `openHelp(articleId?)` 打开帮助,`stop()` 取消导览,`reset(id)` 删除当前用户的偏好。完成、稍后和不再提醒分别记录;稍后暂缓一天,Cookie 默认保存一年。
|
|
827
|
+
- 关闭按钮、Escape 和遮罩点击在首帧定位期间也会取消等待、清除 `busy`;启动中的 `start()` 返回 false,不记录完成。
|
|
828
|
+
- `persistence` 支持 cookie(默认)、localStorage、memory;受限存储回退到实例内存。scope 使用非敏感用户标识,version 变化会重新提示。
|
|
829
|
+
- 通过已有 NavAdapter 接入 SvelteKit、SPA 或原生 WebView;默认完整导航使用 sessionStorage 保存短期启动记录。宿主只在浏览器挂载后读取记录,SSR 实例不得跨用户共享。
|
|
830
|
+
- 目标等待默认 3000ms,可用 waitTimeout 调整。before(signal) 可展开页签/抽屉;必要元素缺失停止,可选步骤使用 optional 跳过。引导不会自动提交业务表单。
|
|
831
|
+
- 业务文案用函数延迟求值,并传入当前 locale。步骤内容按纯文本显示;帮助正文复用安全 MarkdownRenderer,原始 HTML 关闭。
|
|
832
|
+
- HelpCenter 可独立放入帮助页面,props 为 articles、tours(可选)、locale、selectedId 和 onstart;支持类别标签、标题/正文搜索和关联导览。
|
|
833
|
+
|
|
834
|
+
admin-console 示例和 CLI fullstack Web/App/Desktop 默认包含帮助入口、首次邀请及个人资料导览。完整配置和验证场景见 [引导指南](../cli/templates/skills/hai-ui/guide.md)。
|
|
835
|
+
|
|
774
836
|
## 其他导出
|
|
775
837
|
|
|
776
838
|
```ts
|
package/auto-import.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ type AutoImportHaiUiPreprocessor = {
|
|
|
16
16
|
export function autoImportHaiUi(): AutoImportHaiUiPreprocessor
|
|
17
17
|
|
|
18
18
|
declare global {
|
|
19
|
+
const GuideHost: typeof import('@h-ai/ui')['GuideHost']
|
|
20
|
+
const HelpCenter: typeof import('@h-ai/ui')['HelpCenter']
|
|
19
21
|
// primitives
|
|
20
22
|
const Avatar: typeof import('@h-ai/ui')['Avatar']
|
|
21
23
|
const Badge: typeof import('@h-ai/ui')['Badge']
|
package/auto-import.js
CHANGED
|
@@ -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;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<script lang='ts'>
|
|
3
3
|
import type { HaiResult } from '@h-ai/core'
|
|
4
4
|
import type { AuthLoginMethod, AuthPanelMode, AuthPanelProps } from '../types.js'
|
|
5
|
-
import { untrack } from 'svelte'
|
|
5
|
+
import { onMount, untrack } from 'svelte'
|
|
6
6
|
import { uiM } from '../../../messages.js'
|
|
7
7
|
import { getDataAttributes } from '../../../utils.js'
|
|
8
8
|
import Alert from '../../compounds/Alert.svelte'
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
let loading = $state(false)
|
|
23
23
|
let errors = $state<Record<string, string>>({})
|
|
24
24
|
let notice = $state('')
|
|
25
|
+
// SSR 表单先显示但不可编辑;客户端绑定事件后开放输入,避免接管时丢失早到的输入。
|
|
26
|
+
let interactive = $state(false)
|
|
27
|
+
onMount(() => {
|
|
28
|
+
interactive = true
|
|
29
|
+
})
|
|
25
30
|
const methods = $derived(config ? (['password', 'ldap', 'email', 'sms'] as AuthLoginMethod[]).filter(method => config.login[method]) : [])
|
|
26
31
|
const title = $derived(mode === 'register' ? uiM('register_title') : mode === 'forgot' ? uiM('forgot_password_title') : mode === 'reset' ? uiM('reset_password_title') : uiM('login_title'))
|
|
27
32
|
const available = $derived(config && (mode === 'login' || (mode === 'register' ? config.register.enabled : config.passwordReset.enabled)))
|
|
@@ -74,43 +79,45 @@
|
|
|
74
79
|
{/snippet}
|
|
75
80
|
|
|
76
81
|
<AuthShell {...dataAttributes} {...config?.ui} title={config?.ui.title || title} brandTitle={config?.ui.brandTitle || uiM('auth_brand')}>
|
|
77
|
-
{
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
{:else}
|
|
86
|
-
{#if notice}<Alert variant='success' class='mb-4'>{notice}</Alert>{/if}
|
|
87
|
-
{#if mode === 'login'}
|
|
88
|
-
<LoginForm {methods} {loading} {errors} header={loginHeader} extraFields={loginFields} showRememberMe={false} showForgotPassword={config.passwordReset.enabled} {forgotPasswordUrl}
|
|
89
|
-
onforgotpassword={forgotPasswordUrl ? undefined : () => switchMode('forgot')}
|
|
90
|
-
otpLength={config.otp.length} resendInterval={config.otp.resendInterval} onsendotp={actions.sendOtp}
|
|
91
|
-
agreements={config.agreements.showOnLogin ? config.agreements : undefined}
|
|
92
|
-
onsubmit={data => run(() => actions.login({ identifier: data.username, password: data.password, method: data.method, code: data.code }), true)}>
|
|
93
|
-
{#snippet footer()}
|
|
94
|
-
{#if loginFooter}{@render loginFooter()}{/if}
|
|
95
|
-
{#if config?.register.enabled}<div class='text-center text-sm'>{uiM('login_no_account')} {@render link('register', uiM('login_register_now'), registerUrl)}</div>{/if}
|
|
96
|
-
{/snippet}
|
|
97
|
-
</LoginForm>
|
|
98
|
-
{:else if mode === 'register'}
|
|
99
|
-
<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}
|
|
100
|
-
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)}>
|
|
101
|
-
{#snippet footer()}
|
|
102
|
-
{#if registerFooter}{@render registerFooter()}{/if}
|
|
103
|
-
<div class='text-center'>{@render link('login', uiM('register_login_now'), loginUrl)}</div>
|
|
104
|
-
{/snippet}
|
|
105
|
-
</RegisterForm>
|
|
106
|
-
{:else if mode === 'forgot'}
|
|
107
|
-
<ForgotPasswordForm {loading} {errors} mode={config.passwordReset.mode ?? 'email'} onsubmit={data => run(() => actions.requestPasswordReset(data.email || data.phone || ''), false, uiM('auth_reset_sent'))}>
|
|
108
|
-
{#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}
|
|
109
|
-
</ForgotPasswordForm>
|
|
110
|
-
{:else}
|
|
111
|
-
<ResetPasswordForm {loading} {errors} showCode={!resetToken} passwordPolicy={config.password} minPasswordLength={config.password.minLength}
|
|
112
|
-
onsubmit={data => run(() => actions.confirmPasswordReset(resetToken || data.code || '', data.newPassword), false, uiM('auth_reset_done'))} />
|
|
82
|
+
<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>
|
|
113
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'))} />
|
|
119
|
+
<div class='mt-4 text-center'>{@render link('login', uiM('login_submit'), loginUrl)}</div>
|
|
120
|
+
{/if}
|
|
114
121
|
{/if}
|
|
115
|
-
|
|
122
|
+
</fieldset>
|
|
116
123
|
</AuthShell>
|
|
@@ -10,6 +10,7 @@ export * from './charts/index.js';
|
|
|
10
10
|
export * from './crud/index.js';
|
|
11
11
|
export * from './crypto/index.js';
|
|
12
12
|
export * from './error/index.js';
|
|
13
|
+
export * from './guide/index.js';
|
|
13
14
|
export * from './iam/index.js';
|
|
14
15
|
export * from './storage/index.js';
|
|
15
16
|
export * from './types.js';
|