@h-ai/cli 0.1.0-alpha.61 → 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.
Files changed (67) hide show
  1. package/README.en.md +15 -1
  2. package/README.ja.md +15 -1
  3. package/README.md +15 -1
  4. package/dist/{chunk-WWG3U5ZD.js → chunk-X3RSYNBG.js} +8 -3
  5. package/dist/chunk-X3RSYNBG.js.map +1 -0
  6. package/dist/{cli-skill-templates-2KDUACT6.js → cli-skill-templates-EZFW7D43.js} +3 -3
  7. package/dist/{cli-skill-templates-2KDUACT6.js.map → cli-skill-templates-EZFW7D43.js.map} +1 -1
  8. package/dist/index.js +7 -5
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/templates/README.md +5 -4
  12. package/templates/apps/api/e2e/capture-evidence.ts.hbs +23 -0
  13. package/templates/apps/api/playwright.config.ts.hbs +1 -0
  14. package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +123 -4
  15. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +2 -0
  16. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +1 -21
  17. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +1 -1
  18. package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +7 -1
  19. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/en-US.json.hbs +19 -1
  20. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/zh-CN.json.hbs +19 -1
  21. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +7 -2
  22. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +5 -3
  23. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ProfileSettings.svelte.hbs +3 -3
  24. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/guide-store.ts.hbs +55 -0
  25. package/templates/apps/fullstack/playwright.config.ts.hbs +21 -10
  26. package/templates/base/e2e/app.spec.ts.hbs +10 -6
  27. package/templates/base/package.json.hbs +1 -1
  28. package/templates/skills/AGENTS.md +6 -0
  29. package/templates/skills/bridges/admin/AGENTS.md +6 -0
  30. package/templates/skills/bridges/api/AGENTS.md +6 -0
  31. package/templates/skills/bridges/fullstack/AGENTS.md.hbs +6 -0
  32. package/templates/skills/bridges/h5/AGENTS.md +6 -0
  33. package/templates/skills/bridges/mobile-app/AGENTS.md +6 -0
  34. package/templates/skills/bridges/website/AGENTS.md +6 -0
  35. package/templates/skills/copilot-instructions.md +3 -9
  36. package/templates/skills/hai-app-create/SKILL.md +6 -0
  37. package/templates/skills/hai-app-create/development.md +10 -6
  38. package/templates/skills/hai-audit/SKILL.md +1 -1
  39. package/templates/skills/hai-build/SKILL.md +1 -0
  40. package/templates/skills/hai-build/reference.md +1 -1
  41. package/templates/skills/hai-ci/SKILL.md +1 -1
  42. package/templates/skills/hai-framework-sync/SKILL.md +67 -70
  43. package/templates/skills/hai-framework-sync/sync.md +49 -0
  44. package/templates/skills/hai-iam/SKILL.md +5 -0
  45. package/templates/skills/hai-iam/reference.md +88 -7
  46. package/templates/skills/hai-pr-review/SKILL.md +1 -1
  47. package/templates/skills/hai-reach/SKILL.md +1 -1
  48. package/templates/skills/hai-reach/reference.md +4 -5
  49. package/templates/skills/hai-serv/SKILL.md +1 -1
  50. package/templates/skills/hai-serv/reference.md +9 -6
  51. package/templates/skills/hai-spec-implement/SKILL.md +41 -0
  52. package/templates/skills/hai-spec-implement/templates.md +90 -0
  53. package/templates/skills/hai-spec-implement/workflow.md +53 -0
  54. package/templates/skills/hai-spec-review/SKILL.md +44 -0
  55. package/templates/skills/hai-spec-review/checklist.md +70 -0
  56. package/templates/skills/hai-storage/SKILL.md +9 -2
  57. package/templates/skills/hai-ui/SKILL.md +3 -1
  58. package/templates/skills/hai-ui/guide.md +79 -0
  59. package/templates/skills/hai-ui/reference.md +11 -0
  60. package/templates/skills/opencode.json +1 -1
  61. package/dist/chunk-WWG3U5ZD.js.map +0 -1
  62. package/templates/skills/bridges/admin/copilot-instructions.md +0 -11
  63. package/templates/skills/bridges/api/copilot-instructions.md +0 -13
  64. package/templates/skills/bridges/fullstack/copilot-instructions.md.hbs +0 -19
  65. package/templates/skills/bridges/h5/copilot-instructions.md +0 -11
  66. package/templates/skills/bridges/mobile-app/copilot-instructions.md +0 -11
  67. package/templates/skills/bridges/website/copilot-instructions.md +0 -11
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@h-ai/cli",
3
3
  "type": "module",
4
- "version": "0.1.0-alpha.61",
4
+ "version": "0.1.0-alpha.62",
5
5
  "description": "Hai Framework CLI for project scaffolding and code generation.",
6
6
  "license": "Apache-2.0",
7
7
  "keywords": [
@@ -30,7 +30,7 @@
30
30
  "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
31
31
  },
32
32
  "dependencies": {
33
- "@h-ai/core": "0.1.0-alpha.61",
33
+ "@h-ai/core": "0.1.0-alpha.62",
34
34
  "cac": "^7.0.1",
35
35
  "chalk": "^6.0.1",
36
36
  "fast-wrap-ansi": "^0.2.2",
@@ -41,11 +41,11 @@
41
41
  "yaml": "^2.9.1"
42
42
  },
