@h-ai/cli 0.1.0-alpha.57 → 0.1.0-alpha.59

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 (177) hide show
  1. package/README.en.md +70 -21
  2. package/README.ja.md +70 -21
  3. package/README.md +70 -21
  4. package/dist/chunk-2H7UOFLK.js +0 -0
  5. package/dist/{chunk-O3ANVICQ.js → chunk-WUSFJFW5.js} +13 -7
  6. package/dist/chunk-WUSFJFW5.js.map +1 -0
  7. package/dist/{chunk-MJGUBOAV.js → chunk-WWG3U5ZD.js} +24 -42
  8. package/dist/chunk-WWG3U5ZD.js.map +1 -0
  9. package/dist/cli-config-templates-6XA7V6CC.js +5 -0
  10. package/dist/{cli-config-templates-OWBEGBZQ.js.map → cli-config-templates-6XA7V6CC.js.map} +1 -1
  11. package/dist/{cli-skill-templates-ZYXES3NT.js → cli-skill-templates-2KDUACT6.js} +3 -3
  12. package/dist/{cli-skill-templates-ZYXES3NT.js.map → cli-skill-templates-2KDUACT6.js.map} +1 -1
  13. package/dist/index.js +192 -125
  14. package/dist/index.js.map +1 -1
  15. package/dist/{src-TBIET7YU.js → src-DUGLX4YB.js} +7270 -2119
  16. package/dist/src-DUGLX4YB.js.map +1 -0
  17. package/package.json +19 -17
  18. package/templates/README.md +4 -4
  19. package/templates/apps/admin/src/routes/+layout.svelte.hbs +7 -0
  20. package/templates/apps/admin/src/routes/+page.svelte.hbs +5 -4
  21. package/templates/apps/admin/src/routes/admin/+layout.svelte.hbs +2 -2
  22. package/templates/apps/api/.npmrc.hbs +1 -2
  23. package/templates/apps/api/Dockerfile.hbs +6 -5
  24. package/templates/apps/api/apps/{{projectName}}-contract/package.json.hbs +1 -1
  25. package/templates/apps/api/apps/{{projectName}}-contract/src/app-schemas.ts.hbs +2 -0
  26. package/templates/apps/api/apps/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
  27. package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +8 -0
  28. package/templates/apps/api/apps/{{projectName}}-service/package.json.hbs +1 -1
  29. package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +7 -8
  30. package/templates/apps/api/apps/{{projectName}}-service/src/index.ts.hbs +4 -3
  31. package/templates/apps/api/apps/{{projectName}}-service/src/lib/server/init.ts.hbs +0 -1
  32. package/templates/apps/api/apps/{{projectName}}-service/src/server/procedures/app-procedures.ts.hbs +4 -0
  33. package/templates/apps/api/apps/{{projectName}}-service/tests/service.test.ts.hbs +3 -1
  34. package/templates/apps/api/e2e/api-service.spec.ts.hbs +3 -1
  35. package/templates/apps/api/package.json.hbs +2 -2
  36. package/templates/apps/api/pnpm-workspace.yaml.hbs +26 -0
  37. package/templates/apps/api/tsconfig.base.json +0 -1
  38. package/templates/apps/fullstack/.dockerignore.hbs +3 -0
  39. package/templates/apps/fullstack/.npmrc.hbs +1 -2
  40. package/templates/apps/fullstack/Dockerfile.hbs +6 -5
  41. package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +2 -2
  42. package/templates/apps/fullstack/apps/{{projectName}}-app/capacitor.config.ts.hbs +1 -1
  43. package/templates/apps/fullstack/apps/{{projectName}}-app/index.html.hbs +7 -2
  44. package/templates/apps/fullstack/apps/{{projectName}}-app/messages/en-US.json.hbs +1 -34
  45. package/templates/apps/fullstack/apps/{{projectName}}-app/messages/zh-CN.json.hbs +1 -34
  46. package/templates/apps/fullstack/apps/{{projectName}}-app/package.json.hbs +2 -2
  47. package/templates/apps/fullstack/apps/{{projectName}}-app/src/App.svelte.hbs +30 -170
  48. package/templates/apps/fullstack/apps/{{projectName}}-app/src/app.d.ts.hbs +1 -1
  49. package/templates/apps/fullstack/apps/{{projectName}}-app/src/lib/capacitor.ts.hbs +6 -5
  50. package/templates/apps/fullstack/apps/{{projectName}}-app/src/main.ts.hbs +1 -1
  51. package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +48 -22
  52. package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +3 -1
  53. package/templates/apps/fullstack/apps/{{projectName}}-desktop/index.html.hbs +6 -1
  54. package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/en-US.json.hbs +1 -28
  55. package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/zh-CN.json.hbs +1 -28
  56. package/templates/apps/fullstack/apps/{{projectName}}-desktop/package.json.hbs +5 -4
  57. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src/App.svelte.hbs +10 -141
  58. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/Cargo.toml.hbs +5 -5
  59. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/tauri.conf.json.hbs +3 -3
  60. package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +21 -2
  61. package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +2 -2
  62. package/templates/apps/fullstack/apps/{{projectName}}-web/index.html.hbs +6 -1
  63. package/templates/apps/fullstack/apps/{{projectName}}-web/messages/en-US.json.hbs +1 -28
  64. package/templates/apps/fullstack/apps/{{projectName}}-web/messages/zh-CN.json.hbs +1 -28
  65. package/templates/apps/fullstack/apps/{{projectName}}-web/package.json.hbs +2 -2
  66. package/templates/apps/fullstack/apps/{{projectName}}-web/src/App.svelte.hbs +5 -143
  67. package/templates/apps/fullstack/apps/{{projectName}}-web/src/app.d.ts.hbs +1 -1
  68. package/templates/apps/fullstack/apps/{{projectName}}-web/src/main.ts.hbs +1 -1
  69. package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +48 -22
  70. package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +206 -16
  71. package/templates/apps/fullstack/package.json.hbs +2 -2
  72. package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +2 -0
  73. package/templates/apps/fullstack/packages/{{projectName}}-contract/package.json.hbs +1 -1
  74. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +66 -0
  75. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-schemas.ts.hbs +96 -0
  76. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-schemas.ts.hbs +3 -0
  77. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +3 -1
  78. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +5 -0
  79. package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +3 -0
  80. package/templates/apps/fullstack/packages/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
  81. package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +13 -3
  82. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_audit.yml.hbs +3 -0
  83. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_cache.yml.hbs +2 -0
  84. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +10 -3
  85. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_db.yml.hbs +3 -0
  86. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_iam.yml.hbs +21 -0
  87. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +10 -3
  88. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_storage.yml.hbs +3 -0
  89. package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +10 -3
  90. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +63 -28
  91. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/index.ts.hbs +26 -11
  92. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs +276 -0
  93. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/messages.ts.hbs +29 -0
  94. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +94 -0
  95. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/app-procedures.ts.hbs +6 -0
  96. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/index.ts.hbs +5 -1
  97. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/runtime.ts.hbs +98 -0
  98. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +446 -33
  99. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/runtime.test.ts.hbs +48 -0
  100. package/templates/apps/fullstack/packages/{{projectName}}-serv/tsup.config.ts.hbs +1 -1
  101. package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +19 -26
  102. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/en-US.json.hbs +78 -15
  103. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/zh-CN.json.hbs +78 -15
  104. package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +4 -1
  105. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/index.ts.hbs +3 -4
  106. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +53 -27
  107. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +73 -0
  108. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +55 -45
  109. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AuditManagement.svelte.hbs +61 -0
  110. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ProfileSettings.svelte.hbs +57 -0
  111. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/RoleManagement.svelte.hbs +49 -0
  112. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/UserManagement.svelte.hbs +174 -0
  113. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +399 -0
  114. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/theme-store.svelte.ts.hbs +7 -39
  115. package/templates/apps/fullstack/packages/{{projectName}}-shared/svelte.config.js.hbs +1 -0
  116. package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/api-client.test.ts.hbs +151 -0
  117. package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +101 -0
  118. package/templates/apps/fullstack/packages/{{projectName}}-shared/vitest.config.ts.hbs +7 -0
  119. package/templates/apps/fullstack/playwright.config.ts.hbs +6 -0
  120. package/templates/apps/fullstack/pnpm-workspace.yaml.hbs +24 -0
  121. package/templates/apps/fullstack/tsconfig.base.json +0 -1
  122. package/templates/apps/h5/src/routes/+layout.svelte.hbs +4 -0
  123. package/templates/apps/h5/src/routes/+page.svelte.hbs +3 -2
  124. package/templates/apps/mobile-app/index.html.hbs +2 -2
  125. package/templates/apps/mobile-app/src/App.svelte.hbs +6 -2
  126. package/templates/apps/mobile-app/src/lib/app-config.ts.hbs +13 -0
  127. package/templates/apps/website/src/routes/+layout.svelte.hbs +8 -2
  128. package/templates/apps/website/src/routes/+page.svelte.hbs +4 -3
  129. package/templates/base/.npmrc.hbs +1 -15
  130. package/templates/base/Dockerfile.hbs +9 -8
  131. package/templates/base/e2e/app.spec.ts.hbs +1 -0
  132. package/templates/base/package.json.hbs +39 -42
  133. package/templates/base/pnpm-workspace.yaml.hbs +26 -0
  134. package/templates/base/src/app.d.ts.hbs +4 -0
  135. package/templates/base/src/hooks.server.ts.hbs +0 -8
  136. package/templates/base/src/lib/server/init.ts.hbs +26 -5
  137. package/templates/base/src/routes/+layout.server.ts.hbs +18 -0
  138. package/templates/base/tests/scaffold.test.ts.hbs +21 -1
  139. package/templates/base/vite.config.ts.hbs +21 -0
  140. package/templates/features/ai/routes/api/chat/+server.ts +6 -7
  141. package/templates/features/iam/routes-admin/(auth)/auth/login/+page.svelte.hbs +1 -1
  142. package/templates/features/iam/routes-h5/auth/login/+page.svelte.hbs +1 -1
  143. package/templates/features/iam/routes-shared/api/auth/login/+server.ts +14 -10
  144. package/templates/features/iam/routes-shared/api/auth/logout/+server.ts +6 -4
  145. package/templates/features/storage/routes/api/upload/+server.ts +10 -8
  146. package/templates/skills/CLAUDE.md +0 -14
  147. package/templates/skills/bridges/fullstack/AGENTS.md.hbs +13 -2
  148. package/templates/skills/bridges/fullstack/copilot-instructions.md.hbs +3 -0
  149. package/templates/skills/hai-api-client/SKILL.md +1 -1
  150. package/templates/skills/hai-app-create/SKILL.md +2 -0
  151. package/templates/skills/hai-app-create/development.md +21 -0
  152. package/templates/skills/hai-build/reference.md +4 -2
  153. package/templates/skills/hai-core/SKILL.md +2 -0
  154. package/templates/skills/hai-core/reference.md +6 -4
  155. package/templates/skills/hai-deploy/SKILL.md +2 -0
  156. package/templates/skills/hai-framework-sync/SKILL.md +5 -2
  157. package/templates/skills/hai-serv/SKILL.md +1 -0
  158. package/templates/skills/hai-serv/reference.md +3 -1
  159. package/templates/skills/hai-ui/SKILL.md +6 -0
  160. package/templates/skills/hai-ui/reference.md +7 -4
  161. package/templates/skills/hai-vecdb/reference.md +53 -49
  162. package/dist/chunk-MJGUBOAV.js.map +0 -1
  163. package/dist/chunk-O3ANVICQ.js.map +0 -1
  164. package/dist/cli-config-templates-OWBEGBZQ.js +0 -5
  165. package/dist/src-TBIET7YU.js.map +0 -1
  166. package/templates/apps/fullstack/apps/{{projectName}}-app/tests/api.test.ts.hbs +0 -28
  167. package/templates/apps/fullstack/apps/{{projectName}}-desktop/tests/api.test.ts.hbs +0 -28
  168. package/templates/apps/fullstack/apps/{{projectName}}-web/tests/api.test.ts.hbs +0 -55
  169. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/LanguageSwitcher.svelte.hbs +0 -27
  170. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ThemeSwitcher.svelte.hbs +0 -18
  171. package/templates/skills/bridges/admin/CLAUDE.md +0 -15
  172. package/templates/skills/bridges/api/CLAUDE.md +0 -15
  173. package/templates/skills/bridges/fullstack/CLAUDE.md.hbs +0 -9
  174. package/templates/skills/bridges/h5/CLAUDE.md +0 -15
  175. package/templates/skills/bridges/mobile-app/CLAUDE.md +0 -15
  176. package/templates/skills/bridges/website/CLAUDE.md +0 -15
  177. package/templates/skills/claude-skill.md.hbs +0 -7
