32 KiB
Register Page Improvement Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Improve the registration page with component decomposition, shared schema, field-level error handling, password UX enhancements, and visual polish.
Architecture: Decompose register.vue into 4 focused components (RegisterForm, PasswordInput, PasswordStrength, CaptchaField) with a shared Zod schema in packages/shared/. Fix R.error to pass data through, add ApiError class for typed error handling from useHttpFetch.
Tech Stack: Nuxt 4.4.2, Nuxt UI 4.6.1, Vue 3.5.32, Zod 4.3.6, Tailwind CSS 4.2.2, Vitest, @nuxt/test-utils
Task 1: Fix R.error utility
Files:
- Modify:
server/utils/response.ts:17-17
Context: R.error<T>(message: string, data: T) accepts a data parameter but always returns data: null. This blocks field-level error responses. Only one caller exists (server/api/auth/register.post.ts) and all callers pass null as data, so the fix is safe.
- Step 1: Fix R.error to return the data parameter
In server/utils/response.ts, change line 17 from data: null to data: data:
export const R = {
success: <T>(data: T) => {
return {
code: 0,
message: 'success',
data: data,
} as const
},
error: <T>(message: string, data: T) => {
return {
code: 1,
message: message,
data: data,
} as const
},
throwError: <T>(code: number, message: string, data: T) => {
throw createError({
statusCode: code,
statusMessage: message,
data: data,
}) as never
}
} as const
- Step 2: Verify build
Run: bun run build 2>&1 | tail -5
Expected: Build succeeds, no type errors from existing R.error(null) callers.
- Step 3: Commit
git add server/utils/response.ts
git commit -m "fix: return data parameter in R.error response helper
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
Task 2: Create ApiError class for field-level error transport
Files:
- Modify:
app/utils/http/factory.ts
Context: useHttpFetch auto-unwraps API responses via unwrapApiBody, which throws new Error(message) on non-zero code — losing data. We need data.field to survive the throw so the form can call setFieldError. Create an ApiError class that preserves data, and update unwrapApiBody to throw it.
- Step 1: Add ApiError class and update unwrapApiBody
In app/utils/http/factory.ts, add the class and update the unwrap function:
import { createUseFetch } from '#imports'
/** 与 `R.success` / `R.error` 返回结构对齐 */
export type ApiResponse<T = unknown> = {
code: number
message: string
data: T
}
/** Wraps API error responses, preserving the data field for field-level error handling */
export class ApiError extends Error {
constructor(message: string, public data: unknown) {
super(message)
this.name = 'ApiError'
}
}
/** 从 Nitro 推断的响应体上剥离一层 `ApiResponse`,得到 `data` 字段类型 */
export type UnwrapApiResponse<T> = T extends ApiResponse<infer D> ? D : T
export function unwrapApiBody<T>(payload: ApiResponse<T>): T {
if (payload.code !== 0) {
throw new ApiError(payload.message, payload.data)
}
return payload.data
}
export const request = $fetch.create({})
const httpFetchDefaults = {
retry: 0,
$fetch: request,
transform: unwrapApiBody,
}
export const _useHttpFetch = createUseFetch(httpFetchDefaults)
export const _useLazyHttpFetch = createUseFetch({
...httpFetchDefaults,
lazy: true,
})
- Step 2: Verify build
Run: bun run build 2>&1 | tail -5
Expected: Build succeeds.
- Step 3: Commit
git add app/utils/http/factory.ts
git commit -m "feat: add ApiError class to preserve error data through unwrapApiBody
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
Task 3: Extract shared Zod schema
Files:
- Create:
packages/shared/auth-schema.ts - Create:
packages/shared/package.json - Modify:
nuxt.config.ts(add path alias) - Modify:
app/pages/register.vue(import from shared, remove inline schema) - Modify:
server/utils/auth/validation.ts(re-export from shared) - Delete:
server/utils/auth/validation.tscontent (replace with re-export)
Context: Both client and server have near-identical Zod schemas. Extract to a shared package. The shared package needs to be importable by both Nitro (server) and the Nuxt app (client). Use a simple TS file + path alias approach.
- Step 1: Create packages/shared/package.json
mkdir -p packages/shared
{
"name": "shared",
"type": "module",
"private": true
}
- Step 2: Create the shared schema
In packages/shared/auth-schema.ts:
import { z } from 'zod'
export const registerSchema = z
.object({
username: z
.string()
.trim()
.min(3, '用户名至少需要3个字符')
.max(30, '用户名最多30个字符'),
password: z.string().min(8, '密码至少需要8个字符'),
confirmPassword: z.string(),
captchaToken: z.string().min(1, '验证码令牌不能为空'),
captchaText: z.string().min(1, '验证码不能为空'),
})
.refine((data) => data.password === data.confirmPassword, {
message: '两次输入的密码不一致',
path: ['confirmPassword'],
})
/** Client-side schema: omits captchaToken (managed by page shell) */
export const clientRegisterSchema = registerSchema.omit({ captchaToken: true })
export type RegisterInput = z.infer<typeof clientRegisterSchema>
- Step 3: Add path alias in nuxt.config.ts
In nuxt.config.ts, add the shared alias to compilerOptions.paths:
paths: {
'drizzle-pkg': ['./packages/drizzle-pkg/lib'],
'logger': ['./packages/logger/lib'],
'shared': ['./packages/shared']
},
- Step 4: Update server validation to re-export from shared
Replace server/utils/auth/validation.ts:
export { registerSchema } from 'shared/auth-schema'
- Step 5: Update client-side register.vue to use shared schema
In app/pages/register.vue, remove the inline formSchema (lines 4-14) and replace with:
import { clientRegisterSchema } from 'shared/auth-schema'
const formSchema = clientRegisterSchema
- Step 6: Verify build
Run: bun run build 2>&1 | tail -10
Expected: Build succeeds with both client and server resolving the shared schema.
- Step 7: Commit
git add packages/shared/ nuxt.config.ts server/utils/auth/validation.ts app/pages/register.vue
git commit -m "refactor: extract shared Zod registration schema to packages/shared
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
Task 4: Update server register endpoint for field-level errors
Files:
- Modify:
server/api/auth/register.post.ts
Context: Server errors currently use R.error(message, null). Update to pass { field: 'fieldName' } so the client can call setFieldError on the correct field.
- Step 1: Update error responses with field metadata
import { registerSchema } from '../../utils/auth/validation'
import { verifyCaptcha } from '../../utils/auth/captcha'
import { dbGlobal } from 'drizzle-pkg/lib/db'
import { users } from 'drizzle-pkg/lib/schema/auth'
import { hash } from 'bcryptjs'
import log4js from 'logger'
const logger = log4js.getLogger('AUTH')
export default defineWrappedResponseHandler(async (event) => {
const body = await readBody(event)
const parsed = registerSchema.safeParse(body)
if (!parsed.success) {
const field = parsed.error.issues[0]?.path[0]?.toString() ?? undefined
return R.error(parsed.error.issues[0]?.message || '表单验证失败', { field })
}
const { username, password, captchaToken, captchaText } = parsed.data
if (!verifyCaptcha(captchaToken, captchaText)) {
return R.error('验证码错误或已过期', { field: 'captchaText' })
}
const hashedPassword = await hash(password, 10)
try {
const result = await dbGlobal
.insert(users)
.values({
username,
password: hashedPassword,
role: 'user',
status: 'active',
})
.returning({ id: users.id })
return R.success({ id: result[0].id, username })
} catch (err: any) {
const msg = String(err?.message ?? '')
if (msg.toLowerCase().includes('unique') || msg.includes('SQLITE_CONSTRAINT')) {
return R.error('用户名已存在', { field: 'username' })
}
logger.error('Failed to insert user', msg)
return R.error('注册失败,请稍后重试', null)
}
})
- Step 2: Verify build
Run: bun run build 2>&1 | tail -5
Expected: Build succeeds.
- Step 3: Commit
git add server/api/auth/register.post.ts
git commit -m "feat: add field-level error metadata to register endpoint responses
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
Task 5: Install test dependencies
Files:
- Modify:
package.json(devDependencies) - Create:
vitest.config.ts
Context: Project has no test framework. Add vitest for unit tests and @nuxt/test-utils for component tests.
- Step 1: Install vitest
Run: bun add -d vitest
Expected: vitest added to devDependencies.
- Step 2: Install @nuxt/test-utils
Run: bun add -d @nuxt/test-utils @vue/test-utils happy-dom
Expected: Packages added to devDependencies.
- Step 3: Create vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'happy-dom',
include: ['**/*.{test,spec}.{ts,js}'],
},
})
- Step 4: Verify vitest runs
Run: bun vitest --version
Expected: Prints vitest version.
- Step 5: Commit
git add package.json bun.lock vitest.config.ts
git commit -m "chore: add vitest and @nuxt/test-utils for testing
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
Task 6: Unit tests for auth-schema
Files:
- Create:
packages/shared/__tests__/auth-schema.test.ts
Context: Test the shared Zod schema validation rules for both valid and invalid inputs. Pure unit test, no Vue/Nuxt needed.
- Step 1: Write the test
In packages/shared/__tests__/auth-schema.test.ts:
import { describe, it, expect } from 'vitest'
import { registerSchema, clientRegisterSchema } from '../auth-schema'
describe('registerSchema', () => {
const validBody = {
username: 'testuser',
password: 'password123',
confirmPassword: 'password123',
captchaToken: 'token-abc',
captchaText: 'abcde',
}
it('passes with valid input', () => {
expect(registerSchema.safeParse(validBody).success).toBe(true)
})
it('fails on short username', () => {
const r = registerSchema.safeParse({ ...validBody, username: 'ab' })
expect(r.success).toBe(false)
if (!r.success) {
expect(r.error.issues[0].path).toContain('username')
}
})
it('fails on short password', () => {
const r = registerSchema.safeParse({ ...validBody, password: '1234567' })
expect(r.success).toBe(false)
if (!r.success) {
expect(r.error.issues[0].path).toContain('password')
}
})
it('fails on password mismatch', () => {
const r = registerSchema.safeParse({
...validBody,
confirmPassword: 'different',
})
expect(r.success).toBe(false)
if (!r.success) {
expect(r.error.issues[0].path).toContain('confirmPassword')
}
})
it('fails on empty captchaText', () => {
const r = registerSchema.safeParse({ ...validBody, captchaText: '' })
expect(r.success).toBe(false)
if (!r.success) {
expect(r.error.issues[0].path).toContain('captchaText')
}
})
it('fails on empty captchaToken', () => {
const r = registerSchema.safeParse({ ...validBody, captchaToken: '' })
expect(r.success).toBe(false)
if (!r.success) {
expect(r.error.issues[0].path).toContain('captchaToken')
}
})
})
describe('clientRegisterSchema', () => {
it('excludes captchaToken from the schema', () => {
const shape = clientRegisterSchema._def.schema.shape()
expect('captchaToken' in shape).toBe(false)
expect('username' in shape).toBe(true)
})
it('validates without captchaToken field', () => {
const r = clientRegisterSchema.safeParse({
username: 'testuser',
password: 'password123',
confirmPassword: 'password123',
captchaText: 'abcde',
})
expect(r.success).toBe(true)
})
})
- Step 2: Run the test — verify it fails (file doesn't exist yet)
Wait — the test file is new and the schema already exists (created in Task 3), so tests should pass. Run:
Run: bun vitest run packages/shared/__tests__/auth-schema.test.ts
Expected: 7 tests pass.
- Step 3: Commit
git add packages/shared/__tests__/auth-schema.test.ts
git commit -m "test: add zod schema validation tests for shared auth-schema
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
Task 7: PasswordStrength component (TDD)
Files:
- Create:
app/components/register/PasswordStrength.vue - Create:
app/components/register/__tests__/PasswordStrength.test.ts
Context: Pure display component — receives a password string, computes a score 0-4 based on composition and length, renders a color-coded bar and label.
Scoring algorithm:
-
score 0: empty password, nothing rendered
-
score 1: < 8 chars, only digits OR only letters — red, "弱"
-
score 2: >= 8 chars, letters + digits — amber, "中"
-
score 3: >= 10 chars, upper + lower + digits — green, "强"
-
score 4: >= 12 chars, upper + lower + digits + special chars — emerald, "很强"
-
Step 1: Write component test
In app/components/register/__tests__/PasswordStrength.test.ts:
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PasswordStrength from '../PasswordStrength.vue'
function score(password: string): number {
const comp = mount(PasswordStrength, { props: { password } })
// The component renders data-testid="strength-bar" with title attr containing score
const bar = comp.find('[data-testid="strength-bar"]')
if (!bar.exists()) return 0
return Number(bar.attributes('data-score') || 0)
}
describe('PasswordStrength', () => {
it('renders nothing for empty password', () => {
const wrapper = mount(PasswordStrength, { props: { password: '' } })
expect(wrapper.find('[data-testid="strength-bar"]').exists()).toBe(false)
})
it('score 1 for weak password (only letters, < 8)', () => {
const wrapper = mount(PasswordStrength, { props: { password: 'abc' } })
expect(wrapper.find('[data-testid="strength-bar"]').attributes('data-score')).toBe('1')
expect(wrapper.text()).toContain('弱')
})
it('score 1 for only digits', () => {
const wrapper = mount(PasswordStrength, { props: { password: '1234567' } })
expect(wrapper.find('[data-testid="strength-bar"]').attributes('data-score')).toBe('1')
})
it('score 2 for letters+digits >= 8 chars', () => {
const wrapper = mount(PasswordStrength, { props: { password: 'abc12345' } })
expect(wrapper.find('[data-testid="strength-bar"]').attributes('data-score')).toBe('2')
expect(wrapper.text()).toContain('中')
})
it('score 3 for upper+lower+digits >= 10 chars', () => {
const wrapper = mount(PasswordStrength, { props: { password: 'Abcdef1234' } })
expect(wrapper.find('[data-testid="strength-bar"]').attributes('data-score')).toBe('3')
expect(wrapper.text()).toContain('强')
})
it('score 4 for upper+lower+digits+special >= 12 chars', () => {
const wrapper = mount(PasswordStrength, { props: { password: 'Abcdef1234!@' } })
expect(wrapper.find('[data-testid="strength-bar"]').attributes('data-score')).toBe('4')
expect(wrapper.text()).toContain('很强')
})
})
- Step 2: Run the test — expect FAIL
Run: bun vitest run app/components/register/__tests__/PasswordStrength.test.ts
Expected: FAIL — component file does not exist.
- Step 3: Implement the component
In app/components/register/PasswordStrength.vue:
<script setup lang="ts">
const props = defineProps<{ password: string }>()
const score = computed(() => {
const p = props.password
if (!p) return 0
let s = 0
const hasLower = /[a-z]/.test(p)
const hasUpper = /[A-Z]/.test(p)
const hasDigit = /\d/.test(p)
const hasSpecial = /[^a-zA-Z\d]/.test(p)
if (p.length >= 8 && hasLower && hasDigit) s = 2
if (p.length >= 10 && hasLower && hasUpper && hasDigit) s = 3
if (p.length >= 12 && hasLower && hasUpper && hasDigit && hasSpecial) s = 4
// Fallback: any input < 8 or single-type = weak
if (s === 0 && p.length > 0) s = 1
return s
})
const label = computed(() => {
const labels = ['', '弱', '中', '强', '很强']
return labels[score.value]
})
const barWidth = computed(() => `${(score.value / 4) * 100}%`)
const barColor = computed(() => {
const colors = ['', 'bg-red-500', 'bg-amber-500', 'bg-green-500', 'bg-emerald-500']
return colors[score.value]
})
</script>
<template>
<div v-if="score > 0" class="mt-1 space-y-1">
<div class="h-1 w-full rounded-full bg-(--ui-bg-accented)">
<div
data-testid="strength-bar"
:data-score="score"
:class="[barColor, 'h-1 rounded-full transition-all duration-300']"
:style="{ width: barWidth }"
/>
</div>
<p class="text-xs" :class="{
'text-red-500': score === 1,
'text-amber-500': score === 2,
'text-green-500': score === 3,
'text-emerald-500': score === 4,
}">{{ label }}</p>
</div>
</template>
- Step 4: Run the test — verify PASS
Run: bun vitest run app/components/register/__tests__/PasswordStrength.test.ts
Expected: 6 tests pass.
- Step 5: Commit
git add app/components/register/PasswordStrength.vue app/components/register/__tests__/PasswordStrength.test.ts
git commit -m "feat: add PasswordStrength component with real-time strength scoring
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
Task 8: PasswordInput component (TDD)
Files:
- Create:
app/components/register/PasswordInput.vue - Create:
app/components/register/__tests__/PasswordInput.test.ts
Context: Wraps Nuxt UI's UInput with a show/hide toggle button. Uses v-model for two-way binding. The toggle switches type between "password" and "text".
- Step 1: Write component test
In app/components/register/__tests__/PasswordInput.test.ts:
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PasswordInput from '../PasswordInput.vue'
describe('PasswordInput', () => {
it('renders with type=password by default', () => {
const wrapper = mount(PasswordInput, {
props: { modelValue: '' },
})
const input = wrapper.find('input')
expect(input.attributes('type')).toBe('password')
})
it('toggles to type=text when button clicked', async () => {
const wrapper = mount(PasswordInput, {
props: { modelValue: '' },
})
const btn = wrapper.find('button')
await btn.trigger('click')
expect(wrapper.find('input').attributes('type')).toBe('text')
})
it('renders label when provided', () => {
const wrapper = mount(PasswordInput, {
props: { modelValue: '', label: 'Test Label' },
})
// UFormField renders the label — check for presence
expect(wrapper.text()).toContain('Test Label')
})
it('disables input when disabled prop is true', () => {
const wrapper = mount(PasswordInput, {
props: { modelValue: '', disabled: true },
})
expect(wrapper.find('input').element.disabled).toBe(true)
})
})
- Step 2: Run the test — expect FAIL
Run: bun vitest run app/components/register/__tests__/PasswordInput.test.ts
Expected: FAIL — component file does not exist.
- Step 3: Implement the component
In app/components/register/PasswordInput.vue:
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue: string
label?: string
placeholder?: string
disabled?: boolean
}>(), {
label: '密码',
placeholder: '请输入密码',
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const showPassword = ref(false)
</script>
<template>
<UFormField :label="label" required>
<div class="relative">
<UInput
:type="showPassword ? 'text' : 'password'"
:model-value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
class="w-full"
@update:model-value="emit('update:modelValue', $event)"
/>
<UButton
variant="link"
color="neutral"
size="sm"
:icon="showPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
class="absolute right-1 top-1/2 -translate-y-1/2"
@click="showPassword = !showPassword"
/>
</div>
</UFormField>
</template>
- Step 4: Run the test — verify PASS
Run: bun vitest run app/components/register/__tests__/PasswordInput.test.ts
Expected: 4 tests pass.
- Step 5: Commit
git add app/components/register/PasswordInput.vue app/components/register/__tests__/PasswordInput.test.ts
git commit -m "feat: add PasswordInput component with show/hide toggle
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
Task 9: CaptchaField component (TDD)
Files:
- Create:
app/components/register/CaptchaField.vue - Create:
app/components/register/__tests__/CaptchaField.test.ts
Context: Displays captcha SVG (v-html), refresh button with loading spinner, and a text input. Supports v-model for the captcha text value. Emits refresh when the button is clicked.
- Step 1: Write component test
In app/components/register/__tests__/CaptchaField.test.ts:
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import CaptchaField from '../CaptchaField.vue'
const defaultProps = { svg: '<svg></svg>', loading: false, modelValue: '' }
describe('CaptchaField', () => {
it('renders SVG via v-html', () => {
const svg = '<svg><circle cx="10" cy="10" r="5"/></svg>'
const wrapper = mount(CaptchaField, {
props: { ...defaultProps, svg },
})
expect(wrapper.html()).toContain('<circle')
})
it('emits refresh when button clicked', async () => {
const wrapper = mount(CaptchaField, { props: defaultProps })
const btn = wrapper.find('button')
await btn.trigger('click')
expect(wrapper.emitted('refresh')).toBeTruthy()
})
it('disables refresh button when loading', () => {
const wrapper = mount(CaptchaField, {
props: { ...defaultProps, loading: true },
})
const btn = wrapper.find('button')
expect(btn.attributes('disabled')).toBeDefined()
})
it('renders error state when svg is empty', () => {
const wrapper = mount(CaptchaField, {
props: { ...defaultProps, svg: '' },
})
expect(wrapper.text()).toContain('加载失败')
})
it('emits update:modelValue when input changes', async () => {
const wrapper = mount(CaptchaField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
await input.setValue('abc')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['abc'])
})
})
- Step 2: Run the test — expect FAIL
Run: bun vitest run app/components/register/__tests__/CaptchaField.test.ts
Expected: FAIL — component file does not exist.
- Step 3: Implement the component
In app/components/register/CaptchaField.vue:
<script setup lang="ts">
defineProps<{
svg: string
loading: boolean
modelValue: string
}>()
const emit = defineEmits<{
refresh: []
'update:modelValue': [value: string]
}>()
</script>
<template>
<UFormField label="Captcha" required>
<div v-if="svg" class="flex gap-2 items-start">
<!-- eslint-disable-next-line vue/no-v-html -->
<div
class="flex-1 h-10 border rounded-md overflow-hidden bg-[#f8f9fa]"
role="img"
aria-label="CAPTCHA verification code"
v-html="svg"
/>
<UButton
variant="ghost"
color="neutral"
:icon="'i-lucide-refresh-cw'"
:class="{ 'animate-spin': loading }"
:disabled="loading"
square
aria-label="刷新验证码"
@click="emit('refresh')"
/>
</div>
<div v-else class="text-sm text-red-500">
验证码加载失败,
<button class="underline" @click="emit('refresh')">点此重试</button>
</div>
<UInput
:model-value="modelValue"
placeholder="Enter the code above"
class="mt-2"
@update:model-value="emit('update:modelValue', $event)"
/>
</UFormField>
</template>
- Step 4: Run the test — verify PASS
Run: bun vitest run app/components/register/__tests__/CaptchaField.test.ts
Expected: 5 tests pass.
- Step 5: Commit
git add app/components/register/CaptchaField.vue app/components/register/__tests__/CaptchaField.test.ts
git commit -m "feat: add CaptchaField component with v-model and loading/error states
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
Task 10: RegisterForm component
Files:
- Create:
app/components/register/RegisterForm.vue
Context: The main form component. Uses UForm with the shared clientRegisterSchema. Integrates PasswordInput, PasswordStrength, CaptchaField. Handles submit via useHttpFetch, catches ApiError for field-level errors.
Note: Use raw $fetch instead of useHttpFetch for the register call. The useHttpFetch composable is designed for GET/query patterns and its error path via ApiError is less ergonomic for form submission where we need the full response structure. Using $fetch directly gives us full control over the { code, message, data } envelope and field-level error handling.
- Step 1: Create the component
In app/components/register/RegisterForm.vue:
<script setup lang="ts">
import { clientRegisterSchema, type RegisterInput } from 'shared/auth-schema'
const props = defineProps<{
captchaSvg: string
captchaToken: string
}>()
const emit = defineEmits<{
'refresh-captcha': []
success: []
error: [message: string]
}>()
const loading = ref(false)
const formRef = ref()
const state = reactive<RegisterInput>({
username: '',
password: '',
confirmPassword: '',
captchaText: '',
})
const password = computed(() => state.password)
async function onSubmit() {
if (loading.value) return
loading.value = true
try {
const res = await $fetch<{ code: number; message: string; data: { id?: number; username?: string; field?: string } | null }>(
'/api/auth/register',
{
method: 'POST',
body: {
username: state.username,
password: state.password,
confirmPassword: state.confirmPassword,
captchaToken: props.captchaToken,
captchaText: state.captchaText,
},
}
)
if (res.code !== 0) {
const field = res.data && 'field' in (res.data || {}) ? (res.data as { field: string }).field : undefined
if (field && formRef.value) {
formRef.value.setFieldError(field, res.message)
} else {
emit('error', res.message)
}
// Auto-refresh captcha on captcha-related errors
if (res.message.includes('验证码')) {
emit('refresh-captcha')
state.captchaText = ''
}
return
}
emit('success')
} catch {
emit('error', '注册失败,请稍后重试')
} finally {
loading.value = false
}
}
</script>
<template>
<UForm ref="formRef" :state="state" :schema="clientRegisterSchema" @submit="onSubmit">
<UFormField name="username" label="用户名" required class="mb-4">
<UInput v-model="state.username" placeholder="请输入用户名" :disabled="loading" />
</UFormField>
<PasswordInput
v-model="state.password"
label="密码"
placeholder="至少8个字符"
:disabled="loading"
class="mb-4"
/>
<PasswordStrength :password="password" />
<PasswordInput
v-model="state.confirmPassword"
label="确认密码"
placeholder="请再次输入密码"
:disabled="loading"
class="mb-4 mt-2"
/>
<CaptchaField
v-model="state.captchaText"
:svg="captchaSvg"
:loading="loading"
class="mb-4"
@refresh="emit('refresh-captcha')"
/>
<UButton type="submit" block :loading="loading" class="mt-6">
{{ loading ? '注册中...' : '注册' }}
</UButton>
</UForm>
</template>
- Step 2: Verify build
Run: bun run build 2>&1 | tail -10
Expected: Build succeeds. The component references shared schema and sub-components.
- Step 3: Commit
git add app/components/register/RegisterForm.vue
git commit -m "feat: add RegisterForm component with field-level error handling
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
Task 11: Refactor register.vue page shell
Files:
- Modify:
app/pages/register.vue
Context: Rewrite the page shell to use RegisterForm and manage only captcha state + toast notifications. Shrink from ~146 lines to ~60 lines.
- Step 1: Rewrite register.vue
<script setup lang="ts">
const toast = useToast()
const captchaToken = ref('')
const captchaSvg = ref('')
const captchaLoading = ref(false)
async function fetchCaptcha() {
captchaLoading.value = true
try {
const res = await $fetch<{ code: number; data: { token: string; svg: string } }>(
'/api/auth/captcha'
)
captchaToken.value = res.data.token
captchaSvg.value = res.data.svg
} catch {
captchaSvg.value = ''
} finally {
captchaLoading.value = false
}
}
function handleSuccess() {
navigateTo('/login?registered=1')
}
function handleError(message: string) {
toast.add({ title: message, color: 'error' })
}
onMounted(() => {
fetchCaptcha()
})
useHead({ title: '注册' })
</script>
<template>
<div class="flex items-center justify-center min-h-[calc(100vh-200px)] py-8">
<UCard class="w-full max-w-[400px]">
<template #header>
<h1 class="text-xl font-semibold">创建账号</h1>
<p class="text-sm text-(--ui-text-muted) mt-1">填写信息开始使用</p>
</template>
<RegisterForm
:captcha-svg="captchaSvg"
:captcha-token="captchaToken"
@refresh-captcha="fetchCaptcha"
@success="handleSuccess"
@error="handleError"
/>
<template #footer>
<p class="text-sm text-center text-(--ui-text-muted)">
已有账号?
<NuxtLink to="/login" class="text-(--ui-primary) hover:underline">去登录</NuxtLink>
</p>
</template>
</UCard>
</div>
</template>
- Step 2: Verify build
Run: bun run build 2>&1 | tail -10
Expected: Build succeeds with no errors.
- Step 3: Commit
git add app/pages/register.vue
git commit -m "refactor: decompose register page into component-driven architecture
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
Task 12: End-to-end verification
Files:
- None (verification only)
Context: Start the dev server, verify the registration page renders and functions correctly.
- Step 1: Start dev server
Run in background: bun run dev
Expected: Nuxt dev server starts on port 3000.
- Step 2: Check page renders
Run: curl -s http://localhost:3000/register | head -20
Expected: HTML response with registration form elements.
- Step 3: Run all tests
Run: bun vitest run
Expected: All tests pass (schema tests + component tests).
- Step 4: Check for TypeScript errors
Run: bun x nuxi typecheck 2>&1 | tail -20
Expected: No type errors.
- Step 5: Verify captcha API works
Run: curl -s http://localhost:3000/api/auth/captcha | head -5
Expected: JSON response with { code: 0, data: { token: "...", svg: "..." } }.
- Step 6: Commit any final fixes
git add -A
git commit -m "chore: final verification fixes for registration page improvement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"