@h-ai/cli 0.1.0-alpha.63 → 0.1.0-alpha.65

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 (104) hide show
  1. package/README.en.md +20 -4
  2. package/README.ja.md +20 -4
  3. package/README.md +28 -12
  4. package/dist/chunk-47V5OT22.js +5639 -0
  5. package/dist/chunk-47V5OT22.js.map +1 -0
  6. package/dist/{chunk-X3RSYNBG.js → chunk-IPG3QNYK.js} +62 -4
  7. package/dist/chunk-IPG3QNYK.js.map +1 -0
  8. package/dist/cli-config-templates-SJ2MBIR7.js +5 -0
  9. package/dist/{cli-config-templates-QRF6B5T4.js.map → cli-config-templates-SJ2MBIR7.js.map} +1 -1
  10. package/dist/{cli-skill-templates-EZFW7D43.js → cli-skill-templates-A5VIR5J6.js} +3 -3
  11. package/dist/{cli-skill-templates-EZFW7D43.js.map → cli-skill-templates-A5VIR5J6.js.map} +1 -1
  12. package/dist/index.js +524 -66
  13. package/dist/index.js.map +1 -1
  14. package/dist/{src-DUGLX4YB.js → src-6PPBKQ6Q.js} +666 -290
  15. package/dist/src-6PPBKQ6Q.js.map +1 -0
  16. package/package.json +9 -7
  17. package/templates/apps/admin/src/routes/+layout.svelte.hbs +4 -2
  18. package/templates/apps/api/.dockerignore.hbs +5 -0
  19. package/templates/apps/api/.gitignore.hbs +4 -4
  20. package/templates/apps/api/Dockerfile.hbs +11 -1
  21. package/templates/apps/api/apps/{{projectName}}-contract/package.json.hbs +2 -0
  22. package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +2 -2
  23. package/templates/apps/api/apps/{{projectName}}-service/config/_serv.yml.hbs +4 -4
  24. package/templates/apps/api/apps/{{projectName}}-service/package.json.hbs +9 -2
  25. package/templates/apps/api/package.json.hbs +9 -0
  26. package/templates/apps/fullstack/.dockerignore.hbs +5 -3
  27. package/templates/apps/fullstack/.gitignore.hbs +22 -9
  28. package/templates/apps/fullstack/Dockerfile.hbs +11 -1
  29. package/templates/apps/fullstack/apps/{{projectName}}-app/package.json.hbs +6 -0
  30. package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +7 -3
  31. package/templates/apps/fullstack/apps/{{projectName}}-desktop/package.json.hbs +5 -0
  32. package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +7 -3
  33. package/templates/apps/fullstack/apps/{{projectName}}-web/package.json.hbs +6 -0
  34. package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +7 -3
  35. package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +93 -1
  36. package/templates/apps/fullstack/package.json.hbs +12 -0
  37. package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +2 -2
  38. package/templates/apps/fullstack/packages/{{projectName}}-contract/package.json.hbs +2 -0
  39. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +1 -1
  40. package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +8 -14
  41. package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +6 -4
  42. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_reach.yml.hbs +8 -8
  43. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +3 -3
  44. package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +9 -2
  45. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +4 -4
  46. package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +6 -2
  47. package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +2 -0
  48. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +19 -13
  49. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +1 -1
  50. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/settings.svelte.hbs +46 -0
  51. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +17 -3
  52. package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +15 -5
  53. package/templates/apps/fullstack/playwright.config.ts.hbs +6 -1
  54. package/templates/apps/fullstack/pnpm-workspace.yaml.hbs +0 -1
  55. package/templates/apps/h5/src/routes/+layout.svelte.hbs +13 -13
  56. package/templates/apps/mobile-app/src/App.svelte.hbs +46 -117
  57. package/templates/apps/mobile-app/src/lib/pages/home.svelte.hbs +58 -0
  58. package/templates/apps/mobile-app/src/lib/pages/profile.svelte.hbs +13 -0
  59. package/templates/apps/mobile-app/src/lib/pages/tasks.svelte.hbs +16 -0
  60. package/templates/base/.dockerignore.hbs +5 -0
  61. package/templates/base/.gitignore.hbs +18 -4
  62. package/templates/base/Dockerfile.hbs +17 -1
  63. package/templates/base/e2e/app.spec.ts.hbs +14 -0
  64. package/templates/base/package.json.hbs +14 -2
  65. package/templates/base/tests/scaffold.test.ts.hbs +9 -3
  66. package/templates/base/vite.config.ts.hbs +6 -4
  67. package/templates/shared/hash-router.svelte.ts +83 -0
  68. package/templates/skills/AGENTS.md +15 -1
  69. package/templates/skills/bridges/admin/AGENTS.md +17 -1
  70. package/templates/skills/bridges/api/AGENTS.md +17 -3
  71. package/templates/skills/bridges/fullstack/AGENTS.md.hbs +35 -11
  72. package/templates/skills/bridges/h5/AGENTS.md +17 -1
  73. package/templates/skills/bridges/mobile-app/AGENTS.md +19 -3
  74. package/templates/skills/bridges/website/AGENTS.md +17 -1
  75. package/templates/skills/hai-ai/reference.md +2 -2
  76. package/templates/skills/hai-app-create/SKILL.md +5 -3
  77. package/templates/skills/hai-app-create/development.md +126 -2
  78. package/templates/skills/hai-app-create/reference.md +2 -2
  79. package/templates/skills/hai-app-review/SKILL.md +2 -2
  80. package/templates/skills/hai-app-tests/SKILL.md +2 -1
  81. package/templates/skills/hai-app-tests/reference.md +1 -1
  82. package/templates/skills/hai-core/SKILL.md +2 -1
  83. package/templates/skills/hai-deploy/SKILL.md +42 -34
  84. package/templates/skills/hai-deploy/middleware.md +94 -0
  85. package/templates/skills/hai-framework-sync/SKILL.md +1 -1
  86. package/templates/skills/hai-framework-sync/sync.md +1 -1
  87. package/templates/skills/hai-reach/reference.md +8 -8
  88. package/templates/skills/hai-spec-implement/SKILL.md +12 -9
  89. package/templates/skills/hai-spec-implement/changes.md +67 -0
  90. package/templates/skills/hai-spec-implement/templates.md +25 -3
  91. package/templates/skills/hai-spec-implement/workflow.md +7 -6
  92. package/templates/skills/hai-spec-review/SKILL.md +14 -5
  93. package/templates/skills/hai-spec-review/checklist.md +4 -3
  94. package/templates/skills/hai-storage/SKILL.md +2 -0
  95. package/templates/skills/hai-ui/SKILL.md +4 -0
  96. package/dist/chunk-WW2MIO6T.js +0 -992
  97. package/dist/chunk-WW2MIO6T.js.map +0 -1
  98. package/dist/chunk-X3RSYNBG.js.map +0 -1
  99. package/dist/cli-config-templates-QRF6B5T4.js +0 -5
  100. package/dist/src-DUGLX4YB.js.map +0 -1
  101. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ProfileSettings.svelte.hbs +0 -57
  102. /package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/{components/AuditManagement.svelte.hbs → pages/audit.svelte.hbs} +0 -0
  103. /package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/{components/RoleManagement.svelte.hbs → pages/roles.svelte.hbs} +0 -0
  104. /package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/{components/UserManagement.svelte.hbs → pages/users.svelte.hbs} +0 -0
