@h-ai/cli 0.1.0-alpha.64 → 0.1.0-alpha.66

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 (122) hide show
  1. package/README.en.md +31 -9
  2. package/README.ja.md +30 -8
  3. package/README.md +38 -16
  4. package/dist/{chunk-X3RSYNBG.js → chunk-FWN5B54L.js} +94 -4
  5. package/dist/chunk-FWN5B54L.js.map +1 -0
  6. package/dist/chunk-QYX6V7H2.js +5613 -0
  7. package/dist/chunk-QYX6V7H2.js.map +1 -0
  8. package/dist/cli-config-templates-3XSFIXBP.js +5 -0
  9. package/dist/{cli-config-templates-QRF6B5T4.js.map → cli-config-templates-3XSFIXBP.js.map} +1 -1
  10. package/dist/{cli-skill-templates-EZFW7D43.js → cli-skill-templates-TSYEQTCY.js} +3 -3
  11. package/dist/{cli-skill-templates-EZFW7D43.js.map → cli-skill-templates-TSYEQTCY.js.map} +1 -1
  12. package/dist/index.js +733 -210
  13. package/dist/index.js.map +1 -1
  14. package/dist/{src-DUGLX4YB.js → src-RDQ2EIBT.js} +1054 -660
  15. package/dist/{src-DUGLX4YB.js.map → src-RDQ2EIBT.js.map} +1 -1
  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 +4 -0
  19. package/templates/apps/api/.gitignore.hbs +6 -5
  20. package/templates/apps/api/Dockerfile.hbs +5 -8
  21. package/templates/apps/api/apps/{{projectName}}-contract/package.json.hbs +2 -0
  22. package/templates/apps/api/apps/{{projectName}}-service/README.md.hbs +2 -2
  23. package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +2 -2
  24. package/templates/apps/api/apps/{{projectName}}-service/config/_serv.yml.hbs +4 -4
  25. package/templates/apps/api/apps/{{projectName}}-service/package.json.hbs +8 -2
  26. package/templates/apps/api/hai.build.json.hbs +1 -0
  27. package/templates/apps/api/package.json.hbs +13 -7
  28. package/templates/apps/fullstack/.dockerignore.hbs +4 -3
  29. package/templates/apps/fullstack/.gitignore.hbs +24 -10
  30. package/templates/apps/fullstack/Dockerfile.hbs +6 -8
  31. package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +0 -3
  32. package/templates/apps/fullstack/apps/{{projectName}}-app/package.json.hbs +5 -1
  33. package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +8 -4
  34. package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +3 -6
  35. package/templates/apps/fullstack/apps/{{projectName}}-desktop/package.json.hbs +6 -2
  36. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/src/lib.rs.hbs +73 -2
  37. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/tauri.conf.json.hbs +2 -2
  38. package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +8 -4
  39. package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +2 -6
  40. package/templates/apps/fullstack/apps/{{projectName}}-web/package.json.hbs +5 -1
  41. package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +8 -4
  42. package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +24 -1
  43. package/templates/apps/fullstack/hai.build.json.hbs +7 -0
  44. package/templates/apps/fullstack/package.json.hbs +16 -11
  45. package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +2 -2
  46. package/templates/apps/fullstack/packages/{{projectName}}-contract/package.json.hbs +2 -0
  47. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +1 -1
  48. package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +8 -14
  49. package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +8 -6
  50. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_reach.yml.hbs +8 -8
  51. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +3 -3
  52. package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +8 -2
  53. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +4 -4
  54. package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +4 -2
  55. package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +2 -0
  56. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +14 -12
  57. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +1 -1
  58. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +17 -3
  59. package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +15 -5
  60. package/templates/apps/fullstack/playwright.config.ts.hbs +6 -1
  61. package/templates/apps/fullstack/pnpm-workspace.yaml.hbs +0 -1
  62. package/templates/apps/h5/src/routes/+layout.svelte.hbs +13 -13
  63. package/templates/apps/mobile-app/index.html.hbs +1 -0
  64. package/templates/apps/mobile-app/public/runtime-config.js +1 -0
  65. package/templates/apps/mobile-app/src/App.svelte.hbs +46 -117
  66. package/templates/apps/mobile-app/src/lib/api.ts.hbs +2 -1
  67. package/templates/apps/mobile-app/src/lib/app-config.ts.hbs +3 -2
  68. package/templates/apps/mobile-app/src/lib/pages/home.svelte.hbs +58 -0
  69. package/templates/apps/mobile-app/src/lib/pages/profile.svelte.hbs +13 -0
  70. package/templates/apps/mobile-app/src/lib/pages/tasks.svelte.hbs +16 -0
  71. package/templates/base/.dockerignore.hbs +4 -0
  72. package/templates/base/.gitignore.hbs +20 -5
  73. package/templates/base/Dockerfile.hbs +10 -11
  74. package/templates/base/docker/nginx.conf.hbs +6 -0
  75. package/templates/base/e2e/app.spec.ts.hbs +14 -0
  76. package/templates/base/hai.build.json.hbs +1 -0
  77. package/templates/base/package.json.hbs +25 -17
  78. package/templates/base/svelte.config.js.hbs +2 -0
  79. package/templates/base/tests/scaffold.test.ts.hbs +9 -3
  80. package/templates/base/vite.config.ts.hbs +11 -5
  81. package/templates/shared/.gitattributes +5 -0
  82. package/templates/shared/container-runtime-config.sh +30 -0
  83. package/templates/shared/hash-router.svelte.ts +83 -0
  84. package/templates/shared/run-e2e.mjs +65 -0
  85. package/templates/shared/vite-runtime-config.ts +54 -0
  86. package/templates/skills/AGENTS.md +15 -1
  87. package/templates/skills/bridges/admin/AGENTS.md +17 -1
  88. package/templates/skills/bridges/api/AGENTS.md +17 -3
  89. package/templates/skills/bridges/fullstack/AGENTS.md.hbs +35 -11
  90. package/templates/skills/bridges/h5/AGENTS.md +17 -1
  91. package/templates/skills/bridges/mobile-app/AGENTS.md +19 -3
  92. package/templates/skills/bridges/website/AGENTS.md +17 -1
  93. package/templates/skills/hai-ai/reference.md +2 -2
  94. package/templates/skills/hai-app-create/SKILL.md +5 -3
  95. package/templates/skills/hai-app-create/development.md +128 -2
  96. package/templates/skills/hai-app-create/reference.md +4 -4
  97. package/templates/skills/hai-app-review/SKILL.md +2 -2
  98. package/templates/skills/hai-app-tests/SKILL.md +2 -1
  99. package/templates/skills/hai-app-tests/reference.md +1 -1
  100. package/templates/skills/hai-build/SKILL.md +16 -1
  101. package/templates/skills/hai-core/SKILL.md +2 -1
  102. package/templates/skills/hai-deploy/SKILL.md +52 -252
  103. package/templates/skills/hai-deploy/middleware.md +88 -0
  104. package/templates/skills/hai-framework-sync/SKILL.md +1 -1
  105. package/templates/skills/hai-framework-sync/sync.md +1 -1
  106. package/templates/skills/hai-reach/reference.md +8 -8
  107. package/templates/skills/hai-spec-implement/SKILL.md +12 -9
  108. package/templates/skills/hai-spec-implement/changes.md +67 -0
  109. package/templates/skills/hai-spec-implement/templates.md +25 -3
  110. package/templates/skills/hai-spec-implement/workflow.md +7 -6
  111. package/templates/skills/hai-spec-review/SKILL.md +14 -5
  112. package/templates/skills/hai-spec-review/checklist.md +4 -3
  113. package/templates/skills/hai-storage/SKILL.md +2 -0
  114. package/templates/skills/hai-ui/reference.md +2 -0
  115. package/dist/chunk-WW2MIO6T.js +0 -992
  116. package/dist/chunk-WW2MIO6T.js.map +0 -1
  117. package/dist/chunk-X3RSYNBG.js.map +0 -1
  118. package/dist/cli-config-templates-QRF6B5T4.js +0 -5
  119. /package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/{components/AuditManagement.svelte.hbs → pages/audit.svelte.hbs} +0 -0
  120. /package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/{components/RoleManagement.svelte.hbs → pages/roles.svelte.hbs} +0 -0
  121. /package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/{components/ProfileSettings.svelte.hbs → pages/settings.svelte.hbs} +0 -0
  122. /package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/{components/UserManagement.svelte.hbs → pages/users.svelte.hbs} +0 -0
