@h-ai/cli 0.1.0-alpha.62 → 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.62",
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.62",
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.62",
45
- "@h-ai/deploy": "0.1.0-alpha.62",
46
- "@h-ai/reach": "0.1.0-alpha.62",
47
- "@h-ai/reldb": "0.1.0-alpha.62",
48
- "@h-ai/storage": "0.1.0-alpha.62",
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",
@@ -157,3 +157,7 @@ import { iam } from '@h-ai/iam'
157
157
  选用 IAM 的 admin/h5/website 模板提供 AuthPanel 和标准认证路由;mobile-app 与 fullstack Web/App/Desktop 使用 API 客户端共享动作。修改服务端 config/_iam.yml 即可控制登录方式、注册、重置、协议和品牌,无需改表单。配置 Reach 模板和真实发送 Provider 后启用 OTP/重置。分布式部署使用共享数据库与 Redis。
158
158
 
159
159
  LDAP/Reach 是可选 peer;IAM 不强制启用 Reach。默认 LDAP 客户端由应用选择安装 ldapts。自定义通知回调、协议弹窗、表单 snippet 用法随 hai-iam/hai-ui skills 完整复制到生成项目,不能要求应用读取本仓库私有路径。
160
+
161
+ ## 表单布局
162
+
163
+ 新增/编辑复用 `@h-ai/ui` 的 Form/FormLayout/FormField:可选一列或双列、标签独立成行或与控件同行,多行文本与多选自动跨列,窄容器自动收为一列。CRUD 使用 `form` 配置,字段可用 `colSpan` 覆盖。错误显示在字段附近并随 UI 语言更新。website 联系页采用 FormLayout + FormField;fullstack 共享用户编辑页采用双列同行标签,个人资料与密码页复用 UI 场景组件。没有数据表单的模板保留现有骨架,新增表单按 hai-ui skill 接入。
@@ -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,8 +43,14 @@ 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,由安全渲染器在弹窗显示。
49
51
 
50
52
  See `.agents/skills/hai-iam/reference.md` in the generated application for the complete contract.
53
+
54
+ ## Form layouts
55
+
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 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 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
  <script lang="ts">
2
2
  import type { AdminRoleCode, AdminUser } from '{{fullstack.contractPackageName}}'
3
3
  import type { ManagementStore } from '../stores/management-store.svelte.js'
4
- import { Card, DataTable, FormField, Modal, Pagination, Skeleton } from '@h-ai/ui/components/compounds/index'
4
+ import { Card, DataTable, FormField, FormLayout, Modal, Pagination, Skeleton } from '@h-ai/ui/components/compounds/index'
5
5
  import { Avatar, Badge, BareInput, Button, Checkbox, Input, Select } from '@h-ai/ui/components/primitives/index'
6
6
  import { PasswordInput } from '@h-ai/ui/components/scenes/iam/index'
7
7
  import * as m from '../paraglide/messages.js'
@@ -133,23 +133,25 @@
133
133
  </div>
134
134
  </section>
135
135
 