@@ -14,7 +14,7 @@ import {
14
14
  ProfileUpdateSchema,
15
15
  } from './admin-schemas.js'
16
16
 
17
- /** mini 管理业务路径统一定义;仅开放两种固定角色。 */
17
+ /** mini 管理示例的业务路径;初始 admin/user 角色可按实际需求调整。 */
18
18
  export const adminContract = {
19
19
  users: {
20
20
  list: apiContract.route({ method: 'POST', path: '/admin/users/list', tags: ['admin'] })
@@ -1,36 +1,30 @@
1
1
  import { apiContract } from '@h-ai/api-contract'
2
2
  import { describe, expect, it } from 'vitest'
3
- import {
4
- appContract,
5
- AppEchoInputSchema,
6
- AppInfoOutputDataSchema,
7
- ENABLED_FRONTENDS,
8
- {{fullstack.contractExportName}},
9
- } from '../src/index.js'
3
+ import * as contracts from '../src/index.js'
10
4
 
11
5
  describe('{{fullstack.contractPackageName}}', () => {
12
6
  it('defines app contract routes', () => {
13
- expect({{fullstack.contractExportName}}.app).toBeDefined()
14
- expect(appContract.info).toBeDefined()
15
- expect(apiContract.pathOf(appContract.info)).toBe('/app/info')
7
+ expect(contracts.{{fullstack.contractExportName}}.app).toBeDefined()
8
+ expect(contracts.appContract.info).toBeDefined()
9
+ expect(apiContract.pathOf(contracts.appContract.info)).toBe('/app/info')
16
10
  })
17
11
 
18
12
  it('validates app info payload', () => {
19
- const result = AppInfoOutputDataSchema.safeParse({
13
+ const result = contracts.AppInfoOutputDataSchema.safeParse({
20
14
  name: '{{fullstack.servPackageName}}',
21
15
  version: '0.1.0',
22
16
  env: 'test',
23
17
  defaultLocale: 'zh-CN',
24
18
  supportedLocales: ['zh-CN', 'en-US'],
25
19
  startedAt: '2026-01-01T00:00:00.000Z',
26
- frontends: [...ENABLED_FRONTENDS],
20
+ frontends: [...contracts.ENABLED_FRONTENDS],
27
21
  })
28
22
 
29
23
  expect(result.success).toBe(true)
30
24
  })
31
25
 
32
26
  it('validates echo input boundaries', () => {
33
- expect(AppEchoInputSchema.safeParse({ message: 'hello' }).success).toBe(true)
34
- expect(AppEchoInputSchema.safeParse({ message: '' }).success).toBe(false)
27
+ expect(contracts.AppEchoInputSchema.safeParse({ message: 'hello' }).success).toBe(true)
28
+ expect(contracts.AppEchoInputSchema.safeParse({ message: '' }).success).toBe(false)
35
29
  })
36
30
  })
@@ -4,6 +4,8 @@
4
4
 
5
5
  ## 默认功能与配置
6
6
 
7
+ 以下是可运行示例的初始实现,角色/权限、管理流程、头像限额、分页、审计策略及部署方案均可按用户需求调整。需求与代码或模板规则不一致时,以需求为业务基线同步实现、测试与过期说明;实际框架 API 契约和服务端安全校验仍需满足。
8
+
7
9
  - `config/_core.yml`:应用元信息(名称 / 版本 / 环境 / 语言),启动时由 `core.config` 加载。
8
10
  - `config/_serv.yml`:服务端运行时设置(CORS 来源等)。
9
11
  - `config/_db.yml` / `_cache.yml` / `_iam.yml` / `_storage.yml` / `_audit.yml` / `_reach.yml`:管理及开发通知配置,初始化前使用模块公开 Schema 验证。Reach 默认控制台 Provider,生产按注释替换为 SMTP/短信/API。
@@ -13,10 +15,10 @@
13
15
  - `src/server/procedures/app-procedures.ts`:app 领域 procedures,对应 contract 中的 `app.info` / `app.echo` 路由。
14
16
  - `src/server/procedures/admin-procedures.ts`:认证、管理员用户/角色/审计管理和个人设置,使用 `.auth()` / `.role('admin')` 守卫。每个请求读取用户最新启用状态、角色和权限。
15
17
  - 标准认证 procedures 复用 `@h-ai/serv/features/iam`,注册、登录方式、重置与协议由 `_iam.yml` 控制;登录、退出、修改密码追加业务审计。HTTP App 类型复用公开 `ServHttpApp`。
16
- - `src/server/admin-service.ts`:角色分配、头像上传和审计编排,全部底层操作复用 hai 模块。固定 admin/user 两种角色;锁内重新鉴权,禁止管理员自删、自禁用、自降级,并补偿用户更新失败后的角色变更。
18
+ - `src/server/admin-service.ts`:角色分配、头像上传和审计编排,全部底层操作复用 hai 模块。初始使用 admin/user 两种角色;锁内重新鉴权,默认禁止管理员自删、自禁用、自降级,并补偿用户更新失败后的角色变更。业务规则可按需求替换,保留的不变量需验证并发与失败路径。
17
19
  - `tests/app-server.test.ts`:真实加密 HTTP 测试,覆盖 Cookie/受信原生刷新、RBAC、旧会话权限、并发管理操作、补偿、头像安全、偏好、审计及会话失效。
18
20
 
19
- CLI 已生成根目录 `.env`,`HAI_ADMIN_DEFAULT_PASSWORD` 为每个项目独立的随机强密码;`dev`/`start` 自动读取,首次登录使用用户名 `admin` 和该密码。仅在没有用户时创建管理员,不重设已有用户密码。密码不符合 `_iam.yml` 策略时,启动日志显示具体原因及错误码。`.env` 被 Git 和容器构建忽略,`.env.example` 不含真实密码;部署时设置目标环境自己的密码。所有业务 JSON 请求和响应强制加密;健康检查和随机路径的只读头像图片可直接访问。头像支持 PNG/JPEG/WebP,最大 512 KiB,校验内容特征和大小后由服务端生成 key,不能指定任意文件路径。
21
+ CLI 已生成根目录 `.env.local`,`HAI_ADMIN_DEFAULT_PASSWORD` 为每个项目独立的随机强密码;`dev`/`start` 自动读取,首次登录使用用户名 `admin` 和该密码。仅在没有用户时创建管理员,不重设已有用户密码。密码不符合 `_iam.yml` 策略时,启动日志显示具体原因及错误码。`.env.local` 被 Git 和容器构建忽略,`.env.example` 不含真实密码;部署时设置目标环境自己的密码。所有业务 JSON 请求和响应强制加密;健康检查和随机路径的只读头像图片可直接访问。头像支持 PNG/JPEG/WebP,最大 512 KiB,校验内容特征和大小后由服务端生成 key,不能指定任意文件路径。
20
22
 
21
23
  默认 SQLite + 本地 storage + memory cache 适合单实例,管理写入仅支持单写实例:进程内保护无过期时间,共享 cache 锁定期续租,丢失锁或释放失败会记录错误。多实例读取可使用共享数据库、Redis cache 和 S3 storage,但管理写请求必须统一路由到同一写实例;仅更换 Redis 不提供任意多写副本的最后管理员原子保证。另需配置传输会话粘性路由:服务端私钥驻留单个进程,服务重启后客户端需重新协商。Web 使用 HttpOnly refresh Cookie + 内存 access token;仅同时满足客户端标识、body 通道头与受信原生 Origin 的 App/Desktop 请求返回 refresh token body。
22
24
 
@@ -24,9 +26,9 @@ CLI 已生成根目录 `.env`,`HAI_ADMIN_DEFAULT_PASSWORD` 为每个项目独
24
26
 
25
27
  ## 开发
26
28
 
27
- 所有配置字段以模块 Schema 为准;未使用字段和替代 Provider 用注释保留。叶子环境变量只覆盖已有 YAML 路径,先取消相应键的注释,或用 `HAI_DB` / `HAI_CACHE` 等整体对象覆盖;`connectTimeout` 对应 `HAI_CACHE_CONNECTTIMEOUT`。凭据示例在根 `.env.example`,实际密钥保存在忽略的 `.env` 或部署环境。`.gitignore` 的 `**/data/` 忽略所有运行数据及 `.gitkeep`;运行时会创建目录。`crypto.init()` 无配置参数,datapipe 为纯函数,不提供虚构配置。
29
+ 所有配置字段以模块 Schema 为准;未使用字段和替代 Provider 用注释保留。叶子环境变量只覆盖已有 YAML 路径,先取消相应键的注释,或用 `HAI_DB` / `HAI_CACHE` 等整体对象覆盖;`connectTimeout` 对应 `HAI_CACHE_CONNECTTIMEOUT`。凭据示例在根 `.env.example`,实际密钥保存在忽略的 `.env.local` 或部署环境。`.gitignore` 的 `**/data/` 忽略所有运行数据及 `.gitkeep`;运行时会创建目录。`crypto.init()` 无配置参数,datapipe 为纯函数,不提供虚构配置。
28
30
 
29
- 自动生成的 `.env` 只启用初始管理员密码和 `PUBLIC_API_BASE`;模块环境覆盖保留注释,以 YAML 为默认值来源。有明确环境覆盖时编辑 YAML 要同步核对。
31
+ 自动生成的 `.env` 包含已启用模块的环境参数;YAML 显式引用相同变量并保留本地回退。使用 `start:test` / `start:prod` 加载对应环境覆盖。
30
32
 
31
33
  - `pnpm --filter {{fullstack.servPackageName}} dev`
32
34
  - `pnpm --filter {{fullstack.servPackageName}} test`
@@ -7,17 +7,17 @@ providers:
7
7
  # SMTP 替换 email 控制台 Provider;同名 Provider 只保留一个。
8
8
  # - name: email
9
9
  # type: smtp
10
- # host: ${HAI_REACH_SMTP_HOST:smtp.example.com}
11
- # port: ${HAI_REACH_SMTP_PORT:465}
10
+ # host: ${HAI_REACH_PROVIDERS_0_HOST:smtp.example.com}
11
+ # port: ${HAI_REACH_PROVIDERS_0_PORT:465}
12
12
  # secure: true
13
- # user: ${HAI_REACH_SMTP_USER:}
14
- # pass: ${HAI_REACH_SMTP_PASS:}
15
- # from: ${HAI_REACH_SMTP_FROM:noreply@example.com}
13
+ # user: ${HAI_REACH_PROVIDERS_0_USER:}
14
+ # pass: ${HAI_REACH_PROVIDERS_0_PASS:}
15
+ # from: ${HAI_REACH_PROVIDERS_0_FROM:noreply@example.com}
16
16
  # 阿里云短信替换 sms 控制台 Provider。
17
17
  # - name: sms
18
18
  # type: aliyun-sms
19
- # accessKeyId: ${HAI_REACH_SMS_ACCESS_KEY}
20
- # accessKeySecret: ${HAI_REACH_SMS_SECRET_KEY}
19
+ # accessKeyId: ${HAI_REACH_PROVIDERS_0_ACCESSKEYID}
20
+ # accessKeySecret: ${HAI_REACH_PROVIDERS_0_ACCESSKEYSECRET}
21
21
  # signName: MyApp
22
22
  # endpoint: dysmsapi.aliyuncs.com
23
23
  # 自有发送服务:按需将 template.provider 改为 custom。
@@ -26,7 +26,7 @@ providers:
26
26
  # url: https://notify.example.com/send
27
27
  # method: POST # POST | PUT
28
28
  # headers:
29
- # Authorization: 'Bearer ${HAI_REACH_API_TOKEN}'
29
+ # Authorization: '${HAI_REACH_PROVIDERS_0_HEADERS_AUTHORIZATION}'
30
30
  # timeout: 10000
31
31
  templates:
32
32
  - name: otp_email
@@ -30,9 +30,9 @@ http:
30
30
  # gatewaySecret: ${HAI_SERV_HTTP_RPC_GATEWAYSECRET}
31
31
 
32
32
  cors:
33
- origin: ${HAI_CORS_ORIGIN:*}
34
- nativeOrigins: ${HAI_CORS_NATIVEORIGINS:http://localhost,https://localhost,http://tauri.localhost,https://tauri.localhost,tauri://localhost,capacitor://localhost}
35
- credentials: ${HAI_CORS_CREDENTIALS:true}
33
+ origin: ${HAI_SERV_CORS_ORIGIN:*}
34
+ nativeOrigins: ${HAI_SERV_CORS_NATIVEORIGINS:http://localhost,https://localhost,http://tauri.localhost,https://tauri.localhost,tauri://localhost,capacitor://localhost}
35
+ credentials: ${HAI_SERV_CORS_CREDENTIALS:true}
36
36
  allowedHeaders:
37
37
  - Authorization
38
38
  - Content-Type
@@ -20,13 +20,20 @@
20
20
  ],
21
21
  "scripts": {
22
22
  "build": "tsup",
23
+ "build:prod": "pnpm build",
24
+ "check": "pnpm typecheck",
23
25
  "typecheck": "tsc --noEmit",
24
26
  "lint": "eslint .",
27
+ "lint:fix": "eslint . --fix",
25
28
  "test": "vitest run",
26
29
  "test:watch": "vitest",
27
30
  "clean": "rimraf dist .turbo",
28
- "dev": "pnpm build && node --env-file-if-exists=../../.env dist/index.js",
29
- "start": "node --env-file-if-exists=../../.env dist/index.js"
31
+ "dev": "pnpm build && node --env-file=../../.env --env-file=../../.env.dev --env-file-if-exists=../../.env.local dist/index.js",
32
+ "start": "node --env-file=../../.env --env-file=../../.env.prod --env-file-if-exists=../../.env.local dist/index.js",
33
+ "dev:test": "pnpm build && node --env-file=../../.env --env-file=../../.env.test --env-file-if-exists=../../.env.local dist/index.js",
34
+ "dev:prod": "pnpm build && pnpm start",
35
+ "start:test": "node --env-file=../../.env --env-file=../../.env.test --env-file-if-exists=../../.env.local dist/index.js",
36
+ "start:prod": "pnpm start"
30
37
  },
31
38
  "dependencies": {
32
39
  "@h-ai/audit": "{{haiPackageSpecifier}}",
@@ -6,7 +6,7 @@ import { iam } from '@h-ai/iam'
6
6
  import { reldb } from '@h-ai/reldb'
7
7
  import { serv, ServConfigSchema } from '@h-ai/serv'
8
8
  import { storage } from '@h-ai/storage'
9
- import { ENABLED_FRONTENDS, {{fullstack.contractExportName}}, SUPPORTED_LOCALES } from '{{fullstack.contractPackageName}}'
9
+ import * as contracts from '{{fullstack.contractPackageName}}'
10
10
  import { createServerProcedures } from './server/procedures/index.js'
11
11
 
12
12
  const STARTED_AT = new Date().toISOString()
@@ -27,16 +27,16 @@ export function createServerApp(): ServHttpApp {
27
27
  ]
28
28
 
29
29
  return serv.createApp({
30
- contract: {{fullstack.contractExportName}},
30
+ contract: contracts.{{fullstack.contractExportName}},
31
31
  procedures: createServerProcedures({
32
32
  app: {
33
33
  name: coreConfig.name,
34
34
  version: coreConfig.version,
35
35
  env: coreConfig.env,
36
36
  defaultLocale: coreConfig.defaultLocale,
37
- supportedLocales: SUPPORTED_LOCALES,
37
+ supportedLocales: contracts.SUPPORTED_LOCALES,
38
38
  startedAt: STARTED_AT,
39
- frontends: [...ENABLED_FRONTENDS],
39
+ frontends: [...contracts.ENABLED_FRONTENDS],
40
40
  },
41
41
  }),
42
42
  iam,
@@ -2,8 +2,10 @@
2
2
 
3
3
  Shared mini administration workspace for **{{projectName}}**. Web, Capacitor App and Tauri desktop use the same `AdminWorkspace`, responsive `AppShell`, typed API client and management store.
4
4
 
5
+ The behavior below describes the initial example. Roles, permissions, workflows, limits, preferences and audit policies can change with the user's requirements. Update conflicting implementation, tests and guidance together; preserve the applicable public API contracts and server security checks.
6
+
5
7
  - Administrators manage users, upload user avatars, assign the built-in administrator/user roles, edit role descriptions and query audit logs.
6
- - Ordinary users can only update their own profile, avatar, language, appearance and password. The server enforces access on every request.
8
+ - By default, ordinary users update their own profile, avatar, language, appearance and password. The server enforces the actual business permissions on every request.
7
9
  - `ManagementStore` owns API calls and business state; components render the state and forward user interactions.
8
10
  - Application name, version, environment and default locale come from the backend `app.info` contract, which reads `core` configuration. Do not duplicate these values in translations or frontend constants.
9
11
  - Requests, token refresh and JSON responses use the same `@h-ai/api-client` + `@h-ai/crypto` transport. Avatar uploads use encrypted JSON and validate PNG/JPEG/WebP content, with a 512 KiB limit.
@@ -43,6 +45,8 @@ Run root `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm test` and `pnpm test
43
45
 
44
46
  The service's `config/_iam.yml` controls login methods, registration, password reset, agreements and `ui` branding. Email and SMS use providers/templates in `_reach.yml` or custom callbacks. `AuthPanel` uses the public configuration and framework auth actions to manage tokens, resend countdowns and errors. All service nodes must share the same database and Redis; memory cache is for a single process.
45
47
 
48
+ Authentication defaults to a split layout. Set `ui.variant`, `title`, `subtitle`, `brandTitle`, `brandText`, `logoUrl` and `illustrationUrl` to customize login and registration. Without a `brandTitle`, the displayed application name comes from `app.info` and follows `core` configuration.
49
+
46
50
  LDAP and Reach are optional. Install ldapts in the IAM service workspace only when using its default LDAP client, then enable login.ldap and set connection settings. Custom onOtpSendEmail/onOtpSendSms/onPasswordResetRequest callbacks work without Reach and must reject on delivery failure. Agreements open in a Modal. LoginForm/RegisterForm support header/extraFields/footer snippets; AuthPanel forwards loginHeader/loginFields/loginFooter and registerHeader/registerFields/registerFooter. See the generated hai-iam/hai-ui skills for configuration and examples.
47
51
 
48
52
  协议支持配置中的 userAgreementMarkdown/privacyPolicyMarkdown 正文及 URL;同主题优先 Markdown,由安全渲染器在弹窗显示。
@@ -51,4 +55,4 @@ See `.agents/skills/hai-iam/reference.md` in the generated application for the c
51
55
 
52
56
  ## Form layouts
53
57
 
54
- The user editor uses `FormLayout columns={2} labelPosition="left"`; profile and password forms reuse the same UI options. Narrow containers automatically stack fields and labels. Field validation errors follow the current UI language. The management store syncs locale through the lightweight `@h-ai/ui/i18n` entry, without loading components or tour CSS. Configure `colSpan` on FormField to override automatic spans for textareas, uploads and multiple choices.
58
+ The user editor uses `FormLayout columns={2} labelPosition="left"`. Personal settings use a bounded page width, stacked labels and a single avatar editor. Narrow containers automatically stack fields and labels. UI controls and CRUD forms show client validation on blur, update errors while correcting input and validate untouched fields on submit. Field errors follow the current UI language. The management store syncs locale through the lightweight `@h-ai/ui/i18n` entry, without loading components or tour CSS. Configure `colSpan` on FormField to override automatic spans for textareas, uploads and multiple choices.
@@ -24,8 +24,10 @@
24
24
  "paraglide:compile": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide",
25
25
  "paraglide:watch": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide --watch",
26
26
  "prepare": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide",
27
+ "check": "pnpm typecheck",
27
28
  "typecheck": "pnpm paraglide:compile && svelte-check --tsconfig ./tsconfig.json",
28
29
  "lint": "eslint .",
30
+ "lint:fix": "eslint . --fix",
29
31
  "test": "vitest run --passWithNoTests",
30
32
  "clean": "rimraf .turbo src/lib/paraglide"
31
33
  },
@@ -1,4 +1,5 @@
1
1
  <script lang='ts'>
2
+ import type { Component } from 'svelte'
2
3
  import type { ManagementApiOptions } from '../api/api-client.js'
3
4
  import { Alert, Skeleton } from '@h-ai/ui/components/compounds/index'
4
5
  import { Button } from '@h-ai/ui/components/primitives/index'
@@ -6,9 +7,10 @@
6
7
  import { GuideHost } from '@h-ai/ui/guide'
7
8
  import { onMount, untrack } from 'svelte'
8
9
  import { managementAuthActions } from '../api/api-client.js'
10
+ import { HashRouter } from '../navigation/hash-router.svelte.js'
9
11
  import * as m from '../paraglide/messages.js'
10
- import { ManagementStore } from '../stores/management-store.svelte.js'
11
12
  import { createManagementGuide } from '../stores/guide-store.js'
13
+ import { ManagementStore } from '../stores/management-store.svelte.js'
12
14
  import AppShell from './AppShell.svelte'
13
15
 
14
16
  interface Props {
@@ -17,10 +19,16 @@
17
19
  onLocaleChange?: (locale: string) => void
18
20
  }
19
21
  const { platform, apiOptions = {}, onLocaleChange }: Props = $props()
20
- const store = new ManagementStore()
22
+ const pages = import.meta.glob<{ default: Component<{ store: ManagementStore }> }>('../pages/**/*.svelte')
23
+ const router = new HashRouter(Object.keys(pages), '/users')
24
+ const store = new ManagementStore(router)
21
25
  // 宿主平台在组件生命周期中固定,引导控制器只创建一次。
22
26
  const guide = untrack(() => createManagementGuide(store, platform))
23
27
  let resetToken = $state('')
28
+ const authConfig = $derived(store.authConfig && {
29
+ ...store.authConfig,
30
+ ui: { ...store.authConfig.ui, brandTitle: store.authConfig.ui.brandTitle || store.info?.name },
31
+ })
24
32
  async function authenticated() {
25
33
  await store.authenticated()
26
34
  if (resetToken) {
@@ -30,19 +38,16 @@
30
38
  }
31
39
  resetToken = ''
32
40
  }
33
- const pageComponent = $derived.by(() => {
34
- if (store.isAdmin && store.page === 'users')
35
- return import('./UserManagement.svelte')
36
- if (store.isAdmin && store.page === 'roles')
37
- return import('./RoleManagement.svelte')
38
- if (store.isAdmin && store.page === 'audit')
39
- return import('./AuditManagement.svelte')
40
- return import('./ProfileSettings.svelte')
41
- })
41
+ const pageComponent = $derived(pages[router.file]())
42
42
  onMount(() => {
43
+ const stopRouting = router.start(() => {
44
+ if (store.user)
45
+ void store.navigate(router.path.slice(1))
46
+ })
43
47
  resetToken = new URL(location.href).searchParams.get('token') ?? ''
44
48
  void store.initialize(apiOptions, onLocaleChange)
45
49
  return () => {
50
+ stopRouting()
46
51
  void store.close()
47
52
  }
48
53
  })
@@ -56,6 +61,7 @@
56
61
  {#if store.initializing}
57
62
  <div class='grid min-h-dvh place-items-center bg-base-200/50'><div class='w-72 space-y-4'><Skeleton variant='title' /><Skeleton count={3} /><p class='text-center text-sm text-base-content/50'>{m.home_loading()}</p></div></div>
58
63
  {:else if !store.user || resetToken}
64
+ <header class='border-b border-base-content/8 bg-base-100 px-6 py-3 text-sm font-medium'>{store.info?.name}</header>
59
65
  <main data-testid='login-page'>
60
66
  {#if !store.info || !store.authConfig}
61
67
  <section class='mx-auto mt-20 max-w-md space-y-4 p-6'>
@@ -65,9 +71,9 @@
65
71
  {:else}
66
72
  <div data-testid='login-form'>
67
73
  {#if store.authConfig && store.error}<Alert variant='error' class='mx-auto max-w-lg'>{store.error}</Alert>{/if}
68
- <AuthPanel config={store.authConfig} actions={managementAuthActions} {resetToken} initialMode={resetToken ? 'reset' : 'login'} error={store.authConfig ? '' : store.error} onretry={() => void store.initialize(apiOptions, onLocaleChange)} onauthenticated={authenticated} />
74
+ <AuthPanel config={authConfig} actions={managementAuthActions} {resetToken} initialMode={resetToken ? 'reset' : 'login'} error={store.authConfig ? '' : store.error} onretry={() => void store.initialize(apiOptions, onLocaleChange)} onauthenticated={authenticated} />
69
75
  </div>
70
- <p class='fixed bottom-3 left-0 right-0 text-center text-xs text-base-content/50'><span data-testid='app-name'>{store.info.name}</span> · <span data-testid='app-version'>v{store.info.version}</span> · <span data-testid='app-environment'>{store.info.env}</span></p>
76
+ <footer class='border-t border-base-content/8 p-3 text-center text-xs text-base-content/50'><span data-testid='app-name'>{store.info.name}</span> · <span data-testid='app-version'>v{store.info.version}</span> · <span data-testid='app-environment'>{store.info.env}</span></footer>
71
77
  {/if}
72
78
  </main>
73
79
  {:else}
@@ -22,7 +22,7 @@
22
22
  : []),
23
23
  { id: 'settings' as const, label: m.settings_title(), icon: 'icon-[tabler--settings]' },
24
24
  ])
25
- const title = $derived(items.find(item => item.id === store.page)?.label ?? m.settings_title())
25
+ const title = $derived(items.find(item => item.id === store.page)?.label ?? store.info?.name ?? '')
26
26
 
27
27
  function navigate(page: ManagementPage) {
28
28
  menuOpen = false
@@ -0,0 +1,46 @@
1
+ <script lang='ts'>
2
+ import type { ManagementStore } from '../stores/management-store.svelte.js'
3
+ import { Card, FormField, FormLayout } from '@h-ai/ui/components/compounds/index'
4
+ import { Badge, Select } from '@h-ai/ui/components/primitives/index'
5
+ import { ChangePasswordForm, UserProfile } from '@h-ai/ui/components/scenes/iam/index'
6
+ import { getThemeInfo } from '@h-ai/ui/theme'
7
+ import * as m from '../paraglide/messages.js'
8
+ import { locales } from '../paraglide/runtime.js'
9
+ import { AVAILABLE_THEMES, themeStore } from '../stores/theme-store.svelte.js'
10
+
11
+ interface Props { store: ManagementStore }
12
+ const { store }: Props = $props()
13
+ const languageOptions = $derived(locales.filter(locale => store.info?.supportedLocales.includes(locale)).map(locale => ({ value: locale, label: locale === 'zh-CN' ? '简体中文' : 'English' })))
14
+ const themeOptions = AVAILABLE_THEMES.map(theme => ({ value: theme, label: getThemeInfo(theme)?.name ?? theme }))
15
+ </script>
16
+
17
+ <section class='mx-auto w-full max-w-4xl space-y-6' data-testid='settings-page'>
18
+ <div>
19
+ <h1 class='text-xl font-semibold tracking-tight'>{m.settings_title()}</h1>
20
+ <p class='mt-1 text-sm text-base-content/50'>{m.settings_description()}</p>
21
+ </div>
22
+ {#if store.user}
23
+ <Card title={m.profile_title()} bordered shadow={false} data-guide='admin-profile-form'>
24
+ <div class='mb-5 flex flex-wrap items-center gap-3 border-b border-base-content/8 pb-4'>
25
+ <p class='min-w-0 break-all text-sm text-base-content/55'>@{store.user.username}</p>
26
+ <Badge size='sm' variant='ghost'>{store.isAdmin ? m.role_admin() : m.role_user()}</Badge>
27
+ </div>
28
+ <UserProfile columns={2} 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' />
29
+ </Card>
30
+ <Card title={m.preferences_title()} bordered shadow={false} data-guide='admin-preferences'>
31
+ <p class='mb-5 text-sm text-base-content/50'>{m.preferences_description()}</p>
32
+ <FormLayout columns={2}>
33
+ <FormField label={m.settings_language()} for='profile-language'>
34
+ <Select id='profile-language' value={store.locale} options={languageOptions} disabled={store.saving} onchange={locale => void store.savePreferences({ locale })} data-testid='profile-language' />
35
+ </FormField>
36
+ <FormField label={m.settings_theme()} for='profile-theme'>
37
+ <Select id='profile-theme' value={themeStore.current} options={themeOptions} disabled={store.saving} onchange={theme => void store.savePreferences({ theme })} data-testid='profile-theme' />
38
+ </FormField>
39
+ </FormLayout>
40
+ </Card>
41
+ <Card title={m.password_title()} bordered shadow={false} data-guide='admin-password'>
42
+ <p class='mb-5 text-sm text-base-content/50'>{m.password_rules()}</p>
43
+ <ChangePasswordForm loading={store.saving} passwordPolicy={store.authConfig?.password} minPasswordLength={store.authConfig?.password.minLength ?? 8} onsubmit={data => store.changePassword(data)} data-testid='password-form' />
44
+ </Card>
45
+ {/if}
46
+ </section>
@@ -3,6 +3,7 @@ import type { HaiResult } from '@h-ai/core'
3
3
  import type { ChangePasswordFormData, UserProfileSubmitData } from '@h-ai/ui'
4
4
  import type { AdminAuditLog, AdminCreateUser, AdminRole, AdminUpdateUser, AdminUser, AvatarUpload, ProfileUpdate } from '{{fullstack.contractPackageName}}'
5
5
  import type { AppInfo, ManagementApiOptions } from '../api/api-client.js'
6
+ import type { HashRouter } from '../navigation/hash-router.svelte.js'
6
7
  import { HaiApiClientError } from '@h-ai/api-client'
7
8
  import { setGlobalLocale } from '@h-ai/ui/i18n'
8
9
  import { SvelteDate } from 'svelte/reactivity'
@@ -13,10 +14,20 @@ import { AVAILABLE_THEMES, DEFAULT_THEME, themeStore } from './theme-store.svelt
13
14
 
14
15
  type ResultData<T> = T extends { success: true, data: infer D } ? D : never
15
16
  export type CurrentUser = ResultData<Awaited<ReturnType<typeof managementApi.iam.auth.currentUser>>>
16
- export type ManagementPage = 'users' | 'roles' | 'audit' | 'settings'
17
+ /** 页面目录对应的相对路径;例如 `reports/weekly`,新增页面无需修改类型或路由表。 */
18
+ export type ManagementPage = string
17
19
 
18
20
  /** 页面状态和业务操作在共享 store 中收敛,宿主端仅装配平台能力。 */
19
21
  export class ManagementStore {
22
+ /**
23
+ * 绑定当前工作区的页面路由;服务端继续验证各 API 的权限。
24
+ * @example new ManagementStore(new HashRouter(Object.keys(pages), '/users'))
25
+ */
26
+ constructor(router: HashRouter) {
27
+ this.router = router
28
+ }
29
+
30
+ private readonly router: HashRouter
20
31
  info = $state<AppInfo | null>(null)
21
32
  authConfig = $state<IamPublicConfig | null>(null)
22
33
  user = $state<CurrentUser | null>(null)
@@ -76,7 +87,7 @@ export class ManagementStore {
76
87
  const me = await managementApi.iam.auth.currentUser()
77
88
  if (me.success) {
78
89
  this.setUser(me.data)
79
- await this.navigate(this.isAdmin ? 'users' : 'settings')
90
+ await this.navigate(this.router.path.slice(1))
80
91
  }
81
92
  else if (me.error.httpStatus !== 401) {
82
93
  this.error = m.operation_failed()
@@ -102,7 +113,7 @@ export class ManagementStore {
102
113
  if (!me?.success)
103
114
  return
104
115
  this.setUser(me.data)
105
- await this.navigate(this.isAdmin ? 'users' : 'settings')
116
+ await this.navigate(this.router.path.slice(1))
106
117
  }
107
118
 
108
119
  async logout(): Promise<void> {
@@ -121,6 +132,9 @@ export class ManagementStore {
121
132
  this.requestVersion++
122
133
  this.loading = false
123
134
  this.page = this.isAdmin ? page : 'settings'
135
+ if (this.router.path !== `/${this.page}`)
136
+ this.router.navigate(`/${this.page}`)
137
+ this.page = this.router.path.slice(1)
124
138
  this.error = ''
125
139
  this.notice = ''
126
140
  if (this.page === 'users')
@@ -1,4 +1,5 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest'
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { HashRouter } from '../src/lib/navigation/hash-router.svelte.js'
2
3
  import * as m from '../src/lib/paraglide/messages.js'
3
4
  import { ManagementStore } from '../src/lib/stores/management-store.svelte.js'
4
5
 
@@ -35,15 +36,24 @@ function deferred<T>() {
35
36
 
36
37
  beforeEach(() => {
37
38
  vi.resetAllMocks()
39
+ vi.stubGlobal('location', { hash: '#/users' })
40
+ vi.stubGlobal('history', { replaceState: vi.fn(), pushState: vi.fn() })
38
41
  api.iam.auth.logout.mockResolvedValue({ success: true })
39
42
  api.iam.auth.currentUser.mockResolvedValue({ success: true, data: member })
40
43
  })
41
44
 
45
+ afterEach(() => vi.unstubAllGlobals())
46
+
47
+ /** 会话测试使用真实路由状态,浏览器历史由 E2E 验证。 */
48
+ function createStore() {
49
+ return new ManagementStore(new HashRouter(['../pages/users.svelte', '../pages/settings.svelte'], '/users'))
50
+ }
51
+
42
52
  describe('management session and request ownership', () => {
43
53
  it('ignores a previous administrator failure after logout and another user login', async () => {
44
54
  const oldRequest = deferred<typeof forbidden>()
45
55
  api.admin.users.list.mockReturnValueOnce(oldRequest.promise)
46
- const store = new ManagementStore()
56
+ const store = createStore()
47
57
  store.user = administrator
48
58
  const loading = store.loadUsers()
49
59
  await store.logout()
@@ -60,7 +70,7 @@ describe('management session and request ownership', () => {
60
70
  it('ignores an obsolete page failure after opening personal settings', async () => {
61
71
  const oldRequest = deferred<typeof forbidden>()
62
72
  api.admin.users.list.mockReturnValueOnce(oldRequest.promise)
63
- const store = new ManagementStore()
73
+ const store = createStore()
64
74
  store.user = administrator
65
75
  const loading = store.loadUsers()
66
76
  await store.navigate('settings')
@@ -75,7 +85,7 @@ describe('management session and request ownership', () => {
75
85
 
76
86
  it('refreshes revoked roles once and clears administration data', async () => {
77
87
  api.admin.users.list.mockResolvedValueOnce(forbidden)
78
- const store = new ManagementStore()
88
+ const store = createStore()
79
89
  store.user = administrator
80
90
  store.roles = [{ id: 'role-id', code: 'admin', name: 'Administrator', userCount: 1 }]
81
91
  await store.loadUsers()
@@ -90,7 +100,7 @@ describe('management session and request ownership', () => {
90
100
 
91
101
  it('reports a connection failure when loading the authenticated user', async () => {
92
102
  api.iam.auth.currentUser.mockRejectedValueOnce(new Error('Connection unavailable'))
93
- const store = new ManagementStore()
103
+ const store = createStore()
94
104
  await store.authenticated()
95
105
  expect(store.error).toBe(m.network_error())
96
106
  expect(store.error).not.toBe(m.login_invalid())
@@ -5,12 +5,17 @@ const defaultBaseURL = '{{#if fullstack.e2eFrontend}}http://127.0.0.1:{{fullstac
5
5
  const defaultServiceBaseURL = 'http://127.0.0.1:3000'
6
6
  const baseURL = process.env.BASE_URL || defaultBaseURL
7
7
  const primaryFrontend = '{{fullstack.e2eFrontend.target}}'
8
+ const frontendUrls = {
9
+ web: process.env.HAI_E2E_WEB_URL,
10
+ app: process.env.HAI_E2E_APP_URL,
11
+ desktop: process.env.HAI_E2E_DESKTOP_URL,
12
+ }
8
13
  const frontends: { target: string, name: string, url: string }[] = [
9
14
  {{#each fullstack.frontendApps}}
10
15
  {
11
16
  target: '{{target}}',
12
17
  name: '{{packageName}}',
13
- url: process.env.HAI_E2E_{{target}}_URL || 'http://127.0.0.1:{{port}}',
18
+ url: frontendUrls.{{target}} || 'http://127.0.0.1:{{port}}',
14
19
  },
15
20
  {{/each}}
16
21
  ]
@@ -9,7 +9,6 @@ trustPolicyExclude:
9
9
  # pnpm 12 配置;原生依赖仅运行明确列出的安装脚本。
10
10
  nodeLinker: hoisted
11
11
  allowBuilds:
12
- onnxruntime-node: true
13
12
  protobufjs: true
14
13
  vue-demi: true
15
14
  better-sqlite3: true
@@ -14,9 +14,9 @@
14
14
  {{#if hasI18n}}
15
15
  import * as m from '$lib/paraglide/messages'
16
16
  import { getLocale } from '$lib/paraglide/runtime.js'
17
- import { BottomNav, setGlobalLocale } from '@h-ai/ui'
17
+ import { AppBar, BottomNav, setGlobalLocale } from '@h-ai/ui'
18
18
  {{else}}
19
- import { BottomNav } from '@h-ai/ui'
19
+ import { AppBar, BottomNav } from '@h-ai/ui'
20
20
  {{/if}}
21
21
  import '../app.css'
22
22
 
@@ -39,7 +39,7 @@
39
39
  ]
40
40
 
41
41
  const activeNav = $derived(navItems.find(item => item.path === page.url.pathname)?.id)
42
- // 认证页面独立显示;业务页面复用框架导航和移动端安全区域样式。
42
+ // 所有页面共用页头页脚;仅业务页面显示底部导航。
43
43
  const isAuthPage = $derived(page.url.pathname.startsWith('/auth'))
44
44
 
45
45
  function handleNavigation(id: string) {
@@ -60,15 +60,15 @@
60
60
  <title>{page.data.appConfig.name}</title>
61
61
  </svelte:head>
62
62
 
63
- {#if isAuthPage}
64
- {@render children()}
65
- {:else}
66
- <div class='hai-mobile-viewport'>
67
- <div class='hai-mobile-shell max-w-lg'>
68
- <main class='hai-mobile-main'>
69
- {@render children()}
70
- </main>
63
+ <div class='hai-mobile-viewport'>
64
+ <div class='hai-mobile-shell max-w-lg'>
65
+ <AppBar title={page.data.appConfig.name} safeArea fixed={false} />
66
+ <main class='hai-mobile-main'>{@render children()}</main>
67
+ <footer class='border-t border-base-300 bg-base-100 p-2 text-center text-xs text-base-content/60'>
68
+ {page.data.appConfig.name} · {page.data.appConfig.version} · {page.data.appConfig.env}
69
+ </footer>
70
+ {#if !isAuthPage}
71
71
  <BottomNav items={navItems} active={activeNav} onchange={handleNavigation} centered maxWidth='lg' safeArea />
72
- </div>
72
+ {/if}
73
73
  </div>
74
- {/if}
74
+ </div>