@@ -0,0 +1,399 @@
1
+ import type { HaiResult } from '@h-ai/core'
2
+ import type { ChangePasswordFormData, LoginFormData, UserProfileSubmitData } from '@h-ai/ui'
3
+ import type { AdminAuditLog, AdminCreateUser, AdminRole, AdminUpdateUser, AdminUser, AvatarUpload, ProfileUpdate } from '{{fullstack.contractPackageName}}'
4
+ import type { AppInfo, ManagementApiOptions } from '../api/api-client.js'
5
+ import { HaiApiClientError } from '@h-ai/api-client'
6
+ import { core } from '@h-ai/core'
7
+ import { SvelteDate } from 'svelte/reactivity'
8
+ import { initializeManagementApi, managementApi, resolveAvatarUrl } from '../api/api-client.js'
9
+ import * as m from '../paraglide/messages.js'
10
+ import { getLocale, locales, setLocale } from '../paraglide/runtime.js'
11
+ import { AVAILABLE_THEMES, DEFAULT_THEME, themeStore } from './theme-store.svelte.js'
12
+
13
+ type ResultData<T> = T extends { success: true, data: infer D } ? D : never
14
+ export type CurrentUser = ResultData<Awaited<ReturnType<typeof managementApi.iam.auth.currentUser>>>
15
+ export type ManagementPage = 'users' | 'roles' | 'audit' | 'settings'
16
+
17
+ /** 页面状态和业务操作在共享 store 中收敛,宿主端仅装配平台能力。 */
18
+ export class ManagementStore {
19
+ info = $state<AppInfo | null>(null)
20
+ user = $state<CurrentUser | null>(null)
21
+ page = $state<ManagementPage>('settings')
22
+ locale = $state(getLocale())
23
+ initializing = $state(true)
24
+ loading = $state(false)
25
+ saving = $state(false)
26
+ error = $state('')
27
+ notice = $state('')
28
+ users = $state<AdminUser[]>([])
29
+ usersTotal = $state(0)
30
+ usersPage = $state(1)
31
+ usersSearch = $state('')
32
+ usersStatus = $state('all')
33
+ roles = $state<AdminRole[]>([])
34
+ audit = $state<AdminAuditLog[]>([])
35
+ auditTotal = $state(0)
36
+ auditPage = $state(1)
37
+ auditAction = $state('')
38
+ auditResource = $state('')
39
+ auditStartDate = $state('')
40
+ auditEndDate = $state('')
41
+ readonly pageSize = 20
42
+ private requestVersion = 0
43
+ private sessionEpoch = 0
44
+ private onLocaleChange?: (locale: string) => void
45
+
46
+ get isAdmin(): boolean {
47
+ return this.user?.roles.includes('admin') ?? false
48
+ }
49
+
50
+ async initialize(options: ManagementApiOptions, onLocaleChange?: (locale: string) => void): Promise<void> {
51
+ this.onLocaleChange = onLocaleChange
52
+ this.initializing = true
53
+ this.error = ''
54
+ try {
55
+ const init = await initializeManagementApi({ ...options, onSessionExpired: () => this.expireSession() })
56
+ if (!init.success) {
57
+ this.error = init.error.code === HaiApiClientError.CONFIG_ERROR.code ? m.api_not_configured() : m.home_error()
58
+ return
59
+ }
60
+ const info = await managementApi.app.info()
61
+ if (!info.success) {
62
+ this.error = m.home_error()
63
+ return
64
+ }
65
+ this.info = info.data
66
+ this.applyLocale(info.data.defaultLocale)
67
+ // 默认 Cookie 存储通过 api-client 的加密刷新链路恢复会话。
68
+ const me = await managementApi.iam.auth.currentUser()
69
+ if (me.success) {
70
+ this.setUser(me.data)
71
+ await this.navigate(this.isAdmin ? 'users' : 'settings')
72
+ }
73
+ else if (me.error.httpStatus !== 401) {
74
+ this.error = m.operation_failed()
75
+ }
76
+ }
77
+ catch {
78
+ this.error = m.home_error()
79
+ }
80
+ finally {
81
+ this.initializing = false
82
+ }
83
+ }
84
+
85
+ async close(): Promise<void> {
86
+ this.requestVersion++
87
+ this.sessionEpoch++
88
+ this.loading = false
89
+ await managementApi.close()
90
+ }
91
+
92
+ async login(data: LoginFormData): Promise<void> {
93
+ await this.mutate(async () => {
94
+ const login = await managementApi.iam.auth.login({ identifier: data.username, password: data.password })
95
+ if (!login.success) {
96
+ this.error = login.error.httpStatus && login.error.httpStatus < 500 ? m.login_invalid() : m.network_error()
97
+ return
98
+ }
99
+ await managementApi.auth.setTokens(login.data.tokens)
100
+ const me = await this.request(() => managementApi.iam.auth.currentUser())
101
+ if (!me?.success)
102
+ return
103
+ this.setUser(me.data)
104
+ await this.navigate(this.isAdmin ? 'users' : 'settings')
105
+ })
106
+ }
107
+
108
+ async logout(): Promise<void> {
109
+ await this.mutate(async () => {
110
+ const result = await this.request(() => managementApi.iam.auth.logout({}))
111
+ if (!result?.success)
112
+ return
113
+ await managementApi.auth.clear()
114
+ this.clearSession()
115
+ })
116
+ }
117
+
118
+ async navigate(page: ManagementPage): Promise<void> {
119
+ if (!this.user)
120
+ return
121
+ this.requestVersion++
122
+ this.loading = false
123
+ this.page = this.isAdmin ? page : 'settings'
124
+ this.error = ''
125
+ this.notice = ''
126
+ if (this.page === 'users')
127
+ await this.loadUsers()
128
+ else if (this.page === 'roles')
129
+ await this.loadRoles()
130
+ else if (this.page === 'audit')
131
+ await this.loadAudit()
132
+ }
133
+
134
+ async loadUsers(page = 1): Promise<void> {
135
+ await this.load(async (version) => {
136
+ const result = await this.request(() => managementApi.admin.users.list({
137
+ page,
138
+ pageSize: this.pageSize,
139
+ search: this.usersSearch.trim() || undefined,
140
+ enabled: this.usersStatus === 'all' ? undefined : this.usersStatus === 'enabled',
141
+ }), version)
142
+ if (result?.success && version === this.requestVersion) {
143
+ this.users = result.data.items.map(user => ({ ...user, avatarUrl: resolveAvatarUrl(user.avatarUrl) }))
144
+ this.usersTotal = result.data.total
145
+ this.usersPage = result.data.page
146
+ }
147
+ })
148
+ }
149
+
150
+ async loadRoles(): Promise<void> {
151
+ await this.load(async (version) => {
152
+ const result = await this.request(() => managementApi.admin.roles.list(), version)
153
+ if (result?.success && version === this.requestVersion)
154
+ this.roles = result.data.items
155
+ })
156
+ }
157
+
158
+ async loadAudit(page = 1): Promise<void> {
159
+ if (this.auditStartDate && this.auditEndDate && this.auditStartDate > this.auditEndDate) {
160
+ this.error = m.audit_date_invalid()
161
+ return
162
+ }
163
+ await this.load(async (version) => {
164
+ const result = await this.request(() => managementApi.admin.audit.list({
165
+ page,
166
+ pageSize: this.pageSize,
167
+ action: this.auditAction.trim() || undefined,
168
+ resource: this.auditResource.trim() || undefined,
169
+ startDate: this.auditStartDate ? new SvelteDate(`${this.auditStartDate}T00:00:00`).toISOString() : undefined,
170
+ endDate: this.auditEndDate ? new SvelteDate(`${this.auditEndDate}T23:59:59.999`).toISOString() : undefined,
171
+ }), version)
172
+ if (result?.success && version === this.requestVersion) {
173
+ this.audit = result.data.items
174
+ this.auditTotal = result.data.total
175
+ this.auditPage = result.data.page
176
+ }
177
+ })
178
+ }
179
+
180
+ async saveUser(data: AdminCreateUser | AdminUpdateUser): Promise<boolean> {
181
+ let saved = false
182
+ await this.mutate(async () => {
183
+ const result = 'id' in data
184
+ ? await this.request(() => managementApi.admin.users.update(data))
185
+ : await this.request(() => managementApi.admin.users.create(data))
186
+ if (!result?.success)
187
+ return
188
+ saved = true
189
+ await this.loadUsers(this.usersPage)
190
+ this.notice = m.saved()
191
+ })
192
+ return saved
193
+ }
194
+
195
+ async deleteUser(user: AdminUser): Promise<boolean> {
196
+ let deleted = false
197
+ await this.mutate(async () => {
198
+ const result = await this.request(() => managementApi.admin.users.delete({ id: user.id }))
199
+ if (!result?.success)
200
+ return
201
+ deleted = true
202
+ const page = this.users.length === 1 ? Math.max(1, this.usersPage - 1) : this.usersPage
203
+ await this.loadUsers(page)
204
+ this.notice = m.user_deleted()
205
+ })
206
+ return deleted
207
+ }
208
+
209
+ async saveRole(id: string, description: string): Promise<boolean> {
210
+ let saved = false
211
+ await this.mutate(async () => {
212
+ const result = await this.request(() => managementApi.admin.roles.update({ id, description }))
213
+ if (!result?.success)
214
+ return
215
+ saved = true
216
+ await this.loadRoles()
217
+ this.notice = m.saved()
218
+ })
219
+ return saved
220
+ }
221
+
222
+ async saveProfile(data: UserProfileSubmitData): Promise<void> {
223
+ await this.savePreferences({ displayName: data.displayName?.trim() ?? '', email: data.email?.trim() ?? '' })
224
+ }
225
+
226
+ async savePreferences(data: ProfileUpdate): Promise<void> {
227
+ await this.mutate(async () => {
228
+ const result = await this.request(() => managementApi.profile.update(data))
229
+ if (!result?.success)
230
+ return
231
+ const me = await this.request(() => managementApi.iam.auth.currentUser())
232
+ if (me?.success)
233
+ this.setUser(me.data)
234
+ this.notice = m.saved()
235
+ })
236
+ }
237
+
238
+ async changePassword(data: ChangePasswordFormData): Promise<void> {
239
+ await this.mutate(async () => {
240
+ if (!data.oldPassword || data.newPassword === data.oldPassword || data.newPassword !== data.confirmPassword || data.newPassword.length < 8) {
241
+ this.error = m.validation_failed()
242
+ return
243
+ }
244
+ const oldPassword = data.oldPassword
245
+ const result = await this.request(() => managementApi.iam.auth.changePassword({ oldPassword, newPassword: data.newPassword }))
246
+ if (!result?.success)
247
+ return
248
+ await managementApi.auth.clear()
249
+ this.clearSession()
250
+ this.notice = m.password_changed()
251
+ })
252
+ }
253
+
254
+ async uploadAvatar(file: File, userId?: string): Promise<void> {
255
+ await this.mutate(async () => {
256
+ const payload = await this.readAvatar(file)
257
+ if (!payload)
258
+ return
259
+ const result = userId
260
+ ? await this.request(() => managementApi.admin.users.avatar({ id: userId, ...payload }))
261
+ : await this.request(() => managementApi.profile.avatar(payload))
262
+ if (!result?.success)
263
+ return
264
+ if (!userId || userId === this.user?.id) {
265
+ const me = await this.request(() => managementApi.iam.auth.currentUser())
266
+ if (me?.success)
267
+ this.setUser(me.data)
268
+ }
269
+ if (userId)
270
+ await this.loadUsers(this.usersPage)
271
+ this.notice = m.avatar_saved()
272
+ })
273
+ }
274
+
275
+ formatDate(value: string): string {
276
+ return new Intl.DateTimeFormat(this.locale, { dateStyle: 'medium', timeStyle: 'short' }).format(new SvelteDate(value))
277
+ }
278
+
279
+ private async readAvatar(file: File): Promise<AvatarUpload | undefined> {
280
+ if (file.size > 512 * 1024 || !['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
281
+ this.error = m.avatar_invalid()
282
+ return undefined
283
+ }
284
+ const bytes = new Uint8Array(await file.arrayBuffer())
285
+ let binary = ''
286
+ for (const byte of bytes)
287
+ binary += String.fromCharCode(byte)
288
+ return { contentType: file.type as AvatarUpload['contentType'], base64: btoa(binary) }
289
+ }
290
+
291
+ private setUser(user: CurrentUser): void {
292
+ if (this.user?.id !== user.id)
293
+ this.sessionEpoch++
294
+ this.user = { ...user, avatarUrl: resolveAvatarUrl(user.avatarUrl) }
295
+ this.applyLocale(typeof user.metadata?.locale === 'string' ? user.metadata.locale : this.info?.defaultLocale ?? getLocale())
296
+ if (typeof user.metadata?.theme === 'string' && AVAILABLE_THEMES.includes(user.metadata.theme))
297
+ themeStore.set(user.metadata.theme)
298
+ else
299
+ themeStore.set(DEFAULT_THEME)
300
+ }
301
+
302
+ private applyLocale(value: string): void {
303
+ const locale = locales.find(locale => locale === value)
304
+ if (!locale)
305
+ return
306
+ this.locale = locale
307
+ setLocale(locale, { reload: false })
308
+ core.i18n.setGlobalLocale(locale)
309
+ if (typeof document !== 'undefined')
310
+ document.documentElement.lang = locale
311
+ this.onLocaleChange?.(locale)
312
+ }
313
+
314
+ private clearSession(): void {
315
+ this.requestVersion++
316
+ this.sessionEpoch++
317
+ this.loading = false
318
+ this.user = null
319
+ this.users = []
320
+ this.roles = []
321
+ this.audit = []
322
+ this.page = 'settings'
323
+ }
324
+
325
+ private expireSession(): void {
326
+ if (!this.user)
327
+ return
328
+ this.clearSession()
329
+ this.error = m.session_expired()
330
+ void managementApi.auth.clear()
331
+ }
332
+
333
+ private async request<T>(task: () => Promise<HaiResult<T>>, version?: number): Promise<HaiResult<T> | undefined> {
334
+ const epoch = this.sessionEpoch
335
+ const isCurrent = () => epoch === this.sessionEpoch && (version === undefined || version === this.requestVersion)
336
+ try {
337
+ const result = await task()
338
+ if (!isCurrent())
339
+ return undefined
340
+ if (!result.success) {
341
+ if (result.error.httpStatus === 401 && this.user) {
342
+ this.expireSession()
343
+ }
344
+ else if (result.error.httpStatus === 403 && this.user) {
345
+ const current = await managementApi.iam.auth.currentUser()
346
+ if (!isCurrent())
347
+ return undefined
348
+ if (current.success) {
349
+ this.setUser(current.data)
350
+ if (!this.isAdmin) {
351
+ this.requestVersion++
352
+ this.loading = false
353
+ this.users = []
354
+ this.roles = []
355
+ this.audit = []
356
+ this.page = 'settings'
357
+ }
358
+ }
359
+ this.error = m.forbidden()
360
+ }
361
+ else {
362
+ this.error = result.error.httpStatus === 403 ? m.forbidden() : result.error.httpStatus === 409 ? m.conflict() : result.error.httpStatus === 400 || result.error.httpStatus === 422 ? m.validation_failed() : m.operation_failed()
363
+ }
364
+ }
365
+ return result
366
+ }
367
+ catch {
368
+ if (isCurrent())
369
+ this.error = m.network_error()
370
+ return undefined
371
+ }
372
+ }
373
+
374
+ private async load(task: (version: number) => Promise<void>): Promise<void> {
375
+ const version = ++this.requestVersion
376
+ this.loading = true
377
+ this.error = ''
378
+ await task(version)
379
+ if (version === this.requestVersion)
380
+ this.loading = false
381
+ }
382
+
383
+ private async mutate(task: () => Promise<void>): Promise<void> {
384
+ if (this.saving)
385
+ return
386
+ this.saving = true
387
+ this.error = ''
388
+ this.notice = ''
389
+ try {
390
+ await task()
391
+ }
392
+ catch {
393
+ this.error = m.network_error()
394
+ }
395
+ finally {
396
+ this.saving = false
397
+ }
398
+ }
399
+ }
@@ -1,54 +1,22 @@
1
- {{!-- Shared theme store for {{projectName}} fullstack apps. --}}
2
- const STORAGE_KEY = 'hai-theme'
3
- const DEFAULT_THEME = '{{fullstack.theme.defaultTheme}}'
4
- export const AVAILABLE_THEMES: readonly string[] = [{{#each fullstack.theme.themes}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}]
5
-
6
- function isBrowser(): boolean {
7
- return typeof window !== 'undefined' && typeof document !== 'undefined'
8
- }
1
+ import { applyTheme, getSavedTheme } from '@h-ai/ui/theme'
9
2
 
10
- function readInitialTheme(): string {
11
- if (!isBrowser())
12
- return DEFAULT_THEME
13
- try {
14
- const stored = window.localStorage.getItem(STORAGE_KEY)
15
- if (stored && AVAILABLE_THEMES.includes(stored))
16
- return stored
17
- }
18
- catch {
19
- // localStorage 不可用时(隐私模式 / SSR),回退到默认主题
20
- }
21
- return DEFAULT_THEME
22
- }
3
+ export const DEFAULT_THEME = '{{fullstack.theme.defaultTheme}}'
4
+ export const AVAILABLE_THEMES: readonly string[] = [{{#each fullstack.theme.themes}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}]
23
5
 
6
+ /** 只维护响应式状态和已编译主题白名单,DOM 与安全持久化复用 UI 公共工具。 */
24
7
  class ThemeState {
25
8
  current = $state<string>(DEFAULT_THEME)
26
9
 
27
10
  constructor() {
28
- this.current = readInitialTheme()
29
- if (isBrowser())
30
- this.apply(this.current)
11
+ const saved = getSavedTheme(DEFAULT_THEME)
12
+ this.set(AVAILABLE_THEMES.includes(saved) ? saved : DEFAULT_THEME)
31
13
  }
32
14
 
33
15
  set(value: string): void {
34
16
  if (!AVAILABLE_THEMES.includes(value))
35
17
  return
36
18
  this.current = value
37
- this.apply(value)
38
- if (isBrowser()) {
39
- try {
40
- window.localStorage.setItem(STORAGE_KEY, value)
41
- }
42
- catch {
43
- // 忽略 localStorage 写入失败
44
- }
45
- }
46
- }
47
-
48
- private apply(value: string): void {
49
- if (!isBrowser())
50
- return
51
- document.documentElement.dataset.theme = value
19
+ applyTheme(value)
52
20
  }
53
21
  }
54
22
 
@@ -0,0 +1,151 @@
1
+ import type { EncryptedPayload } from '@h-ai/crypto'
2
+ import { HaiApiClientError } from '@h-ai/api-client'
3
+ import { crypto, TRANSPORT_PROTOCOL } from '@h-ai/crypto'
4
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
5
+ import { initializeManagementApi, managementApi, resolveAvatarUrl } from '../src/lib/api/api-client.js'
6
+
7
+ const runtimeInfo = {
8
+ name: 'Configured workspace',
9
+ version: '9.8.7',
10
+ env: 'test',
11
+ defaultLocale: 'zh-CN',
12
+ supportedLocales: ['zh-CN', 'en-US'],
13
+ startedAt: '2026-01-01T00:00:00.000Z',
14
+ frontends: ['web'],
15
+ }
16
+
17
+ /** 使用真实 crypto 公共入口模拟对端,覆盖握手及双向加解密。 */
18
+ function createServerFetch() {
19
+ const created = crypto.transport.createServer()
20
+ if (!created.success)
21
+ throw new Error(created.error.message)
22
+ const server = created.data
23
+ const requests: Request[] = []
24
+ const messages: string[] = []
25
+ const fetchFn: typeof fetch = async (input, init) => {
26
+ const request = new Request(input, init)
27
+ requests.push(request.clone())
28
+ const url = new URL(request.url)
29
+ if (url.pathname.endsWith('/_hai/key-exchange')) {
30
+ const body = await request.json() as { clientPublicKey: string }
31
+ const clientId = await server.registerClientKey(body.clientPublicKey)
32
+ return Response.json({ serverPublicKey: server.getServerPublicKey(), clientId })
33
+ }
34
+ const clientId = request.headers.get(TRANSPORT_PROTOCOL.CLIENT_ID_HEADER)
35
+ if (!clientId)
36
+ return Response.json({ success: false }, { status: 400 })
37
+ let data: unknown = runtimeInfo
38
+ if (url.pathname.endsWith('/app/echo')) {
39
+ expect(request.headers.get(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER)).toBe(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER_VALUE)
40
+ const encrypted = await request.json() as EncryptedPayload
41
+ expect(JSON.stringify(encrypted)).not.toContain('private message')
42
+ const decrypted = server.decryptRequest(encrypted)
43
+ expect(decrypted.success).toBe(true)
44
+ if (!decrypted.success)
45
+ throw new Error(decrypted.error.message)
46
+ const payload = JSON.parse(decrypted.data) as { message: string }
47
+ messages.push(payload.message)
48
+ data = { message: payload.message, echoedAt: '2026-01-01T00:00:00.000Z', requestId: 'test-request' }
49
+ }
50
+ const response = await server.encryptResponse(clientId, JSON.stringify({ success: true, data }))
51
+ if (!response.success)
52
+ throw new Error(response.error.message)
53
+ return Response.json(response.data, { headers: { [TRANSPORT_PROTOCOL.ENCRYPTED_HEADER]: TRANSPORT_PROTOCOL.ENCRYPTED_HEADER_VALUE } })
54
+ }
55
+ return { fetchFn, requests, messages }
56
+ }
57
+
58
+ describe('shared API transport', () => {
59
+ beforeAll(async () => {
60
+ expect((await crypto.init()).success).toBe(true)
61
+ })
62
+ afterAll(async () => {
63
+ await managementApi.close()
64
+ await crypto.close()
65
+ })
66
+
67
+ it('decrypts configured core metadata and includes refresh cookies', async () => {
68
+ const server = createServerFetch()
69
+ expect((await initializeManagementApi({ fetchFn: server.fetchFn, baseUrl: 'https://api.example.com', clientName: 'configured-client' })).success).toBe(true)
70
+ const info = await managementApi.app.info()
71
+ expect(info.success).toBe(true)
72
+ if (info.success)
73
+ expect(info.data).toEqual(runtimeInfo)
74
+ expect(server.requests.every(request => request.credentials === 'include')).toBe(true)
75
+ expect(server.requests.at(-1)?.headers.get('x-request-client')).toBe('configured-client')
76
+ })
77
+
78
+ it('uses the API origin injected at container startup', async () => {
79
+ Object.assign(globalThis, { __HAI_PUBLIC_CONFIG__: { apiBase: 'https://runtime.example.com' } })
80
+ const server = createServerFetch()
81
+ try {
82
+ expect((await initializeManagementApi({ fetchFn: server.fetchFn })).success).toBe(true)
83
+ expect((await managementApi.app.info()).success).toBe(true)
84
+ expect(server.requests.every(request => request.url.startsWith('https://runtime.example.com/api/v1/'))).toBe(true)
85
+ }
86
+ finally {
87
+ Reflect.deleteProperty(globalThis, '__HAI_PUBLIC_CONFIG__')
88
+ }
89
+ })
90
+
91
+ it('encrypts business input and preserves the native token transport header', async () => {
92
+ const server = createServerFetch()
93
+ const init = await initializeManagementApi({ fetchFn: server.fetchFn, baseUrl: 'https://api.example.com/api/v1', headers: { 'X-HAI-Token-Transport': 'body' } })
94
+ expect(init.success).toBe(true)
95
+ try {
96
+ const result = await managementApi.app.echo({ message: 'private message' })
97
+ expect(result.success).toBe(true)
98
+ if (result.success)
99
+ expect(result.data.message).toBe('private message')
100
+ expect(server.messages).toEqual(['private message'])
101
+ expect(server.requests.at(-1)?.headers.get('X-HAI-Token-Transport')).toBe('body')
102
+ }
103
+ finally {
104
+ await managementApi.close()
105
+ }
106
+ })
107
+
108
+ it('resolves avatars only under the configured API origin', async () => {
109
+ const server = createServerFetch()
110
+ expect((await initializeManagementApi({ fetchFn: server.fetchFn, baseUrl: 'https://trusted.example.com/api/v1' })).success).toBe(true)
111
+ try {
112
+ const path = '/api/v1/assets/avatars/user-id/asset-id.png'
113
+ expect(resolveAvatarUrl(path)).toBe(`https://trusted.example.com${path}`)
114
+ expect(resolveAvatarUrl(`https://host-injection.example.com${path}`)).toBe(`https://trusted.example.com${path}`)
115
+ expect(resolveAvatarUrl('https://host-injection.example.com/pixel.png')).toBeUndefined()
116
+ expect(resolveAvatarUrl('/api/v1/assets/avatars/../secrets.txt')).toBeUndefined()
117
+ expect(resolveAvatarUrl('data:image/svg+xml,<svg/>')).toBeUndefined()
118
+ }
119
+ finally {
120
+ await managementApi.close()
121
+ }
122
+ })
123
+
124
+ it('requires an explicit API origin for a native build', async () => {
125
+ vi.stubEnv('PUBLIC_API_BASE', '')
126
+ try {
127
+ const result = await initializeManagementApi({ requireApiBase: true })
128
+ expect(result.success).toBe(false)
129
+ if (!result.success)
130
+ expect(result.error.code).toBe(HaiApiClientError.CONFIG_ERROR.code)
131
+ }
132
+ finally {
133
+ vi.unstubAllEnvs()
134
+ }
135
+ })
136
+
137
+ it('preserves a configured API prefix for transport and avatars', async () => {
138
+ const server = createServerFetch()
139
+ expect((await initializeManagementApi({ fetchFn: server.fetchFn, baseUrl: 'https://trusted.example.com/custom-api/' })).success).toBe(true)
140
+ try {
141
+ expect((await managementApi.app.info()).success).toBe(true)
142
+ expect(server.requests.every(request => request.url.startsWith('https://trusted.example.com/custom-api/'))).toBe(true)
143
+ const avatar = '/custom-api/assets/avatars/user-id/asset-id.png'
144
+ expect(resolveAvatarUrl(avatar)).toBe(`https://trusted.example.com${avatar}`)
145
+ expect(resolveAvatarUrl('/api/v1/assets/avatars/user-id/asset-id.png')).toBeUndefined()
146
+ }
147
+ finally {
148
+ await managementApi.close()
149
+ }
150
+ })
151
+ })