136
- <Modal bind:open={editorOpen} title={editing ? m.user_edit() : m.user_create()} closeOnBackdrop={!store.saving} closeOnEscape={!store.saving} data-testid="user-editor">
137
- <form class="space-y-4" onsubmit={submit}>
138
- <FormField label={m.user_username()} required>
139
- <Input bind:value={username} name="username" readonly={!!editing} required minlength={3} maxlength={64} pattern="[\w\-]+" autocomplete="off" data-testid="user-username" />
140
- </FormField>
141
- <FormField label={m.user_display_name()}><Input bind:value={displayName} maxlength={100} data-testid="user-display-name" /></FormField>
142
- <FormField label={m.user_email()}><Input type="email" bind:value={email} data-testid="user-email" /></FormField>
143
- {#if !editing}
144
- <FormField label={m.user_password()} hint={m.initial_password_rules({ min: store.authConfig?.password.minLength ?? 8 })} required><PasswordInput bind:value={password} autocomplete="new-password" minLength={store.authConfig?.password.minLength ?? 8} data-testid="user-password" /></FormField>
145
- {/if}
146
- <FormField label={m.user_role()}><Select bind:value={role} options={roleOptions} disabled={editing?.id === store.user?.id} data-testid="user-role" /></FormField>
147
- <Checkbox bind:checked={enabled} label={m.account_enabled()} disabled={editing?.id === store.user?.id} />
148
- {#if store.error}<p class="text-sm text-error" role="alert">{store.error}</p>{/if}
149
- <div class="flex justify-end gap-2 border-t border-base-content/8 pt-4">
150
- <Button variant="ghost" disabled={store.saving} onclick={() => { editorOpen = false }}>{m.cancel()}</Button>
151
- <Button type="submit" variant="primary" loading={store.saving} disabled={!editing && password.length < 8} data-testid="user-save">{m.save()}</Button>
152
- </div>
136
+ <Modal bind:open={editorOpen} title={editing ? m.user_edit() : m.user_create()} closeOnBackdrop={!store.saving} closeOnEscape={!store.saving} size="3xl" data-testid="user-editor">
137
+ <form onsubmit={submit}>
138
+ <FormLayout columns={2} labelPosition="left" labelWidth="6rem">
139
+ <FormField label={m.user_username()} required>
140
+ <Input bind:value={username} name="username" readonly={!!editing} required minlength={3} maxlength={64} pattern="[\w\-]+" autocomplete="off" data-testid="user-username" />
141
+ </FormField>
142
+ <FormField label={m.user_display_name()}><Input bind:value={displayName} maxlength={100} data-testid="user-display-name" /></FormField>
143
+ <FormField label={m.user_email()}><Input type="email" bind:value={email} data-testid="user-email" /></FormField>
144
+ {#if !editing}
145
+ <FormField label={m.user_password()} hint={m.initial_password_rules({ min: store.authConfig?.password.minLength ?? 8 })} required><PasswordInput required bind:value={password} autocomplete="new-password" minLength={store.authConfig?.password.minLength ?? 8} data-testid="user-password" /></FormField>
146
+ {/if}
147
+ <FormField label={m.user_role()}><Select bind:value={role} options={roleOptions} disabled={editing?.id === store.user?.id} data-testid="user-role" /></FormField>
148
+ <Checkbox bind:checked={enabled} label={m.account_enabled()} disabled={editing?.id === store.user?.id} />
149
+ {#if store.error}<p class="text-sm text-error" role="alert">{store.error}</p>{/if}
150
+ <div class="flex justify-end gap-2 border-t border-base-content/8 pt-4">
151
+ <Button variant="ghost" disabled={store.saving} onclick={() => { editorOpen = false }}>{m.cancel()}</Button>
152
+ <Button type="submit" variant="primary" loading={store.saving} disabled={!editing && password.length < 8} data-testid="user-save">{m.save()}</Button>
153
+ </div>
154
+ </FormLayout>
153
155
  </form>
154
156
  </Modal>
155
157
 
@@ -4,7 +4,7 @@ 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
6
  import { HaiApiClientError } from '@h-ai/api-client'
7
- import { core } from '@h-ai/core'
7
+ import { setGlobalLocale } from '@h-ai/ui/i18n'
8
8
  import { SvelteDate } from 'svelte/reactivity'
9
9
  import { initializeManagementApi, managementApi, resolveAvatarUrl } from '../api/api-client.js'
10
10
  import * as m from '../paraglide/messages.js'
@@ -305,7 +305,7 @@ export class ManagementStore {
305
305
  return
306
306
  this.locale = locale
307
307
  setLocale(locale, { reload: false })
308
- core.i18n.setGlobalLocale(locale)
308
+ setGlobalLocale(locale)
309
309
  if (typeof document !== 'undefined')
310
310
  document.documentElement.lang = locale
311
311
  this.onLocaleChange?.(locale)
@@ -6,7 +6,7 @@
6
6
  {{#if hasI18n}}
7
7
  import * as m from '$lib/paraglide/messages'
8
8
  {{/if}}
9
- import { Button, Card, FormField, Input, PageHeader } from '@h-ai/ui'
9
+ import { Button, Card, FormField, FormLayout, Input, PageHeader, Textarea } from '@h-ai/ui'
10
10
 
11
11
  let name = $state('')
12
12
  let email = $state('')
@@ -33,30 +33,32 @@
33
33
  {{/if}}
34
34
 
35
35
  <Card shadow='sm' class='bg-base-100'>
36
- <form class='space-y-4' onsubmit={handleSubmit}>
36
+ <form onsubmit={handleSubmit}>
37
+ <FormLayout columns={2} labelPosition='top'>
37
38
  {{#if hasI18n}}
38
- <FormField label={m.page_contact_name()} required>
39
- <Input id='name' type='text' bind:value={name} required />
40
- </FormField>
41
- <FormField label={m.page_contact_email()} required>
42
- <Input id='email' type='email' bind:value={email} required />
43
- </FormField>
44
- <FormField label={m.page_contact_message()} required>
45
- <textarea id='message' class='textarea textarea-bordered w-full' rows='4' bind:value={message}></textarea>
46
- </FormField>
47
- <Button type='submit' variant='primary' class='w-full'>{m.page_contact_submit()}</Button>
39
+ <FormField for='name' label={m.page_contact_name()} required>
40
+ <Input id='name' type='text' bind:value={name} required />
41
+ </FormField>
42
+ <FormField for='email' label={m.page_contact_email()} required>
43
+ <Input id='email' type='email' bind:value={email} required />
44
+ </FormField>
45
+ <FormField for='message' label={m.page_contact_message()} required>
46
+ <Textarea id='message' rows={4} bind:value={message} required />
47
+ </FormField>
48
+ <Button type='submit' variant='primary' class='w-full'>{m.page_contact_submit()}</Button>
48
49
  {{else}}
49
- <FormField label='姓名' required>
50
- <Input id='name' type='text' bind:value={name} required />
51
- </FormField>
52
- <FormField label='邮箱' required>
53
- <Input id='email' type='email' bind:value={email} required />
54
- </FormField>
55
- <FormField label='留言' required>
56
- <textarea id='message' class='textarea textarea-bordered w-full' rows='4' bind:value={message}></textarea>
57
- </FormField>
58
- <Button type='submit' variant='primary' class='w-full'>发送</Button>
50
+ <FormField for='name' label='姓名' required>
51
+ <Input id='name' type='text' bind:value={name} required />
52
+ </FormField>
53
+ <FormField for='email' label='邮箱' required>
54
+ <Input id='email' type='email' bind:value={email} required />
55
+ </FormField>
56
+ <FormField for='message' label='留言' required>
57
+ <Textarea id='message' rows={4} bind:value={message} required />
58
+ </FormField>
59
+ <Button type='submit' variant='primary' class='w-full'>发送</Button>
59
60
  {{/if}}
61
+ </FormLayout>
60
62
  </form>
61
63
  </Card>
62
64
  </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.62",
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}}",
@@ -181,3 +181,7 @@ export const appKitConfig = resolveKitConfig(parse(rawKitConfig) ?? {})
181
181
  - 传输加密只通过 `crypto.transport` 间接装配,不在 kit 中新增本地加密工厂或鸭子类型。
182
182
  - 服务端业务模块直接调用模块 API,不通过 HTTP 自环。
183
183
  - API endpoint 必须用 Zod 校验用户输入,并设置认证/权限边界。
184
+
185
+ ## 声明式 CRUD 表单字段
186
+
187
+ `kit.crud.define` 的字段支持 `colSpan: 'auto' | 1 | 2`,以及 `validation.message: string | (() => string)`。布局通过 UI 的 `CrudPage.form` 配置;客户端校验用于交互提示,服务端仍使用 Zod 验证和现有权限守卫。UI 公共契约见相邻 [hai-ui skill](../hai-ui/SKILL.md)。
@@ -45,3 +45,17 @@ description: "使用 @h-ai/ui 构建 Svelte 5 表单、表格、弹窗、新手
45
45
  - 重要约定
46
46
  - 导出路径
47
47
  - 相关 Skills
48
+
49
+ ## 表单布局与错误提示
50
+
51
+ 输入控件与 CRUD 新增/编辑默认在首次失焦后显示前端错误,修正输入时即时更新;未操作字段在提交时校验。Select 在焦点离开触发器及选项后校验。应用无需逐项绑定 blur;业务异步校验仍由应用提供。PasswordInput 的 minLength 提供时执行长度校验;登录现有密码时省略该属性。
52
+
53
+ UserProfile 的 `fieldConstraints` 按字段声明 `required/minlength/maxlength/pattern/validationMessage`;单行控件支持 pattern,业务文案可用翻译函数。不要只在保存回调内放前端校验,服务端仍须执行 schema 校验。
54
+
55
+ 状态层通过轻量入口 `@h-ai/ui/i18n` 导入 `setGlobalLocale` / `createLocaleStore`,不加载组件或引导 CSS。
56
+
57
+ 数据新增/修改统一使用 `Form`(托管提交)或原生 `<form>` + `FormLayout`,不要在应用复制字段 grid。两者支持 `columns={1 | 2}`、`labelPosition='top' | 'left'`、`labelWidth`;默认单列/标签在上,窄容器自动收拢。`FormField.colSpan='auto'` 自动让多行文本、上传和多选占两列,`1` / `2` 可覆盖;自定义宽控件标注 `data-form-wide`。已有控件 ID 或原生控件给 FormField 配 `for`,避免重复或失效的标签。
58
+
59
+ IAM 表单、AuthPanel、UserProfile、FeedbackModal 直接接收同组参数;CRUD 使用 `form={{ columns: 2, labelPosition: 'left', labelWidth: '6rem' }}`,字段支持 `colSpan` 和延迟翻译的 `validation.message: () => m.required()`。通用字段的 `label/error/hint`、Input/Textarea/Select 的 `validationMessage` 支持翻译函数。内置必填、类型、长度和范围错误由 UI 提供;应用只提供业务文案。通过 UI 的 `setGlobalLocale` / `createLocaleStore` 同步语言,已显示的错误随之更新;自定义提示只用于失败,不能把固定提示直接设成持续的 customValidity。字段错误只在 FormField 或控件其中一处展示。
60
+
61
+ 验证一列/双列与两种标签位置、自动/显式跨列、窄抽屉/手机、无效提交与修正、切换语言后的错误、按钮和回车提交,以及关闭重开后的状态。
@@ -255,8 +255,8 @@ const p = usePlatform()
255
255
 
256
256
  | 组件 | Props 要点 | 说明 |
257
257
  | ------------ | ------------------------------------------------------------------ | ------------- |
258
- | `Form` | `loading`, `disabled`, `onsubmit` | 表单容器 |
259
- | `FormField` | `label`, `name`, `error`, `hint`, `required` | 表单字段 |
258
+ | `Form` | `columns`, `labelPosition`, `labelWidth`, `loading`, `disabled`, `onsubmit` | 表单容器 |
259
+ | `FormField` | `label`, `for`, `colSpan`, `labelPosition`, `error`, `hint`, `required` | 表单字段 |
260
260
  | `Modal` | `open`, `title`, `size`, `closeOnBackdrop` | 模态框,内置对话框语义、焦点约束和关闭后焦点恢复 |
261
261
  | `Drawer` | `open`, `position`, `size`, `width`, `resizable`, `widthStorageKey` | 可选拖动并记忆宽度的抽屉 |
262
262
  | `DataTable` | `data`, `columns`, `keyField`, `sortKey`, `sortDir`, `onsort`, snippet slots | 数据表格(列定义 `sortable` 可排序) |
@@ -643,6 +643,7 @@ AuthPanel 在 SSR 阶段禁用原生表单控件,客户端挂载并绑定事
643
643
  | --------------------------- | -------------------------------------- |
644
644
  | `@h-ai/ui` | 主入口:所有组件、工具函数、类型 |
645
645
  | `@h-ai/ui/theme` | 轻量主题元数据与工具,不经过组件根入口 |
646
+ | `@h-ai/ui/i18n` | 轻量语言工具,供状态层使用,不加载组件或引导 CSS |
646
647
  | `@h-ai/ui/auto-import` | Svelte 预处理器:组件自动导入 |
647
648
  | `@h-ai/ui/components/*` | 公开组件目录的 JS 入口,按实际 exports/产物选择 |
648
649
  | `@h-ai/ui/components/primitives/index` | 公开原子组件目录入口,如 Button/Input |
@@ -657,3 +658,25 @@ AuthPanel 在 SSR 阶段禁用原生表单控件,客户端挂载并绑定事
657
658
  - `hai-iam`:IAM 模块 API(与 LoginForm/RegisterForm 配合)
658
659
  - `hai-capacitor`:原生 App 开发(与 SafeArea/AppBar 配合)
659
660
  - `hai-api-client`:客户端数据获取
661
+
662
+ ## 表单布局与错误提示
663
+
664
+ 数据新增/修改统一使用 `Form`(托管提交)或原生 `<form>` + `FormLayout`,不要在应用复制字段 grid。两者支持 `columns={1 | 2}`、`labelPosition='top' | 'left'`、`labelWidth`;默认单列/标签在上,窄容器自动收拢。`FormField.colSpan='auto'` 自动让多行文本、上传和多选占两列,`1` / `2` 可覆盖;自定义宽控件标注 `data-form-wide`。已有控件 ID 或原生控件给 FormField 配 `for`,避免重复或失效的标签。
665
+
666
+ IAM 表单、AuthPanel、UserProfile、FeedbackModal 直接接收同组参数;CRUD 使用 `form={{ columns: 2, labelPosition: 'left', labelWidth: '6rem' }}`,字段支持 `colSpan` 和延迟翻译的 `validation.message: () => m.required()`。通用字段的 `label/error/hint`、Input/Textarea/Select 的 `validationMessage` 支持翻译函数。内置必填、类型、长度和范围错误由 UI 提供;应用只提供业务文案。通过 UI 的 `setGlobalLocale` / `createLocaleStore` 同步语言,已显示的错误随之更新;自定义提示只用于失败,不能把固定提示直接设成持续的 customValidity。字段错误只在 FormField 或控件其中一处展示。
667
+
668
+ 验证一列/双列与两种标签位置、自动/显式跨列、窄抽屉/手机、无效提交与修正、切换语言后的错误、按钮和回车提交,以及关闭重开后的状态。
669
+
670
+
671
+ ```svelte
672
+ <!-- m、save、name、notes 为应用自己的翻译、提交回调和状态 -->
673
+ <Form columns={2} labelPosition="left" labelWidth="6rem" onsubmit={save}>
674
+ <FormField label={() => m.name()} required>
675
+ <Input name="name" bind:value={name} required />
676
+ </FormField>
677
+ <FormField label={() => m.notes()}>
678
+ <Textarea name="notes" bind:value={notes} />
679
+ </FormField>
680
+ <Button type="submit">{m.save()}</Button>
681
+ </Form>
682
+ ```