@@ -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>
@@ -5,6 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
6
  <meta name="theme-color" content="#2563eb" />
7
7
  <title></title>
8
+ <script src="/runtime-config.js"></script>
8
9
  </head>
9
10
  <body>
10
11
  <div id="app"></div>
@@ -0,0 +1 @@
1
+ globalThis.__HAI_PUBLIC_CONFIG__ ??= {}
@@ -1,6 +1,9 @@
1
1
  <script lang='ts'>
2
2
  {{#if features.iam}}
3
3
  import type { IamPublicConfig } from '@h-ai/api-contract'
4
+ {{/if}}
5
+ import type { Component } from 'svelte'
6
+ {{#if features.iam}}
4
7
  import { apiClient } from '@h-ai/api-client'
5
8
  {{/if}}
6
9
  import { applyTheme, getSavedTheme, setGlobalLocale } from '@h-ai/ui'
@@ -8,9 +11,11 @@
8
11
  import { AuthPanel } from '@h-ai/ui/components/scenes/iam/index'
9
12
  {{/if}}
10
13
  import { onMount } from 'svelte'
11
- import { closeApi, initApi, resolveApiBase, usesNativeTokenStorage } from './lib/api.js'
14
+
15
+ import { closeApi, initApi, usesNativeTokenStorage } from './lib/api.js'
12
16
  import { appConfig, initClientCore } from './lib/app-config.js'
13
17
  import { initCapacitor, isNativeApp } from './lib/capacitor.js'
18
+ import { HashRouter } from './lib/navigation/hash-router.svelte.js'
14
19
  import * as m from './lib/paraglide/messages.js'
15
20
  import { getLocale } from './lib/paraglide/runtime.js'
16
21
 
@@ -31,11 +36,12 @@
31
36
  }
32
37
  {{/if}}
33
38
 
34
- type TabId = 'home' | 'tasks' | 'profile'
39
+ const pages = import.meta.glob<{ default: Component<{ nativeRuntime: boolean, nativeTokenStorage: boolean }> }>('./lib/pages/**/*.svelte')
40
+ const router = new HashRouter(Object.keys(pages), '/home')
41
+ const pageComponent = $derived(pages[router.file]())
35
42
 
36
43
  let booting = $state(true)
37
44
  let bootError = $state<string | null>(null)
38
- let activeTab = $state<TabId>('home')
39
45
  let nativeRuntime = $state(false)
40
46
  let nativeTokenStorage = $state(false)
41
47
 
@@ -74,136 +80,59 @@
74
80
  }
75
81
  }
76
82
 
83
+ /** 切换底部导航;例如 handleTabChange('tasks') 会写入 #/tasks。 */
77
84
  function handleTabChange(id: string): void {
78
- if (id === 'home' || id === 'tasks' || id === 'profile')
79
- activeTab = id
85
+ router.navigate(`/${id}`)
80
86
  }
81
87
 
82
88
  onMount(() => {
89
+ const stopRouting = router.start()
83
90
  {{#if features.iam}}
84
91
  resetToken = new URL(location.href).searchParams.get('token') ?? ''
85
92
  {{/if}}
86
93
  void bootstrap()
87
94
 
88
95
  return () => {
96
+ stopRouting()
89
97
  void closeApi()
90
98
  }
91
99
  })
92
100
  </script>
93
101
 
94
- {#if booting}
95
- <div class='hai-mobile-viewport'>
96
- <div class='hai-mobile-shell max-w-lg'>
97
- <div class='flex min-h-screen items-center justify-center'>
98
- <span class='loading loading-spinner loading-lg'></span>
99
- </div>
100
- </div>
101
- </div>
102
- {:else if bootError}
103
- <div class='hai-mobile-viewport'>
104
- <div class='hai-mobile-shell max-w-lg'>
105
- <div class='flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center'>
106
- <span class='icon-[tabler--alert-triangle] text-error text-4xl'></span>
107
- <h1 class='text-lg font-bold'>{m.boot_failed_title()}</h1>
108
- <p class='text-base-content/60 text-sm'>{bootError}</p>
109
- <Button variant='primary' size='sm' onclick={() => location.reload()}>{m.common_retry()}</Button>
110
- </div>
111
- </div>
112
- </div>
102
+ <div class='hai-mobile-viewport'>
103
+ <div class='hai-mobile-shell max-w-lg'>
104
+ <AppBar title={appConfig.name} safeArea fixed={false} class='bg-base-100/95 backdrop-blur border-base-content/8' />
105
+ <main class='hai-mobile-main'>
106
+ {#if booting}
107
+ <div class='grid min-h-64 place-items-center'><span class='loading loading-spinner loading-lg'></span></div>
108
+ {:else if bootError}
109
+ <section class='flex min-h-64 flex-col items-center justify-center gap-3 p-6 text-center'>
110
+ <h1 class='text-lg font-bold'>{m.boot_failed_title()}</h1>
111
+ <p class='text-base-content/60 text-sm'>{bootError}</p>
112
+ <Button variant='primary' size='sm' onclick={() => location.reload()}>{m.common_retry()}</Button>
113
+ </section>
113
114
  {{#if features.iam}}
114
- {:else if !authenticated}
115
- <main>
116
- <AuthPanel config={authConfig} actions={authActions} {resetToken} initialMode={resetToken ? 'reset' : 'login'} onauthenticated={handleAuthenticated} />
117
- </main>
115
+ {:else if !authenticated}
116
+ <AuthPanel config={authConfig} actions={authActions} {resetToken} initialMode={resetToken ? 'reset' : 'login'} onauthenticated={handleAuthenticated} />
117
+ {{/if}}
118
+ {:else}
119
+ {#await pageComponent}
120
+ <div class='grid min-h-64 place-items-center'><span class='loading loading-spinner'></span></div>
121
+ {:then { default: Page }}
122
+ <Page {nativeRuntime} {nativeTokenStorage} />
123
+ {/await}
124
+ {/if}
125
+ </main>
126
+ <footer class='border-t border-base-content/8 bg-base-100 p-2 text-center text-xs text-base-content/50'>
127
+ {appConfig.name} · {appConfig.version} · {appConfig.env}
128
+ </footer>
129
+ {{#if features.iam}}
130
+ {#if authenticated && !booting && !bootError}
131
+ {{/if}}
132
+ <BottomNav items={navItems} active={router.path.slice(1)} onchange={handleTabChange} safeArea centered maxWidth='lg' />
133
+ {{#if features.iam}}
134
+ {/if}
118
135
  {{/if}}
119
- {:else}
120
- <div class='hai-mobile-viewport'>
121
- <div class='hai-mobile-shell max-w-lg'>
122
- <AppBar title={appConfig.name} safeArea fixed={false} class='bg-base-100/95 backdrop-blur border-base-content/8' />
123
-
124
- <main class='hai-mobile-main'>
125
- {#if activeTab === 'home'}
126
- <section class='space-y-4 p-4 pb-6'>
127
- <div class='bg-primary text-primary-content rounded-2xl p-5 shadow-sm'>
128
- <p class='text-sm opacity-75'>{m.hero_eyebrow()}</p>
129
- <h1 class='mt-1 text-2xl font-bold'>{m.hero_title()}</h1>
130
- <p class='mt-2 text-sm opacity-80'>{m.hero_subtitle()}</p>
131
- </div>
132
-
133
- <div class='grid grid-cols-2 gap-3'>
134
- <Card padding='sm' shadow='sm'>
135
- <div class='flex flex-col gap-2'>
136
- <span class='icon-[tabler--device-mobile-code] text-primary text-2xl'></span>
137
- <span class='text-sm font-medium'>{m.feature_native_title()}</span>
138
- <span class='text-base-content/60 text-xs'>{m.feature_native_desc()}</span>
139
- </div>
140
- </Card>
141
- <Card padding='sm' shadow='sm'>
142
- <div class='flex flex-col gap-2'>
143
- <span class='icon-[tabler--api-app] text-primary text-2xl'></span>
144
- <span class='text-sm font-medium'>{m.feature_api_title()}</span>
145
- <span class='text-base-content/60 text-xs'>{m.feature_api_desc()}</span>
146
- </div>
147
- </Card>
148
- </div>
149
-
150
- <Card title={m.status_title()} shadow='sm'>
151
- <p class='mb-3 text-xs text-base-content/50'>{appConfig.name} · {appConfig.version} · {appConfig.env}</p>
152
- <div class='grid grid-cols-2 gap-3 text-sm'>
153
- <div>
154
- <p class='text-base-content/50'>{m.status_api_label()}</p>
155
- <p class='font-medium break-all'>{resolveApiBase()}</p>
156
- </div>
157
- <div>
158
- <p class='text-base-content/50'>{m.status_runtime_label()}</p>
159
- <Badge variant={nativeRuntime ? 'success' : 'warning'} size='sm'>
160
- {nativeRuntime ? m.status_runtime_native() : m.status_runtime_web()}
161
- </Badge>
162
- </div>
163
- <div class='col-span-2'>
164
- <p class='text-base-content/50'>{m.status_token_label()}</p>
165
- <p class='font-medium'>{nativeTokenStorage ? m.status_token_native() : m.status_token_memory()}</p>
166
- </div>
167
- </div>
168
- </Card>
169
- </section>
170
- {:else if activeTab === 'tasks'}
171
- <section class='space-y-4 p-4 pb-6'>
172
- <Card title={m.tasks_title()} shadow='sm'>
173
- <div class='space-y-3'>
174
- {#each [m.tasks_sync(), m.tasks_polish(), m.tasks_release()] as task (task)}
175
- <div class='bg-base-200 rounded-xl flex items-center gap-3 p-3'>
176
- <span class='icon-[tabler--circle-check] text-success text-xl'></span>
177
- <span class='text-sm font-medium'>{task}</span>
178
- </div>
179
- {/each}
180
- </div>
181
- </Card>
182
- </section>
183
- {:else}
184
- <section class='space-y-4 p-4 pb-6'>
185
- <Card title={m.profile_title()} shadow='sm'>
186
- <div class='space-y-3 text-sm'>
187
- <div class='bg-base-200 rounded-xl p-3'>{m.profile_language()}</div>
188
- <div class='bg-base-200 rounded-xl p-3'>{m.profile_theme()}</div>
189
- <div class='bg-base-200 rounded-xl p-3'>{m.profile_secure()}</div>
190
- </div>
191
- </Card>
192
- </section>
193
- {/if}
194
- </main>
195
-
196
- <BottomNav
197
- items={navItems}
198
- active={activeTab}
199
- onchange={handleTabChange}
200
- safeArea
201
- centered
202
- maxWidth='lg'
203
- class='border-base-content/8 shadow-[0_-12px_32px_rgba(15,23,42,0.08)]'
204
- />
205
- </div>
206
136
  </div>
207
- {/if}
208
-
137
+ </div>
209
138
  <ToastContainer />
@@ -10,7 +10,8 @@ const DEFAULT_API_BASE = 'http://localhost:3000/api/v1'
10
10
  let initialized = false
11
11
 
12
12
  export function resolveApiBase(): string {
13
- return import.meta.env.PUBLIC_API_BASE ?? DEFAULT_API_BASE
13
+ const runtime = (globalThis as typeof globalThis & { __HAI_PUBLIC_CONFIG__?: { apiBase?: string } }).__HAI_PUBLIC_CONFIG__
14
+ return runtime?.apiBase || import.meta.env.PUBLIC_API_BASE || DEFAULT_API_BASE
14
15
  }
15
16
 
16
17
  export async function usesNativeTokenStorage(): Promise<boolean> {
@@ -5,8 +5,9 @@ declare const __APP_CORE_CONFIG__: Pick<CoreConfig, 'name' | 'version' | 'env' |
5
5
  logging?: Pick<NonNullable<CoreConfig['logging']>, 'level' | 'format'>
6
6
  }
7
7
 
8
- /** Vite 经 core 加载后注入的公开配置,修改 YAML 后重新构建客户端。 */
9
- export const appConfig = __APP_CORE_CONFIG__
8
+ /** 构建包含公开默认值;预览或容器启动时的公开配置覆盖默认值,不重建静态资源。 */
9
+ const runtime = (globalThis as typeof globalThis & { __HAI_PUBLIC_CONFIG__?: { core?: Partial<typeof __APP_CORE_CONFIG__> } }).__HAI_PUBLIC_CONFIG__?.core
10
+ export const appConfig = { ...__APP_CORE_CONFIG__, ...runtime, logging: { ...__APP_CORE_CONFIG__.logging, ...runtime?.logging } }
10
11
 
11
12
  export function initClientCore(): void {
12
13
  core.init({ logging: appConfig.logging })
@@ -0,0 +1,58 @@
1
+ <script lang='ts'>
2
+ import { resolveApiBase } from '../api.js'
3
+ import { appConfig } from '../app-config.js'
4
+ import * as m from '../paraglide/messages.js'
5
+ /** 首页展示的平台运行状态;例如 { nativeRuntime: true, nativeTokenStorage: true }。 */
6
+ interface Props {
7
+ /** 是否运行于原生壳。 */
8
+ nativeRuntime: boolean
9
+ /** 是否使用原生安全凭据存储。 */
10
+ nativeTokenStorage: boolean
11
+ }
12
+ const { nativeRuntime, nativeTokenStorage }: Props = $props()
13
+ </script>
14
+
15
+ <section class='space-y-4 p-4 pb-6'>
16
+ <div class='bg-primary text-primary-content rounded-2xl p-5 shadow-sm'>
17
+ <p class='text-sm opacity-75'>{m.hero_eyebrow()}</p>
18
+ <h1 class='mt-1 text-2xl font-bold'>{m.hero_title()}</h1>
19
+ <p class='mt-2 text-sm opacity-80'>{m.hero_subtitle()}</p>
20
+ </div>
21
+
22
+ <div class='grid grid-cols-2 gap-3'>
23
+ <Card padding='sm' shadow='sm'>
24
+ <div class='flex flex-col gap-2'>
25
+ <span class='icon-[tabler--device-mobile-code] text-primary text-2xl'></span>
26
+ <span class='text-sm font-medium'>{m.feature_native_title()}</span>
27
+ <span class='text-base-content/60 text-xs'>{m.feature_native_desc()}</span>
28
+ </div>
29
+ </Card>
30
+ <Card padding='sm' shadow='sm'>
31
+ <div class='flex flex-col gap-2'>
32
+ <span class='icon-[tabler--api-app] text-primary text-2xl'></span>
33
+ <span class='text-sm font-medium'>{m.feature_api_title()}</span>
34
+ <span class='text-base-content/60 text-xs'>{m.feature_api_desc()}</span>
35
+ </div>
36
+ </Card>
37
+ </div>
38
+
39
+ <Card title={m.status_title()} shadow='sm'>
40
+ <p class='mb-3 text-xs text-base-content/50'>{appConfig.name} · {appConfig.version} · {appConfig.env}</p>
41
+ <div class='grid grid-cols-2 gap-3 text-sm'>
42
+ <div>
43
+ <p class='text-base-content/50'>{m.status_api_label()}</p>
44
+ <p class='font-medium break-all'>{resolveApiBase()}</p>
45
+ </div>
46
+ <div>
47
+ <p class='text-base-content/50'>{m.status_runtime_label()}</p>
48
+ <Badge variant={nativeRuntime ? 'success' : 'warning'} size='sm'>
49
+ {nativeRuntime ? m.status_runtime_native() : m.status_runtime_web()}
50
+ </Badge>
51
+ </div>
52
+ <div class='col-span-2'>
53
+ <p class='text-base-content/50'>{m.status_token_label()}</p>
54
+ <p class='font-medium'>{nativeTokenStorage ? m.status_token_native() : m.status_token_memory()}</p>
55
+ </div>
56
+ </div>
57
+ </Card>
58
+ </section>
@@ -0,0 +1,13 @@
1
+ <script lang='ts'>
2
+ import * as m from '../paraglide/messages.js'
3
+ </script>
4
+
5
+ <section class='space-y-4 p-4 pb-6'>
6
+ <Card title={m.profile_title()} shadow='sm'>
7
+ <div class='space-y-3 text-sm'>
8
+ <div class='bg-base-200 rounded-xl p-3'>{m.profile_language()}</div>
9
+ <div class='bg-base-200 rounded-xl p-3'>{m.profile_theme()}</div>
10
+ <div class='bg-base-200 rounded-xl p-3'>{m.profile_secure()}</div>
11
+ </div>
12
+ </Card>
13
+ </section>
@@ -0,0 +1,16 @@
1
+ <script lang='ts'>
2
+ import * as m from '../paraglide/messages.js'
3
+ </script>
4
+
5
+ <section class='space-y-4 p-4 pb-6'>
6
+ <Card title={m.tasks_title()} shadow='sm'>
7
+ <div class='space-y-3'>
8
+ {#each [m.tasks_sync(), m.tasks_polish(), m.tasks_release()] as task (task)}
9
+ <div class='bg-base-200 rounded-xl flex items-center gap-3 p-3'>
10
+ <span class='icon-[tabler--circle-check] text-success text-xl'></span>
11
+ <span class='text-sm font-medium'>{task}</span>
12
+ </div>
13
+ {/each}
14
+ </div>
15
+ </Card>
16
+ </section>
@@ -1,4 +1,5 @@
1
1
  .git
2
+ **/*.tar
2
3
  .github
3
4
  .agents
4
5
  .claude
@@ -15,3 +16,6 @@ playwright-report
15
16
  test-results
16
17
  **/data
17
18
  *.log
19
+ # 仅保留公共基础 .env;分环境配置、私有覆盖及已有交付包不进入构建上下文。
20
+ **/.env.*
21
+ **/.hai
@@ -1,19 +1,34 @@
1
+ # 构建产物目录
1
2
  node_modules
2
3
  {{#unless isSvelteOnlyApp}}
3
4
  .svelte-kit
4
5
  {{/unless}}
5
6
  build
6
7
  dist
8
+ .turbo
7
9
  android
8
10
  ios
9
- .env
10
- .env.*
11
- !.env.example
11
+ **/src/lib/paraglide
12
+ **/src-tauri/gen/
13
+ **/src-tauri/target/
14
+ **/hash-core/target/
15
+
16
+ # 日志测试目录
12
17
  *.log
13
- .DS_Store
14
18
  test-results/
15
19
  playwright-report/
20
+
21
+
22
+ # 系统文件
23
+ .DS_Store
24
+
25
+ # 本地数据目录
16
26
  **/data/
17
27
 
18
- # hai deploy 产物(compose / .env.runtime / 镜像 tar / sidecar 凭据)
28
+ # 构建与部署产物(镜像 / Compose tar / 私有运行配置)
19
29
  .hai/
30
+ *.tar
31
+
32
+ # 本机私有配置;公共 .env 与 dev/test/prod 环境文件纳入版本控制。
33
+ .env.local
34
+ .env.*.local
@@ -1,6 +1,8 @@
1
1
  # syntax=docker/dockerfile:1.7
2
2
  {{#if isSvelteOnlyApp}}
3
3
  FROM node:24-bookworm-slim AS build
4
+ ARG SQLITE_PREBUILD_MIRROR
5
+ ENV npm_config_better_sqlite3_binary_host_mirror=$SQLITE_PREBUILD_MIRROR
4
6
 
5
7
  ENV PNPM_HOME=/pnpm
6
8
  ENV PATH=$PNPM_HOME:$PATH
@@ -8,14 +10,14 @@ ENV CI=true
8
10
  WORKDIR /workspace
9
11
 
10
12
  RUN corepack enable && corepack prepare pnpm@12.11.2 --activate
11
- COPY pnpm-lock.yaml pnpm-workspace.yaml ./
12
- RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch
13
13
  COPY . .
14
+ RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch
14
15
  RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile --ignore-scripts
15
- RUN pnpm build
16
+ RUN pnpm compile
16
17
 
17
18
  FROM nginxinc/nginx-unprivileged:1.29-alpine AS runtime
18
19
 
20
+ COPY --chmod=755 docker/19-runtime-config.sh /docker-entrypoint.d/19-hai-runtime-config.sh
19
21
  COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
20
22
  COPY --from=build /workspace/dist /usr/share/nginx/html
21
23
 
@@ -24,6 +26,8 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
24
26
  CMD wget -q -O /dev/null http://127.0.0.1:8080/health || exit 1
25
27
  {{else}}
26
28
  FROM node:24-bookworm-slim AS build
29
+ ARG SQLITE_PREBUILD_MIRROR
30
+ ENV npm_config_better_sqlite3_binary_host_mirror=$SQLITE_PREBUILD_MIRROR
27
31
 
28
32
  ENV PNPM_HOME=/pnpm
29
33
  ENV PATH=$PNPM_HOME:$PATH
@@ -31,17 +35,16 @@ ENV CI=true
31
35
  WORKDIR /workspace
32
36
 
33
37
  RUN corepack enable && corepack prepare pnpm@12.11.2 --activate
34
- COPY pnpm-lock.yaml pnpm-workspace.yaml ./
35
- RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch
36
38
  COPY . .
39
+ RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch
37
40
  RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile --ignore-scripts
38
- RUN pnpm build
41
+ RUN pnpm compile
42
+ RUN if [ -d node_modules/better-sqlite3 ]; then cd node_modules/better-sqlite3; for attempt in 1 2 3; do node -e "require('node:child_process').execFileSync(process.execPath,[require.resolve('prebuild-install/bin.js'),'--verbose'],{stdio:'inherit'})" && break; [ "$attempt" = 3 ] && exit 1; sleep 3; done; fi
39
43
  RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm pm deploy --filter {{projectName}} --prod /prod/app
40
44
 
41
45
  FROM node:24-bookworm-slim AS runtime-base
42
46
 
43
47
  ENV NODE_ENV=production
44
- ENV HAI_CORE_ENV=production
45
48
  ENV HOST=0.0.0.0
46
49
  ENV PORT=3000
47
50
  WORKDIR /app
@@ -55,9 +58,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
55
58
  CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
56
59
  CMD ["node", "build"]
57
60
 
58
- FROM runtime-base AS runtime-with-data
59
-
60
- COPY --from=embedded-data --chown=node:node / /app/data/
61
-
62
61
  FROM runtime-base AS runtime
63
62
  {{/if}}
@@ -9,6 +9,12 @@ server {
9
9
  try_files $uri $uri/ /index.html;
10
10
  }
11
11
 
12
+ location = /runtime-config.js {
13
+ alias /tmp/hai-runtime-config/runtime-config.js;
14
+ add_header Cache-Control "no-store";
15
+ default_type application/javascript;
16
+ }
17
+
12
18
  location = /health {
13
19
  access_log off;
14
20
  add_header Content-Type text/plain;
@@ -21,14 +21,26 @@ test('home page renders from browser', async ({ page }) => {
21
21
  await expect(page).toHaveTitle(process.env.HAI_CORE_NAME ?? '{{projectName}}')
22
22
  await expect(pageErrors).toEqual([])
23
23
  await expect(page.locator('main, section').first()).toBeVisible()
24
+ await expect(page.locator('header').first()).toBeVisible()
25
+ await expect(page.locator('footer').first()).toBeAttached()
24
26
  {{#if_eq appType 'h5'}}
25
27
  // 在手机宽度验证导航真正切换路由,避免样式失效但首页渲染测试仍通过。
26
28
  const nav = page.locator('nav')
27
29
  await nav.getByRole('button', { name: /发现|Discover/i, exact: true }).click()
28
30
  await expect(page).toHaveURL(/\/discover$/)
31
+ await page.reload()
32
+ await expect(page).toHaveURL(/\/discover$/)
33
+ await expect(page.getByRole('heading', { name: /发现|Discover/i })).toBeVisible()
29
34
  await nav.getByRole('button', { name: /首页|Home/i, exact: true }).click()
30
35
  await expect(page).toHaveURL(/\/$/)
31
36
  expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
37
+ {{/if_eq}}
38
+ {{#if_eq appType 'website'}}
39
+ await page.goto('/about')
40
+ await page.reload()
41
+ await expect(page).toHaveURL(/\/about$/)
42
+ await expect(page.locator('header')).toBeVisible()
43
+ await expect(page.locator('footer')).toBeVisible()
32
44
  {{/if_eq}}
33
45
  const evidence = process.env.HAI_AUTH_EVIDENCE_DIR || path.resolve('test-results/auth-screenshots')
34
46
  await mkdir(evidence, { recursive: true })
@@ -50,6 +62,8 @@ test('configured IAM registration and password login', async ({ page, context })
50
62
  const authUrl = '{{#if isCapacitorApp}}/{{else}}/auth/login{{/if}}'
51
63
  await page.goto(authUrl)
52
64
  await expect(page.locator('#login-username')).toBeVisible()
65
+ await expect(page.locator('header').first()).toBeVisible()
66
+ await expect(page.locator('footer')).toBeVisible()
53
67
  await page.screenshot({ path: path.join(evidence, '{{projectName}}-login.png'), fullPage: true })
54
68
  await page.getByRole('{{#if isCapacitorApp}}button{{else}}link{{/if}}', { name: /立即注册|Register now/i }).click()
55
69
  await expect(page.locator('input[name="username"]')).toBeVisible()
@@ -0,0 +1 @@
1
+ { "images": [{ "name": "app", "target": "runtime", "containerPort": {{#if isSvelteOnlyApp}}8080{{else}}3000{{/if}} }] }