# 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(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`: ```typescript export const R = { success: (data: T) => { return { code: 0, message: 'success', data: data, } as const }, error: (message: string, data: T) => { return { code: 1, message: message, data: data, } as const }, throwError: (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** ```bash 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 " ``` --- ### 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: ```typescript import { createUseFetch } from '#imports' /** 与 `R.success` / `R.error` 返回结构对齐 */ export type ApiResponse = { 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 extends ApiResponse ? D : T export function unwrapApiBody(payload: ApiResponse): 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** ```bash 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 " ``` --- ### 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** ```bash mkdir -p packages/shared ``` ```json { "name": "shared", "type": "module", "private": true } ``` - [ ] **Step 2: Create the shared schema** In `packages/shared/auth-schema.ts`: ```typescript 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 ``` - [ ] **Step 3: Add path alias in nuxt.config.ts** In `nuxt.config.ts`, add the `shared` alias to `compilerOptions.paths`: ```typescript 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`: ```typescript 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: ```typescript 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** ```bash 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 " ``` --- ### 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** ```typescript 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** ```bash 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 " ``` --- ### 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** ```typescript 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** ```bash 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 " ``` --- ### 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`: ```typescript 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** ```bash 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 " ``` --- ### 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`: ```typescript 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`: ```vue ``` - [ ] **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** ```bash 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 " ``` --- ### 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`: ```typescript 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`: ```vue ``` - [ ] **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** ```bash 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 " ``` --- ### 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`: ```typescript import { describe, it, expect } from 'vitest' import { mount } from '@vue/test-utils' import CaptchaField from '../CaptchaField.vue' const defaultProps = { svg: '', loading: false, modelValue: '' } describe('CaptchaField', () => { it('renders SVG via v-html', () => { const svg = '' const wrapper = mount(CaptchaField, { props: { ...defaultProps, svg }, }) expect(wrapper.html()).toContain(' { 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`: ```vue ``` - [ ] **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** ```bash 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 " ``` --- ### 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`: ```vue ``` - [ ] **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** ```bash 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 " ``` --- ### 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** ```vue ``` - [ ] **Step 2: Verify build** Run: `bun run build 2>&1 | tail -10` Expected: Build succeeds with no errors. - [ ] **Step 3: Commit** ```bash git add app/pages/register.vue git commit -m "refactor: decompose register page into component-driven architecture Co-Authored-By: Claude Opus 4.7 " ``` --- ### 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** ```bash git add -A git commit -m "chore: final verification fixes for registration page improvement Co-Authored-By: Claude Opus 4.7 " ```