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

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@h-ai/cli",
3
3
  "type": "module",
4
- "version": "0.1.0-alpha.63",
4
+ "version": "0.1.0-alpha.64",
5
5
  "description": "Hai Framework CLI for project scaffolding and code generation.",
6
6
  "license": "Apache-2.0",
7
7
  "keywords": [
@@ -30,7 +30,7 @@
30
30
  "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
31
31
  },
32
32
  "dependencies": {
33
- "@h-ai/core": "0.1.0-alpha.63",
33
+ "@h-ai/core": "0.1.0-alpha.64",
34
34
  "cac": "^7.0.1",
35
35
  "chalk": "^6.0.1",
36
36
  "fast-wrap-ansi": "^0.2.2",
@@ -41,11 +41,11 @@
41
41
  "yaml": "^2.9.1"
42
42
  },
43
43
  "devDependencies": {
44
- "@h-ai/cache": "0.1.0-alpha.63",
45
- "@h-ai/deploy": "0.1.0-alpha.63",
46
- "@h-ai/reach": "0.1.0-alpha.63",
47
- "@h-ai/reldb": "0.1.0-alpha.63",
48
- "@h-ai/storage": "0.1.0-alpha.63",
44
+ "@h-ai/cache": "0.1.0-alpha.64",
45
+ "@h-ai/deploy": "0.1.0-alpha.64",
46
+ "@h-ai/reach": "0.1.0-alpha.64",
47
+ "@h-ai/reldb": "0.1.0-alpha.64",
48
+ "@h-ai/storage": "0.1.0-alpha.64",
49
49
  "@types/fs-extra": "^11.0.4",
50
50
  "@types/node": "^26.6.5",
51
51
  "@types/prompts": "^2.4.9",
@@ -61,6 +61,76 @@ test('service rejects plaintext business requests', async ({ request }) => {
61
61
  })
62
62
 
63
63
  {{#if fullstack.e2eFrontend}}
64
+ test('branded authentication, profile layout and blur validation work across screen sizes', async ({ page }, testInfo) => {
65
+ test.setTimeout(60_000)
66
+ await page.setViewportSize({ width: 1440, height: 960 })
67
+ await page.goto('/')
68
+ await expect(page.getByRole('heading', { name: configuredName, exact: true })).toBeVisible()
69
+ await capture(page, '{{projectName}}-login-desktop.png', testInfo.outputPath('login-desktop.png'))
70
+ await page.getByRole('button', { name: /立即注册|Register now/, exact: true }).click()
71
+ await expect(page.locator('.register-form')).toBeVisible()
72
+ await capture(page, '{{projectName}}-register-desktop.png', testInfo.outputPath('register-desktop.png'))
73
+ await page.setViewportSize({ width: 390, height: 844 })
74
+ await capture(page, '{{projectName}}-register-mobile.png', testInfo.outputPath('register-mobile.png'))
75
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
76
+ await page.reload()
77
+ await capture(page, '{{projectName}}-login-mobile.png', testInfo.outputPath('login-mobile.png'))
78
+ await page.setViewportSize({ width: 1440, height: 960 })
79
+ await login(page, 'admin', adminPassword)
80
+ await page.getByTestId('nav-settings').click()
81
+ const profile = page.getByTestId('profile-form')
82
+ await expect(profile).toBeVisible()
83
+ const email = profile.locator('input[name="email"]')
84
+ await email.fill('invalid-email')
85
+ await email.press('Tab')
86
+ await expect(email).toHaveAttribute('aria-invalid', 'true')
87
+ await capture(page, '{{projectName}}-profile-blur-error.png', testInfo.outputPath('profile-blur-error.png'))
88
+ await email.fill('')
89
+ await expect(email).not.toHaveAttribute('aria-invalid')
90
+ await capture(page, '{{projectName}}-settings-desktop.png', testInfo.outputPath('settings-desktop.png'))
91
+ for (const width of [768, 390]) {
92
+ await page.setViewportSize({ width, height: 960 })
93
+ const fields = await profile.locator('.hai-form-field').evaluateAll(elements => elements.map((field) => {
94
+ const label = field.querySelector('label')!.getBoundingClientRect()
95
+ const control = field.querySelector('input')!.getBoundingClientRect()
96
+ return { labelBottom: label.bottom, controlTop: control.top, controlRight: control.right }
97
+ }))
98
+ for (const field of fields) {
99
+ expect(field.labelBottom).toBeLessThanOrEqual(field.controlTop)
100
+ expect(field.controlRight).toBeLessThanOrEqual(width)
101
+ }
102
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
103
+ await capture(page, `{{projectName}}-settings-${width}.png`, testInfo.outputPath(`settings-${width}.png`))
104
+ }
105
+ await page.getByTestId('password-form').scrollIntoViewIfNeeded()
106
+ await capture(page, '{{projectName}}-password-mobile.png', testInfo.outputPath('password-mobile.png'))
107
+ await page.setViewportSize({ width: 1440, height: 960 })
108
+ await page.getByTestId('nav-users').click()
109
+ await page.getByTestId('create-user').click()
110
+ const username = page.getByTestId('user-username').locator('input')
111
+ await username.focus()
112
+ await username.press('Tab')
113
+ await expect(username).toHaveAttribute('aria-invalid', 'true')
114
+ await username.fill('a')
115
+ await expect(username).toHaveAttribute('aria-invalid', 'true')
116
+ await page.getByTestId('user-password').locator('input').fill('short')
117
+ await page.getByTestId('user-password').locator('input').press('Tab')
118
+ await expect(page.getByTestId('user-password').locator('input')).toHaveAttribute('aria-invalid', 'true')
119
+ await capture(page, '{{projectName}}-create-blur-error.png', testInfo.outputPath('create-blur-error.png'))
120
+ await page.getByTestId('user-editor').getByRole('button', { name: /取消|Cancel/, exact: true }).click()
121
+ await page.getByTestId('create-user').click()
122
+ await expect(page.getByTestId('user-editor').locator('[aria-invalid="true"]')).toHaveCount(0)
123
+ await page.getByTestId('user-editor').getByRole('button', { name: /取消|Cancel/, exact: true }).click()
124
+ await userEditor(page, 'admin')
125
+ const editEmail = page.getByTestId('user-email').locator('input')
126
+ await editEmail.fill('invalid-email')
127
+ await editEmail.press('Tab')
128
+ await expect(editEmail).toHaveAttribute('aria-invalid', 'true')
129
+ await capture(page, '{{projectName}}-edit-blur-error.png', testInfo.outputPath('edit-blur-error.png'))
130
+ await editEmail.fill('admin@example.com')
131
+ await expect(editEmail).not.toHaveAttribute('aria-invalid')
132
+ })
133
+
64
134
  test('first-use tour, help search and settings navigation work on desktop and mobile', async ({ page }, testInfo) => {
65
135
  await page.goto('/')
66
136
  await login(page, 'admin', adminPassword, true)
@@ -43,6 +43,8 @@ Run root `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm test` and `pnpm test
43
43
 
44
44
  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
45
 
46
+ 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.
47
+
46
48
  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
49
 
48
50
  协议支持配置中的 userAgreementMarkdown/privacyPolicyMarkdown 正文及 URL;同主题优先 Markdown,由安全渲染器在弹窗显示。
@@ -51,4 +53,4 @@ See `.agents/skills/hai-iam/reference.md` in the generated application for the c
51
53
 
52
54
  ## Form layouts
53
55
 
54
- The user editor uses `FormLayout columns={2} labelPosition="left"`; profile and password forms reuse the same UI options. Narrow containers automatically stack fields and labels. Field validation errors follow the current UI language. The management store syncs locale through the lightweight `@h-ai/ui/i18n` entry, without loading components or tour CSS. Configure `colSpan` on FormField to override automatic spans for textareas, uploads and multiple choices.
56
+ 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.
@@ -21,6 +21,10 @@
21
21
  // 宿主平台在组件生命周期中固定,引导控制器只创建一次。
22
22
  const guide = untrack(() => createManagementGuide(store, platform))
23
23
  let resetToken = $state('')
24
+ const authConfig = $derived(store.authConfig && {
25
+ ...store.authConfig,
26
+ ui: { ...store.authConfig.ui, brandTitle: store.authConfig.ui.brandTitle || store.info?.name },
27
+ })
24
28
  async function authenticated() {
25
29
  await store.authenticated()
26
30
  if (resetToken) {
@@ -65,7 +69,7 @@
65
69
  {:else}
66
70
  <div data-testid='login-form'>
67
71
  {#if store.authConfig && store.error}<Alert variant='error' class='mx-auto max-w-lg'>{store.error}</Alert>{/if}
68
- <AuthPanel config={store.authConfig} actions={managementAuthActions} {resetToken} initialMode={resetToken ? 'reset' : 'login'} error={store.authConfig ? '' : store.error} onretry={() => void store.initialize(apiOptions, onLocaleChange)} onauthenticated={authenticated} />
72
+ <AuthPanel config={authConfig} actions={managementAuthActions} {resetToken} initialMode={resetToken ? 'reset' : 'login'} error={store.authConfig ? '' : store.error} onretry={() => void store.initialize(apiOptions, onLocaleChange)} onauthenticated={authenticated} />
69
73
  </div>
70
74
  <p class='fixed bottom-3 left-0 right-0 text-center text-xs text-base-content/50'><span data-testid='app-name'>{store.info.name}</span> · <span data-testid='app-version'>v{store.info.version}</span> · <span data-testid='app-environment'>{store.info.env}</span></p>
71
75
  {/if}
@@ -1,7 +1,7 @@
1
1
  <script lang='ts'>
2
2
  import type { ManagementStore } from '../stores/management-store.svelte.js'
3
- import { Card } from '@h-ai/ui/components/compounds/index'
4
- import { Avatar, Select } from '@h-ai/ui/components/primitives/index'
3
+ import { Card, FormField, FormLayout } from '@h-ai/ui/components/compounds/index'
4
+ import { Badge, Select } from '@h-ai/ui/components/primitives/index'
5
5
  import { ChangePasswordForm, UserProfile } from '@h-ai/ui/components/scenes/iam/index'
6
6
  import { getThemeInfo } from '@h-ai/ui/theme'
7
7
  import * as m from '../paraglide/messages.js'
@@ -14,44 +14,33 @@
14
14
  const themeOptions = AVAILABLE_THEMES.map(theme => ({ value: theme, label: getThemeInfo(theme)?.name ?? theme }))
15
15
  </script>
16
16
 
17
- <section class='space-y-5' data-testid='settings-page'>
17
+ <section class='mx-auto w-full max-w-4xl space-y-6' data-testid='settings-page'>
18
18
  <div>
19
19
  <h1 class='text-xl font-semibold tracking-tight'>{m.settings_title()}</h1>
20
20
  <p class='mt-1 text-sm text-base-content/50'>{m.settings_description()}</p>
21
21
  </div>
22
22
  {#if store.user}
23
- <div class='grid gap-5 xl:grid-cols-[0.75fr_1.25fr]'>
24
- <Card bordered shadow={false} class='h-fit'>
25
- <div class='flex items-center gap-4'>
26
- <Avatar name={store.user.displayName || store.user.username} src={store.user.avatarUrl} size='lg' />
27
- <div class='min-w-0'>
28
- <h2 class='truncate text-lg font-semibold'>{store.user.displayName || store.user.username}</h2>
29
- <p class='text-sm text-base-content/50'>@{store.user.username}</p>
30
- <p class='mt-2 text-xs text-primary'>{store.isAdmin ? m.role_admin() : m.role_user()}</p>
31
- </div>
32
- </div>
33
- <p class='mt-4 text-xs leading-6 text-base-content/50'>{m.avatar_hint()}</p>
34
- </Card>
35
- <Card title={m.profile_title()} bordered shadow={false} data-guide='admin-profile-form'>
36
- <UserProfile columns={2} labelPosition='left' user={store.user} editable alwaysEditable fields={['avatar', 'displayName', 'email']} avatarAccept='image/png,image/jpeg,image/webp' avatarHint={m.avatar_hint()} loading={store.saving} onsubmit={data => store.saveProfile(data)} onavatarchange={file => void store.uploadAvatar(file)} data-testid='profile-form' />
37
- </Card>
38
- </div>
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>
39
30
  <Card title={m.preferences_title()} bordered shadow={false} data-guide='admin-preferences'>
40
31
  <p class='mb-5 text-sm text-base-content/50'>{m.preferences_description()}</p>
41
- <div class='grid gap-6 sm:grid-cols-2'>
42
- <div class='space-y-2'>
43
- <label class='text-sm font-medium' for='profile-language'>{m.settings_language()}</label>
32
+ <FormLayout columns={2}>
33
+ <FormField label={m.settings_language()} for='profile-language'>
44
34
  <Select id='profile-language' value={store.locale} options={languageOptions} disabled={store.saving} onchange={locale => void store.savePreferences({ locale })} data-testid='profile-language' />
45
- </div>
46
- <div class='space-y-2'>
47
- <label class='text-sm font-medium' for='profile-theme'>{m.settings_theme()}</label>
35
+ </FormField>
36
+ <FormField label={m.settings_theme()} for='profile-theme'>
48
37
  <Select id='profile-theme' value={themeStore.current} options={themeOptions} disabled={store.saving} onchange={theme => void store.savePreferences({ theme })} data-testid='profile-theme' />
49
- </div>
50
- </div>
38
+ </FormField>
39
+ </FormLayout>
51
40
  </Card>
52
41
  <Card title={m.password_title()} bordered shadow={false} data-guide='admin-password'>
53
42
  <p class='mb-5 text-sm text-base-content/50'>{m.password_rules()}</p>
54
- <div class='max-w-xl'><ChangePasswordForm labelPosition='left' loading={store.saving} passwordPolicy={store.authConfig?.password} minPasswordLength={store.authConfig?.password.minLength ?? 8} onsubmit={data => store.changePassword(data)} data-testid='password-form' /></div>
43
+ <ChangePasswordForm loading={store.saving} passwordPolicy={store.authConfig?.password} minPasswordLength={store.authConfig?.password.minLength ?? 8} onsubmit={data => store.changePassword(data)} data-testid='password-form' />
55
44
  </Card>
56
45
  {/if}
57
46
  </section>
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" },
3
3
  "name": "{{projectName}}",
4
- "version": "0.1.0-alpha.63",
4
+ "version": "0.1.0-alpha.64",
5
5
  "private": true,
6
6
  "packageManager": "pnpm@12.11.2",
7
7
  "description": "{{#if_eq appType 'admin'}}Enterprise management system built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'website'}}Enterprise website built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'h5'}}Mobile H5 app built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'api'}}REST API service built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'mobile-app'}}Mobile app built with hai Agent Framework{{/if_eq}}",
@@ -48,6 +48,10 @@ description: "使用 @h-ai/ui 构建 Svelte 5 表单、表格、弹窗、新手
48
48
 
49
49
  ## 表单布局与错误提示
50
50
 
51
+ 输入控件与 CRUD 新增/编辑默认在首次失焦后显示前端错误,修正输入时即时更新;未操作字段在提交时校验。Select 在焦点离开触发器及选项后校验。应用无需逐项绑定 blur;业务异步校验仍由应用提供。PasswordInput 的 minLength 提供时执行长度校验;登录现有密码时省略该属性。
52
+
53
+ UserProfile 的 `fieldConstraints` 按字段声明 `required/minlength/maxlength/pattern/validationMessage`;单行控件支持 pattern,业务文案可用翻译函数。不要只在保存回调内放前端校验,服务端仍须执行 schema 校验。
54
+
51
55
  状态层通过轻量入口 `@h-ai/ui/i18n` 导入 `setGlobalLocale` / `createLocaleStore`,不加载组件或引导 CSS。
52
56
 
53
57
  数据新增/修改统一使用 `Form`(托管提交)或原生 `<form>` + `FormLayout`,不要在应用复制字段 grid。两者支持 `columns={1 | 2}`、`labelPosition='top' | 'left'`、`labelWidth`;默认单列/标签在上,窄容器自动收拢。`FormField.colSpan='auto'` 自动让多行文本、上传和多选占两列,`1` / `2` 可覆盖;自定义宽控件标注 `data-form-wide`。已有控件 ID 或原生控件给 FormField 配 `for`,避免重复或失效的标签。