43
43
  "devDependencies": {
44
- "@h-ai/cache": "0.1.0-alpha.61",
45
- "@h-ai/deploy": "0.1.0-alpha.61",
46
- "@h-ai/reach": "0.1.0-alpha.61",
47
- "@h-ai/reldb": "0.1.0-alpha.61",
48
- "@h-ai/storage": "0.1.0-alpha.61",
44
+ "@h-ai/cache": "0.1.0-alpha.62",
45
+ "@h-ai/deploy": "0.1.0-alpha.62",
46
+ "@h-ai/reach": "0.1.0-alpha.62",
47
+ "@h-ai/reldb": "0.1.0-alpha.62",
48
+ "@h-ai/storage": "0.1.0-alpha.62",
49
49
  "@types/fs-extra": "^11.0.4",
50
50
  "@types/node": "^26.6.5",
51
51
  "@types/prompts": "^2.4.9",
@@ -57,7 +57,7 @@ templates/
57
57
  │ └── settings.json # Paraglide 项目配置
58
58
  │
59
59
  └── skills/ # AI Skill 与各 appType 桥接指引
60
- └── bridges/ # admin / api / website / h5 / mobile-app / fullstack 专属 AGENTS/Copilot
60
+ └── bridges/ # admin / api / website / h5 / mobile-app / fullstack 专属 AGENTS
61
61
  ```
62
62
 
63
63
  ## AI Skill 模板(Single Source of Truth)
@@ -65,11 +65,12 @@ templates/
65
65
  `templates/skills/` 是 CLI 生成共享 AI 上下文时的单一来源,同时用于 `hai create` 与 `hai add`。
66
66
 
67
67
  - `hai create` 会把 `templates/skills/` 下所有 `hai-*` Skill 目录复制到 `.agents/skills/<skill>/`,再按 appType 排除互斥项(例如 fullstack 不复制 `hai-kit`;admin / website / h5 不复制 `hai-serv`、`hai-api-contract`、`hai-api-client`、`hai-capacitor`;mobile-app 不复制 `hai-core`、`hai-kit`、`hai-serv`、`hai-api-contract`)
68
- - `bridges/<appType>/copilot-instructions.md` 会复制到 `.github/copilot-instructions.md`;没有专属文件时回退到通用桥接文件
68
+ - 通用 `copilot-instructions.md` 复制到 `.github/copilot-instructions.md`,只引用项目根 `AGENTS.md`,不另维护 appType 专属规则
69
69
  - `bridges/<appType>/AGENTS.md` 会复制到项目根目录,确保不同样板工程使用自己的 AI 指引;通用 `CLAUDE.md` 仅包含 `@AGENTS.md`
70
70
  - `opencode.json` 会复制到项目根目录
71
- - `templates/skills/AGENTS.md`、`CLAUDE.md`、`copilot-instructions.md` 与 `opencode.json` 不是多余副本:它们是 generic fallback,主要服务于缺少 appType 上下文时的桥接复制与 `hai add` 回填 AI 支持
72
- - `opencode.json` 使用 OpenCode 的 `instructions` 配置复用 `.github/copilot-instructions.md`;`.agents/skills/` 由 OpenCode 原生发现,不再写入过期的 `skills.paths`
71
+ - `templates/skills/AGENTS.md` 是缺少 appType 上下文时的通用规则;`CLAUDE.md`、`copilot-instructions.md` 与 `opencode.json` 是各工具的轻量入口,用于创建项目和回填 AI 支持
72
+ - Codex 原生读取根 `AGENTS.md` 并发现 `.agents/skills/`,支持 `$hai-app-create` 等显式调用和按任务自动选择;无需 `.codex/skills` 或工具专属配置。依据:[Codex skills](https://learn.chatgpt.com/docs/build-skills)、[AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md)
73
+ - `opencode.json` 使用 OpenCode 的 `instructions` 配置直接引用 `AGENTS.md`;`.agents/skills/` 由 OpenCode 原生发现,不再写入过期的 `skills.paths`
73
74
  - `CLAUDE.md` 通过 `@AGENTS.md` 复用共享规范,Claude Code 按其中的任务导航读取 `.agents/skills/` 正文与参考。CLI 不生成 `.claude/` 技能副本;此模式不提供 Claude 原生技能自动发现或 `/skill` 命令。依据:[Claude Code 项目指引](https://code.claude.com/docs/en/memory)、[原生 skills](https://code.claude.com/docs/en/skills)
74
75
  - `hai add` / `hai init` 只补齐缺失的 AI 支持文件,不覆盖用户已自定义的桥接文件,也不会自动删除遗留的 `.github/skills/` 或 `.claude/`
75
76
 
@@ -0,0 +1,23 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import process from 'node:process'
4
+ import { chromium, expect } from '@playwright/test'
5
+
6
+ /** 仅在指定证据目录时采集健康端点截图,常规 API 测试通过 HTTP 请求完成。 */
7
+ export default async function captureEvidence() {
8
+ const evidence = process.env.HAI_AUTH_EVIDENCE_DIR
9
+ if (!evidence)
10
+ return
11
+ const browser = await chromium.launch({ channel: 'chrome' })
12
+ try {
13
+ const page = await browser.newPage()
14
+ const response = await page.goto(new URL('/health', process.env.BASE_URL || 'http://127.0.0.1:3000').href)
15
+ expect(response?.status()).toBe(200)
16
+ await expect(page.locator('body')).toContainText('ok')
17
+ await mkdir(evidence, { recursive: true })
18
+ await page.screenshot({ path: path.join(evidence, '{{projectName}}-health.png'), fullPage: true })
19
+ }
20
+ finally {
21
+ await browser.close()
22
+ }
23
+ }
@@ -32,6 +32,7 @@ function resolvePortFromBaseUrl(urlString: string, fallbackPort: string): string
32
32
 
33
33
  export default defineConfig({
34
34
  testDir: './e2e',
35
+ globalSetup: './e2e/capture-evidence.ts',
35
36
  fullyParallel: false,
36
37
  forbidOnly: !!process.env.CI,
37
38
  retries: process.env.CI ? 2 : 0,
@@ -18,13 +18,29 @@ const configuredEnv = process.env.HAI_CORE_ENV || 'test'
18
18
  // 测试专用的一像素 PNG;上传仍使用完整前后端加密链路。
19
19
  const avatar = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aSxkAAAAASUVORK5CYII=', 'base64')
20
20
 
21
- async function login(page: Page, username: string, password: string) {
21
+ async function capture(page: Page, name: string, fallback: string) {
22
+ const evidence = process.env.HAI_AUTH_EVIDENCE_DIR
23
+ if (evidence)
24
+ await mkdir(evidence, { recursive: true })
25
+ await expect(page.getByTestId('guide-loading')).toHaveCount(0)
26
+ await page.evaluate(() => document.fonts.ready)
27
+ await page.screenshot({ path: evidence ? path.join(evidence, name) : fallback, fullPage: true, animations: 'disabled' })
28
+ }
29
+
30
+ async function submitLogin(page: Page, username: string, password: string) {
22
31
  await expect(page.getByTestId('login-form')).toBeVisible()
23
32
  await page.locator('#login-username').fill(username)
24
33
  await page.getByTestId('login-form').locator('input[type="password"]').fill(password)
25
34
  await page.getByTestId('login-form').getByRole('button', { name: /登录|Sign in|Login|Log in/i }).click()
26
35
  }
27
36
 
37
+ async function login(page: Page, username: string, password: string, keepGuide = false) {
38
+ await submitLogin(page, username, password)
39
+ await expect(page.getByTestId('logout')).toBeVisible()
40
+ if (!keepGuide && await page.getByTestId('guide-later').isVisible())
41
+ await page.getByTestId('guide-later').click()
42
+ }
43
+
28
44
  async function userEditor(page: Page, username: string) {
29
45
  await page.getByTestId('users-search').locator('input').fill(username)
30
46
  await page.getByTestId('users-page').getByRole('button', { name: /查询|Search/ }).click()
@@ -45,6 +61,95 @@ test('service rejects plaintext business requests', async ({ request }) => {
45
61
  })
46
62
 
47
63
  {{#if fullstack.e2eFrontend}}
64
+ test('first-use tour, help search and settings navigation work on desktop and mobile', async ({ page }, testInfo) => {
65
+ await page.goto('/')
66
+ await login(page, 'admin', adminPassword, true)
67
+ await expect(page.getByTestId('guide-offer')).toBeVisible()
68
+ await page.getByTestId('guide-start').click()
69
+ const popover = page.locator('.hai-guide-popover')
70
+ await expect(popover).toBeVisible()
71
+ await capture(page, '{{projectName}}-guide-desktop.png', testInfo.outputPath('guide-desktop.png'))
72
+ for (let step = 1; step < 3; step++) {
73
+ await popover.locator('.driver-popover-next-btn').click()
74
+ await expect(popover.locator('.driver-popover-progress-text')).toContainText(String(step + 1))
75
+ }
76
+ await popover.locator('.driver-popover-next-btn').click()
77
+ await expect(popover).toHaveCount(0)
78
+ await page.reload()
79
+ await expect(page.getByTestId('admin-help')).toBeVisible()
80
+ await expect(page.getByTestId('guide-offer')).toHaveCount(0)
81
+ await page.getByTestId('admin-help').click()
82
+ const profileTitle = (await page.getByTestId('guide-article-profile').textContent())!.trim()
83
+ await page.getByTestId('guide-search').locator('input').fill(profileTitle)
84
+ await expect(page.getByTestId('guide-article-profile')).toBeVisible()
85
+ await expect(page.getByTestId('guide-article-overview')).toHaveCount(0)
86
+ await page.getByTestId('guide-article-start').click()
87
+ await expect(page.getByTestId('settings-page')).toBeVisible()
88
+ await expect(popover).toBeVisible()
89
+ await page.keyboard.press('Escape')
90
+ await expect(popover).toHaveCount(0)
91
+ await page.setViewportSize({ width: 390, height: 844 })
92
+ await page.getByTestId('admin-help').click()
93
+ await expect(page.getByTestId('help-center')).toBeVisible()
94
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
95
+ await capture(page, '{{projectName}}-guide-help-mobile.png', testInfo.outputPath('guide-help-mobile.png'))
96
+ await page.getByTestId('guide-tour-overview').click()
97
+ await expect(popover.locator('.driver-popover-next-btn')).toBeEnabled()
98
+ // 下一步未渲染时仍能通过宿主提示取消,不能被引擎遮罩阻止。
99
+ await page.locator('[data-guide="admin-profile"]').evaluate(element => element.removeAttribute('data-guide'))
100
+ await popover.locator('.driver-popover-next-btn').click()
101
+ await expect(page.getByTestId('guide-loading')).toBeVisible()
102
+ await page.getByTestId('guide-cancel').click()
103
+ await expect(popover).toHaveCount(0)
104
+ await expect(page.getByTestId('guide-error')).toHaveCount(0)
105
+ })
106
+
107
+ // 所有已生成的浏览器前端都验证共享引导,不能用 Web 的成功代替 App/Desktop 预览。
108
+ const additionalFrontends = [
109
+ {{#each fullstack.frontendApps}}
110
+ {
111
+ target: '{{target}}',
112
+ url: process.env.HAI_E2E_{{target}}_URL || 'http://127.0.0.1:{{port}}',
113
+ },
114
+ {{/each}}
115
+ ].filter(frontend => frontend.target !== '{{fullstack.e2eFrontend.target}}')
116
+
117
+ for (const frontend of additionalFrontends) {
118
+ test(`${frontend.target} preview supports login, tour, help and profile navigation`, async ({ browser }, testInfo) => {
119
+ const page = await browser.newPage({ viewport: frontend.target === 'app' ? { width: 390, height: 844 } : { width: 1440, height: 960 } })
120
+ const pageErrors: string[] = []
121
+ page.on('pageerror', error => pageErrors.push(error.message))
122
+ try {
123
+ await page.goto(frontend.url)
124
+ await expect(page.getByTestId('login-form')).toBeVisible()
125
+ await capture(page, `{{projectName}}-${frontend.target}-login.png`, testInfo.outputPath(`${frontend.target}-login.png`))
126
+ await login(page, 'admin', adminPassword, true)
127
+ await expect(page.getByTestId('guide-offer')).toBeVisible()
128
+ await page.getByTestId('guide-start').click()
129
+ const popover = page.locator('.hai-guide-popover')
130
+ await expect(popover).toBeVisible()
131
+ await capture(page, `{{projectName}}-${frontend.target}-guide.png`, testInfo.outputPath(`${frontend.target}-guide.png`))
132
+ await page.keyboard.press('Escape')
133
+ await expect(popover).toHaveCount(0)
134
+ await page.getByTestId('admin-help').click()
135
+ await expect(page.getByTestId('help-center')).toBeVisible()
136
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
137
+ await capture(page, `{{projectName}}-${frontend.target}-help.png`, testInfo.outputPath(`${frontend.target}-help.png`))
138
+ await page.getByTestId('guide-article-profile').click()
139
+ await page.getByTestId('guide-article-start').click()
140
+ await expect(page.getByTestId('settings-page')).toBeVisible()
141
+ await expect(popover).toBeVisible()
142
+ await page.keyboard.press('Escape')
143
+ await expect(popover).toHaveCount(0)
144
+ await capture(page, `{{projectName}}-${frontend.target}-profile.png`, testInfo.outputPath(`${frontend.target}-profile.png`))
145
+ expect(pageErrors).toEqual([])
146
+ }
147
+ finally {
148
+ await page.close()
149
+ }
150
+ })
151
+ }
152
+
48
153
  test('shared authentication supports public registration with configuration', async ({ page }) => {
49
154
  test.setTimeout(90_000)
50
155
  const evidence = process.env.HAI_AUTH_EVIDENCE_DIR || path.resolve('test-results/auth-screenshots')
@@ -106,7 +211,7 @@ test('shared authentication supports public registration with configuration', as
106
211
  await expect(page.getByTestId('settings-page')).toBeVisible()
107
212
  })
108
213
 
109
- test('frontend displays configured core metadata and restores the administrator session', async ({ page }) => {
214
+ test('frontend displays configured core metadata and restores the administrator session', async ({ page }, testInfo) => {
110
215
  await page.goto('/')
111
216
  await expect(page.getByTestId('app-name')).toHaveText(configuredName)
112
217
  await expect(page).toHaveTitle(configuredName)
@@ -118,17 +223,24 @@ test('frontend displays configured core metadata and restores the administrator
118
223
  await expect(page.getByTestId('users-page')).toBeVisible()
119
224
  await expect(page.getByTestId('nav-roles')).toBeVisible()
120
225
  await expect(page.getByTestId('nav-audit')).toBeVisible()
226
+ const evidence = process.env.HAI_AUTH_EVIDENCE_DIR
227
+ if (evidence)
228
+ await mkdir(evidence, { recursive: true })
229
+ await page.screenshot({ path: evidence ? path.join(evidence, '{{projectName}}-users.png') : testInfo.outputPath('users.png'), fullPage: true })
121
230
  await page.getByTestId('logout').click()
122
231
  await expect(page.getByTestId('login-form')).toBeVisible()
123
232
  })
124
233
 
125
- test('audit filters align and fit desktop, tablet and narrow mobile screens', async ({ page }) => {
234
+ test('audit filters align and fit desktop, tablet and narrow mobile screens', async ({ page }, testInfo) => {
126
235
  await page.goto('/')
127
236
  await login(page, 'admin', adminPassword)
128
237
  await page.getByTestId('nav-audit').click()
129
238
  await expect(page.getByTestId('audit-page')).toBeVisible()
130
239
  for (const width of [1440, 960, 390, 320]) {
131
240
  await page.setViewportSize({ width, height: 900 })
241
+ // 等待移动侧栏收起,避免过渡帧遮挡内容并污染截图。
242
+ if (width < 1024)
243
+ await expect.poll(() => page.locator('aside').evaluate(sidebar => sidebar.getBoundingClientRect().right)).toBeLessThanOrEqual(1)
132
244
  const controls = await page.getByTestId('audit-page').locator('form').evaluate(form => [
133
245
  form.querySelector('#audit-action')?.parentElement,
134
246
  form.querySelector('#audit-resource')?.parentElement,
@@ -152,6 +264,13 @@ test('audit filters align and fit desktop, tablet and narrow mobile screens', as
152
264
  expect(controls[2]!.top).toBeCloseTo(controls[3]!.top, 0)
153
265
  }
154
266
  expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
267
+ if (width === 1440 || width === 390) {
268
+ const evidence = process.env.HAI_AUTH_EVIDENCE_DIR
269
+ if (evidence)
270
+ await mkdir(evidence, { recursive: true })
271
+ const file = `{{projectName}}-audit-${width}.png`
272
+ await page.screenshot({ path: evidence ? path.join(evidence, file) : testInfo.outputPath(file), fullPage: true, animations: 'disabled' })
273
+ }
155
274
  }
156
275
  })
157
276
 
@@ -252,7 +371,7 @@ test('administrator and user complete account, avatar, preference, password and
252
371
  await passwordInputs.nth(2).fill(newPassword)
253
372
  await userPage.getByTestId('password-form').getByRole('button', { name: /Change password/i }).click()
254
373
  await expect(userPage.getByTestId('login-form')).toBeVisible()
255
- await login(userPage, username, password)
374
+ await submitLogin(userPage, username, password)
256
375
  await expect(userPage.getByTestId('login-form')).toContainText(/不正确|incorrect/i)
257
376
  await login(userPage, username, newPassword)
258
377
  await expect(userPage.getByTestId('settings-page')).toBeVisible()
@@ -10,6 +10,8 @@ server:
10
10
  port: 3000
11
11
 
12
12
  http:
13
+ # 仅列出会清洗/追加转发头的代理 IP/CIDR;空数组忽略 X-Forwarded-For/X-Real-IP。
14
+ trustedProxies: []
13
15
  apiPrefix: /api/v1
14
16
  openapi:
15
17
  path: /openapi.json
@@ -1,6 +1,6 @@
1
1
  import type { CoreConfig } from '@h-ai/core'
2
2
  import type { ServHttpApp } from '@h-ai/serv'
3
- import { core, err, HaiCommonError, ok } from '@h-ai/core'
3
+ import { core } from '@h-ai/core'
4
4
  import { createReldbTransportKeyStore, crypto } from '@h-ai/crypto'
5
5
  import { iam } from '@h-ai/iam'
6
6
  import { reldb } from '@h-ai/reldb'
@@ -40,26 +40,6 @@ export function createServerApp(): ServHttpApp {
40
40
  },
41
41
  }),
42
42
  iam,
43
- verifyToken: async (token) => {
44
- const session = await iam.session.verifyToken(token)
45
- if (!session.success)
46
- return session
47
- const user = await iam.user.getUser(session.data.userId)
48
- if (!user.success)
49
- return user
50
- if (!user.data?.enabled)
51
- return err(HaiCommonError.UNAUTHORIZED, core.i18n.coreM('core_errorUnauthorized'))
52
- // 会话中的授权同步属于最大努力,业务守卫始终读取最新持久化角色与权限。
53
- const [roles, permissions] = await Promise.all([
54
- iam.authz.getUserRoles(user.data.id),
55
- iam.authz.getUserPermissions(user.data.id),
56
- ])
57
- if (!roles.success)
58
- return roles
59
- if (!permissions.success)
60
- return permissions
61
- return ok({ ...session.data, roles: roles.data.map(role => role.code), permissions: permissions.data.map(permission => permission.code) })
62
- },
63
43
  refreshCookie: {
64
44
  secure: security.secureRefreshCookie,
65
45
  nativeTokenTransport: { isRequest: request => request.headers.get('x-hai-token-transport') === 'body'
@@ -85,7 +85,7 @@ export function createAdminProcedures() {
85
85
  const result = await audit.list({ ...input, startDate: input.startDate ? new Date(input.startDate) : undefined, endDate: input.endDate ? new Date(input.endDate) : undefined })
86
86
  if (!result.success)
87
87
  return result
88
- return ok({ ...result.data, page: input.page, pageSize: input.pageSize, items: result.data.items.map(item => ({ ...item, createdAt: new Date(item.createdAt).toISOString() })) })
88
+ return ok({ ...result.data, items: result.data.items.map(item => ({ ...item, createdAt: new Date(item.createdAt).toISOString() })) })
89
89
  }).build()
90
90
  }
91
91
 
@@ -21,6 +21,12 @@ Capacitor native uses `createCapacitorTokenStorage()` and the trusted native tok
21
21
 
22
22
  Language and theme preferences are saved in IAM user metadata, restored after login and synchronized with the shared/host Paraglide runtimes and `core.i18n`. The small reactive theme store delegates DOM updates and safe appearance caching to `@h-ai/ui/theme` (`applyTheme` / `getSavedTheme`); the framework uses the `theme` localStorage key. Appearance selectors live in personal settings and save through the authenticated profile contract.
23
23
 
24
+ ## Onboarding and help
25
+
26
+ AdminWorkspace mounts the shared `@h-ai/ui/guide` controller and GuideHost after authentication. AppShell includes a help button; first-use invitations offer start, later and dismiss. Web stores preferences in Cookies, while App/Desktop use localStorage, scoped by the current user and tour version. Help articles link to the overview and profile tours.
27
+
28
+ Extend `src/lib/stores/guide-store.ts` and the shared Chinese/English messages, then add stable data-guide attributes to existing components. Navigation uses the same ManagementStore. Keep business text as i18n callbacks and preserve the host's locale/scope binding. Browser tests cover completion, reload, replay, settings navigation and mobile help.
29
+
24
30
  ## i18n and themes
25
31
 
26
32
  Shared page copy belongs in `messages/{zh-CN,en-US}.json`. Frontend-specific copy belongs in each app's own messages directory. Keep `baseLocale` and `locales` aligned across all enabled projects, then run root `pnpm i18n:compile`; do not edit generated Paraglide files.
@@ -31,7 +37,7 @@ The scaffold theme configuration determines the themes compiled into frontend CS
31
37
 
32
38
  ## Validation
33
39
 
34
- Run root `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm test` and `pnpm test:e2e`. Shared API tests exercise real transport key exchange, encrypted request/response handling, configured core metadata and credential forwarding. Root browser tests cover administrator and ordinary-user workflows, avatar rendering, persisted preferences, password/session invalidation and narrow-screen layout.
40
+ Run root `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm test` and `pnpm test:e2e`. Shared API tests exercise real transport key exchange, encrypted request/response handling, configured core metadata and credential forwarding. Root browser tests cover administrator and ordinary-user workflows, avatar rendering, persisted preferences, password/session invalidation and narrow-screen layout. All selected Web/App/Desktop previews also exercise login, tours, help and profile navigation. Set `HAI_AUTH_EVIDENCE_DIR` to keep screenshots outside the test output directory. These browser checks do not replace native-shell or miniapp device acceptance.
35
41
 
36
42
  ## IAM configuration
37
43
 
@@ -83,5 +83,23 @@
83
83
  "audit_system": "System",
84
84
  "audit_details": "Audit log details",
85
85
  "audit_date_invalid": "The end date must be on or after the start date.",
86
- "api_not_configured": "The service connection is not configured. Please contact your administrator."
86
+ "api_not_configured": "The service connection is not configured. Please contact your administrator.",
87
+ "guide_overview_title": "Explore your admin workspace",
88
+ "guide_overview_description": "A few short steps introduce the workspace, personal settings and help. Leave at any time and replay tours from the help center.",
89
+ "guide_content_title": "Workspace",
90
+ "guide_content_description": "The current page appears here. Use navigation to switch features. Available actions depend on your permissions.",
91
+ "guide_theme_title": "Appearance",
92
+ "guide_theme_description": "Switch between light and dark appearances here. More themes are available in settings.",
93
+ "guide_profile_title": "Your profile",
94
+ "guide_profile_description": "Use your avatar to access your profile and settings, including personal information and passwords.",
95
+ "guide_help_title": "Help center",
96
+ "guide_help_description": "Search instructions here whenever you need help, or replay a product tour.",
97
+ "guide_profile_tour_title": "Set up your profile",
98
+ "guide_profile_tour_description": "Find your personal information, preferences and password settings. This tour explains actions without submitting changes.",
99
+ "guide_profile_form_description": "View or edit your profile, choose an avatar and save your changes. Uploaded files must meet the format and size requirements shown on the page.",
100
+ "guide_preferences_description": "Save your preferred language and theme. The interface and help content follow your selection.",
101
+ "guide_password_title": "Change password",
102
+ "guide_password_description": "Enter your current password, new password and confirmation. Check the password rules before submitting.",
103
+ "guide_overview_article": "## Getting started\n\n- Open features through navigation. Menus and actions depend on your current permissions.\n- The workspace contains lists, details or settings. Follow the page instructions and check information before submitting.\n- Use your avatar to access your profile and settings.\n- Search instructions or replay tours through the help button.\n\n## First-use tour\n\n“Remind me later” pauses reminders for one day. “Do not remind me” saves your preference. Completed tours no longer prompt automatically and remain available for manual replay.",
104
+ "guide_profile_article": "## Profile and preferences\n\nOpen your profile to view or update personal information and your avatar. Check the file requirements, save changes and read the result message. Appearance and language can be adjusted in settings.\n\n## Password\n\nChanging a password requires the current password, new password and confirmation. Follow the displayed password rules and check the result after submitting.\n\n“Start tour” opens the relevant page. Tours never save forms or change your account automatically."
87
105
  }
@@ -83,5 +83,23 @@
83
83
  "audit_system": "系统",
84
84
  "audit_details": "审计日志详情",
85
85
  "audit_date_invalid": "结束日期不能早于开始日期。",
86
- "api_not_configured": "尚未配置服务连接,请联系应用管理员。"
86
+ "api_not_configured": "尚未配置服务连接,请联系应用管理员。",
87
+ "guide_overview_title": "快速认识管理后台",
88
+ "guide_overview_description": "用几个简短步骤了解工作区、个人设置和帮助入口。你可以随时退出,也可以从帮助中心重新查看。",
89
+ "guide_content_title": "工作区",
90
+ "guide_content_description": "这里展示当前页面的内容。通过导航菜单切换功能,页面会根据你的权限提供可用操作。",
91
+ "guide_theme_title": "外观切换",
92
+ "guide_theme_description": "点击这里快速切换亮色与暗色外观。更多主题可以在设置页面中选择。",
93
+ "guide_profile_title": "个人资料",
94
+ "guide_profile_description": "点击头像打开个人资料和设置入口,管理头像、个人信息与密码。",
95
+ "guide_help_title": "帮助中心",
96
+ "guide_help_description": "遇到问题时从这里搜索使用说明,或重新开始任意操作导览。",
97
+ "guide_profile_tour_title": "完善个人资料",
98
+ "guide_profile_tour_description": "了解个人信息、偏好和密码设置的位置。引导只说明操作,不会自动提交修改。",
99
+ "guide_profile_form_description": "在这里查看或编辑个人资料,选择头像并保存修改。上传文件需符合页面显示的格式与大小要求。",
100
+ "guide_preferences_description": "选择语言和主题后保存,界面与帮助内容会使用所选语言和外观。",
101
+ "guide_password_title": "修改密码",
102
+ "guide_password_description": "输入当前密码、新密码和确认密码,按页面提示提交。修改前请确认新密码符合密码规则。",
103
+ "guide_overview_article": "## 从哪里开始\n\n- 使用导航菜单打开需要的功能。可见菜单和操作由当前权限决定。\n- 工作区显示列表、详情或设置内容。按页面提示完成操作,提交前确认信息。\n- 点击头像访问个人资料与设置。\n- 点击帮助入口搜索说明或重新查看导览。\n\n## 首次引导\n\n选择“稍后提醒”会暂缓一天;选择“不再提醒”会保留你的选择。完成导览后不会重复提示,仍可手动重看。",
104
+ "guide_profile_article": "## 个人资料与偏好\n\n打开个人资料页面,查看或修改个人信息与头像。头像需符合页面显示的文件要求,保存后查看结果提示。外观和语言可在设置中调整。\n\n## 密码\n\n修改密码需要当前密码、新密码和确认密码。按页面显示的密码规则填写,提交后查看结果。\n\n点击“开始引导”可以直接进入对应页面。导览不会自动保存表单或修改账户。"
87
105
  }
@@ -3,10 +3,12 @@
3
3
  import { Alert, Skeleton } from '@h-ai/ui/components/compounds/index'
4
4
  import { Button } from '@h-ai/ui/components/primitives/index'
5
5
  import { AuthPanel } from '@h-ai/ui/components/scenes/iam/index'
6
- import { onMount } from 'svelte'
6
+ import { GuideHost } from '@h-ai/ui/guide'
7
+ import { onMount, untrack } from 'svelte'
7
8
  import { managementAuthActions } from '../api/api-client.js'
8
9
  import * as m from '../paraglide/messages.js'
9
10
  import { ManagementStore } from '../stores/management-store.svelte.js'
11
+ import { createManagementGuide } from '../stores/guide-store.js'
10
12
  import AppShell from './AppShell.svelte'
11
13
 
12
14
  interface Props {
@@ -16,6 +18,8 @@
16
18
  }
17
19
  const { platform, apiOptions = {}, onLocaleChange }: Props = $props()
18
20
  const store = new ManagementStore()
21
+ // 宿主平台在组件生命周期中固定,引导控制器只创建一次。
22
+ const guide = untrack(() => createManagementGuide(store, platform))
19
23
  let resetToken = $state('')
20
24
  async function authenticated() {
21
25
  await store.authenticated()
@@ -67,7 +71,7 @@
67
71
  {/if}
68
72
  </main>
69
73
  {:else}
70
- <AppShell {platform} {store}>
74
+ <AppShell {platform} {store} onhelp={() => guide.openHelp()}>
71
75
  <div class='space-y-5'>
72
76
  {#if store.error}<Alert variant='error' data-testid='error'>{store.error}</Alert>{/if}
73
77
  {#if store.notice}<Alert variant='success' data-testid='notice'>{store.notice}</Alert>{/if}
@@ -78,5 +82,6 @@
78
82
  {/await}
79
83
  </div>
80
84
  </AppShell>
85
+ <GuideHost {guide} locale={store.locale} scope={store.user.id} autoStart='overview' />
81
86
  {/if}
82
87
  {/key}
@@ -8,8 +8,9 @@
8
8
  platform: 'web' | 'app' | 'desktop' | 'miniapp'
9
9
  store: ManagementStore
10
10
  children: Snippet
11
+ onhelp: () => void
11
12
  }
12
- const { platform, store, children }: Props = $props()
13
+ const { platform, store, children, onhelp }: Props = $props()
13
14
  let menuOpen = $state(false)
14
15
  const items = $derived([
15
16
  ...(store.isAdmin
@@ -54,9 +55,10 @@
54
55
  <span class="truncate text-sm font-medium">{title}</span>
55
56
  </div>
56
57
  <div class="flex shrink-0 items-center gap-1.5 sm:gap-3">
58
+ <Button variant="ghost" size="sm" onclick={onhelp} data-guide="admin-help" data-testid="admin-help" ariaLabel={m.guide_help_title()}><span class="icon-[tabler--help-circle] size-4"></span><span class="hidden sm:inline">{m.guide_help_title()}</span></Button>
57
59
  <Badge variant="ghost" size="sm" data-testid="app-environment">{store.info?.env}</Badge>
58
60
  {#if store.user}
59
- <BareButton class="flex items-center gap-2 rounded-lg px-1 py-1 hover:bg-base-content/5 sm:px-2" ariaLabel={m.settings_title()} onclick={() => navigate('settings')}>
61
+ <BareButton class="flex items-center gap-2 rounded-lg px-1 py-1 hover:bg-base-content/5 sm:px-2" ariaLabel={m.settings_title()} onclick={() => navigate('settings')} data-guide="admin-profile">
60
62
  <Avatar name={store.user.displayName || store.user.username} src={store.user.avatarUrl} size="sm" />
61
63
  <span class="hidden max-w-36 truncate text-sm sm:block">{store.user.displayName || store.user.username}</span>
62
64
  </BareButton>
@@ -64,7 +66,7 @@
64
66
  <Button variant="ghost" size="sm" loading={store.saving} onclick={() => void store.logout()} data-testid="logout">{m.logout()}</Button>
65
67
  </div>
66
68
  </header>
67
- <main class="mx-auto w-full max-w-7xl flex-1 p-4 pb-8 sm:p-6 lg:p-8">{@render children()}</main>
69
+ <main class="mx-auto w-full max-w-7xl flex-1 p-4 pb-8 sm:p-6 lg:p-8" data-guide="admin-content">{@render children()}</main>
68
70
  <footer class="border-t border-base-content/8 px-6 py-3 text-center text-xs text-base-content/35">{store.info?.name} · v{store.info?.version}</footer>
69
71
  </div>
70
72
  </div>
@@ -32,11 +32,11 @@
32
32
  </div>
33
33
  <p class='mt-4 text-xs leading-6 text-base-content/50'>{m.avatar_hint()}</p>
34
34
  </Card>
35
- <Card title={m.profile_title()} bordered shadow={false}>
35
+ <Card title={m.profile_title()} bordered shadow={false} data-guide='admin-profile-form'>
36
36
  <UserProfile user={store.user} editable alwaysEditable fields={['avatar', 'displayName', 'email']} avatarAccept='image/png,image/jpeg,image/webp' avatarHint={m.avatar_hint()} loading={store.saving} onsubmit={data => store.saveProfile(data)} onavatarchange={file => void store.uploadAvatar(file)} data-testid='profile-form' />
37
37
  </Card>
38
38
  </div>
39
- <Card title={m.preferences_title()} bordered shadow={false}>
39
+ <Card title={m.preferences_title()} bordered shadow={false} data-guide='admin-preferences'>
40
40
  <p class='mb-5 text-sm text-base-content/50'>{m.preferences_description()}</p>
41
41
  <div class='grid gap-6 sm:grid-cols-2'>
42
42
  <div class='space-y-2'>
@@ -49,7 +49,7 @@
49
49
  </div>
50
50
  </div>
51
51
  </Card>
52
- <Card title={m.password_title()} bordered shadow={false}>
52
+ <Card title={m.password_title()} bordered shadow={false} data-guide='admin-password'>
53
53
  <p class='mb-5 text-sm text-base-content/50'>{m.password_rules()}</p>
54
54
  <div class='max-w-xl'><ChangePasswordForm loading={store.saving} passwordPolicy={store.authConfig?.password} minPasswordLength={store.authConfig?.password.minLength ?? 8} onsubmit={data => store.changePassword(data)} data-testid='password-form' /></div>
55
55
  </Card>
@@ -0,0 +1,55 @@
1
+ import type { NavAdapter } from '@h-ai/ui'
2
+ import type { ManagementStore } from './management-store.svelte.js'
3
+ import { createGuide } from '@h-ai/ui/guide'
4
+ import * as m from '../paraglide/messages.js'
5
+
6
+ /** Web/App/Desktop 共用内容;宿主只提供当前页面和平台持久化方式。 */
7
+ export function createManagementGuide(store: ManagementStore, platform: 'web' | 'app' | 'desktop') {
8
+ const nav: NavAdapter = {
9
+ get pathname() {
10
+ return `/${store.page}`
11
+ },
12
+ navigate(route) {
13
+ if (route === '/settings')
14
+ return store.navigate('settings')
15
+ },
16
+ }
17
+ return createGuide({
18
+ nav,
19
+ persistKey: '{{projectName}}-guide',
20
+ persistence: platform === 'web' ? 'cookie' : 'localStorage',
21
+ tours: [{
22
+ id: 'overview',
23
+ title: () => m.guide_overview_title(),
24
+ description: () => m.guide_overview_description(),
25
+ steps: [
26
+ { target: 'admin-content', title: () => m.guide_content_title(), description: () => m.guide_content_description() },
27
+ { target: 'admin-profile', title: () => m.guide_profile_title(), description: () => m.guide_profile_description() },
28
+ { target: 'admin-help', title: () => m.guide_help_title(), description: () => m.guide_help_description() },
29
+ ],
30
+ }, {
31
+ id: 'profile',
32
+ title: () => m.guide_profile_tour_title(),
33
+ description: () => m.guide_profile_tour_description(),
34
+ route: '/settings',
35
+ steps: [
36
+ { target: 'admin-profile-form', title: () => m.guide_profile_title(), description: () => m.guide_profile_form_description() },
37
+ { target: 'admin-preferences', title: () => m.preferences_title(), description: () => m.guide_preferences_description() },
38
+ { target: 'admin-password', title: () => m.guide_password_title(), description: () => m.guide_password_description() },
39
+ ],
40
+ }],
41
+ articles: [{
42
+ id: 'overview',
43
+ title: () => m.guide_overview_title(),
44
+ description: () => m.guide_overview_description(),
45
+ content: () => m.guide_overview_article(),
46
+ tourId: 'overview',
47
+ }, {
48
+ id: 'profile',
49
+ title: () => m.guide_profile_tour_title(),
50
+ description: () => m.guide_profile_tour_description(),
51
+ content: () => m.guide_profile_article(),
52
+ tourId: 'profile',
53
+ }],
54
+ })
55
+ }
@@ -4,8 +4,16 @@ import { defineConfig } from '@playwright/test'
4
4
  const defaultBaseURL = '{{#if fullstack.e2eFrontend}}http://127.0.0.1:{{fullstack.e2eFrontend.port}}{{else}}http://127.0.0.1:3000{{/if}}'
5
5
  const defaultServiceBaseURL = 'http://127.0.0.1:3000'
6
6
  const baseURL = process.env.BASE_URL || defaultBaseURL
7
- const frontendHost = resolveHostFromBaseUrl(baseURL, '127.0.0.1')
8
- const frontendPort = resolvePortFromBaseUrl(baseURL, '{{#if fullstack.e2eFrontend}}{{fullstack.e2eFrontend.port}}{{else}}3000{{/if}}')
7
+ const primaryFrontend = '{{fullstack.e2eFrontend.target}}'
8
+ const frontends: { target: string, name: string, url: string }[] = [
9
+ {{#each fullstack.frontendApps}}
10
+ {
11
+ target: '{{target}}',
12
+ name: '{{packageName}}',
13
+ url: process.env.HAI_E2E_{{target}}_URL || 'http://127.0.0.1:{{port}}',
14
+ },
15
+ {{/each}}
16
+ ]
9
17
  const serviceBaseURL = process.env.SERVICE_BASE_URL || defaultServiceBaseURL
10
18
  const serviceHost = resolveHostFromBaseUrl(serviceBaseURL, '127.0.0.1')
11
19
  const servicePort = resolvePortFromBaseUrl(serviceBaseURL, '3000')
@@ -65,13 +73,16 @@ export default defineConfig({
65
73
  url: `${serviceBaseURL}/health`,
66
74
  reuseExistingServer: false,
67
75
  timeout: 120_000,
68
- }{{#if fullstack.e2eFrontend}},
69
- {
70
- name: '{{fullstack.e2eFrontend.packageName}}',
71
- command: `pnpm --filter {{fullstack.e2eFrontend.packageName}} exec vite preview --host ${frontendHost} --port ${frontendPort} --strictPort`,
72
- url: baseURL,
73
- reuseExistingServer: false,
74
- timeout: 120_000,
75
- }{{/if}},
76
+ },
77
+ ...frontends.map((frontend) => {
78
+ const url = frontend.target === primaryFrontend ? baseURL : frontend.url
79
+ return {
80
+ name: frontend.name,
81
+ command: `pnpm --filter ${frontend.name} exec vite preview --host ${resolveHostFromBaseUrl(url, '127.0.0.1')} --port ${resolvePortFromBaseUrl(url, '3000')} --strictPort`,
82
+ url,
83
+ reuseExistingServer: false,
84
+ timeout: 120_000,
85
+ }
86
+ }),
76
87
  ],
77
88
  })