You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

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.ts content (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>"