@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.
- package/README.en.md +20 -4
- package/README.ja.md +20 -4
- package/README.md +28 -12
- package/dist/chunk-47V5OT22.js +5639 -0
- package/dist/chunk-47V5OT22.js.map +1 -0
- package/dist/{chunk-X3RSYNBG.js → chunk-IPG3QNYK.js} +62 -4
- package/dist/chunk-IPG3QNYK.js.map +1 -0
- package/dist/cli-config-templates-SJ2MBIR7.js +5 -0
- package/dist/{cli-config-templates-QRF6B5T4.js.map → cli-config-templates-SJ2MBIR7.js.map} +1 -1
- package/dist/{cli-skill-templates-EZFW7D43.js → cli-skill-templates-A5VIR5J6.js} +3 -3
- package/dist/{cli-skill-templates-EZFW7D43.js.map → cli-skill-templates-A5VIR5J6.js.map} +1 -1
- package/dist/index.js +524 -66
- package/dist/index.js.map +1 -1
- package/dist/{src-DUGLX4YB.js → src-6PPBKQ6Q.js} +666 -290
- package/dist/src-6PPBKQ6Q.js.map +1 -0
- package/package.json +9 -7
- package/templates/apps/admin/src/routes/+layout.svelte.hbs +4 -2
- package/templates/apps/api/.dockerignore.hbs +5 -0
- package/templates/apps/api/.gitignore.hbs +4 -4
- package/templates/apps/api/Dockerfile.hbs +11 -1
- package/templates/apps/api/apps/{{projectName}}-contract/package.json.hbs +2 -0
- package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +2 -2
- package/templates/apps/api/apps/{{projectName}}-service/config/_serv.yml.hbs +4 -4
- package/templates/apps/api/apps/{{projectName}}-service/package.json.hbs +9 -2
- package/templates/apps/api/package.json.hbs +9 -0
- package/templates/apps/fullstack/.dockerignore.hbs +5 -3
- package/templates/apps/fullstack/.gitignore.hbs +22 -9
- package/templates/apps/fullstack/Dockerfile.hbs +11 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/package.json.hbs +6 -0
- package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +7 -3
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/package.json.hbs +5 -0
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +7 -3
- package/templates/apps/fullstack/apps/{{projectName}}-web/package.json.hbs +6 -0
- package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +7 -3
- package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +93 -1
- package/templates/apps/fullstack/package.json.hbs +12 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +2 -2
- package/templates/apps/fullstack/packages/{{projectName}}-contract/package.json.hbs +2 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +8 -14
- package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +6 -4
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_reach.yml.hbs +8 -8
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +3 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +9 -2
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +4 -4
- package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +6 -2
- package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +2 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +19 -13
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/settings.svelte.hbs +46 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +17 -3
- package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +15 -5
- package/templates/apps/fullstack/playwright.config.ts.hbs +6 -1
- package/templates/apps/fullstack/pnpm-workspace.yaml.hbs +0 -1
- package/templates/apps/h5/src/routes/+layout.svelte.hbs +13 -13
- package/templates/apps/mobile-app/src/App.svelte.hbs +46 -117
- package/templates/apps/mobile-app/src/lib/pages/home.svelte.hbs +58 -0
- package/templates/apps/mobile-app/src/lib/pages/profile.svelte.hbs +13 -0
- package/templates/apps/mobile-app/src/lib/pages/tasks.svelte.hbs +16 -0
- package/templates/base/.dockerignore.hbs +5 -0
- package/templates/base/.gitignore.hbs +18 -4
- package/templates/base/Dockerfile.hbs +17 -1
- package/templates/base/e2e/app.spec.ts.hbs +14 -0
- package/templates/base/package.json.hbs +14 -2
- package/templates/base/tests/scaffold.test.ts.hbs +9 -3
- package/templates/base/vite.config.ts.hbs +6 -4
- package/templates/shared/hash-router.svelte.ts +83 -0
- package/templates/skills/AGENTS.md +15 -1
- package/templates/skills/bridges/admin/AGENTS.md +17 -1
- package/templates/skills/bridges/api/AGENTS.md +17 -3
- package/templates/skills/bridges/fullstack/AGENTS.md.hbs +35 -11
- package/templates/skills/bridges/h5/AGENTS.md +17 -1
- package/templates/skills/bridges/mobile-app/AGENTS.md +19 -3
- package/templates/skills/bridges/website/AGENTS.md +17 -1
- package/templates/skills/hai-ai/reference.md +2 -2
- package/templates/skills/hai-app-create/SKILL.md +5 -3
- package/templates/skills/hai-app-create/development.md +126 -2
- package/templates/skills/hai-app-create/reference.md +2 -2
- package/templates/skills/hai-app-review/SKILL.md +2 -2
- package/templates/skills/hai-app-tests/SKILL.md +2 -1
- package/templates/skills/hai-app-tests/reference.md +1 -1
- package/templates/skills/hai-core/SKILL.md +2 -1
- package/templates/skills/hai-deploy/SKILL.md +42 -34
- package/templates/skills/hai-deploy/middleware.md +94 -0
- package/templates/skills/hai-framework-sync/SKILL.md +1 -1
- package/templates/skills/hai-framework-sync/sync.md +1 -1
- package/templates/skills/hai-reach/reference.md +8 -8
- package/templates/skills/hai-spec-implement/SKILL.md +12 -9
- package/templates/skills/hai-spec-implement/changes.md +67 -0
- package/templates/skills/hai-spec-implement/templates.md +25 -3
- package/templates/skills/hai-spec-implement/workflow.md +7 -6
- package/templates/skills/hai-spec-review/SKILL.md +14 -5
- package/templates/skills/hai-spec-review/checklist.md +4 -3
- package/templates/skills/hai-storage/SKILL.md +2 -0
- package/templates/skills/hai-ui/SKILL.md +4 -0
- package/dist/chunk-WW2MIO6T.js +0 -992
- package/dist/chunk-WW2MIO6T.js.map +0 -1
- package/dist/chunk-X3RSYNBG.js.map +0 -1
- package/dist/cli-config-templates-QRF6B5T4.js +0 -5
- package/dist/src-DUGLX4YB.js.map +0 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ProfileSettings.svelte.hbs +0 -57
- /package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/{components/AuditManagement.svelte.hbs → pages/audit.svelte.hbs} +0 -0
- /package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/{components/RoleManagement.svelte.hbs → pages/roles.svelte.hbs} +0 -0
- /package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/{components/UserManagement.svelte.hbs → pages/users.svelte.hbs} +0 -0
package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs
CHANGED
|
@@ -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'] })
|
package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs
CHANGED
|
@@ -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
|
|
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`
|
|
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: ${
|
|
11
|
-
# port: ${
|
|
10
|
+
# host: ${HAI_REACH_PROVIDERS_0_HOST:smtp.example.com}
|
|
11
|
+
# port: ${HAI_REACH_PROVIDERS_0_PORT:465}
|
|
12
12
|
# secure: true
|
|
13
|
-
# user: ${
|
|
14
|
-
# pass: ${
|
|
15
|
-
# from: ${
|
|
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: ${
|
|
20
|
-
# accessKeySecret: ${
|
|
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: '
|
|
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: ${
|
|
34
|
-
nativeOrigins: ${
|
|
35
|
-
credentials: ${
|
|
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
|
|
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
|
-
-
|
|
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"
|
|
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
|
|
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.
|
|
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={
|
|
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
|
-
<
|
|
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 ??
|
|
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
|
package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/settings.svelte.hbs
ADDED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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')
|
package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
18
|
+
url: frontendUrls.{{target}} || 'http://127.0.0.1:{{port}}',
|
|
14
19
|
},
|
|
15
20
|
{{/each}}
|
|
16
21
|
]
|
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
{
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
72
|
+
{/if}
|
|
73
73
|
</div>
|
|
74
|
-
|
|
74
|
+
</div>
|