跳转至

03 前端开发:从 HTML/CSS 原型到 Vue SPA

前端是 AI 最容易“看起来做完、实际不能用”的地方。原因不是 AI 不会写 CSS,而是它很容易漏掉状态、交互、接口错误和边界。本章的核心方法是:先做静态原型,再做组件化 SPA,最后接真实 API。


1. 为什么要分两阶段

阶段 A:静态 HTML/CSS 原型

目标:视觉和布局正确。

  • 纯 HTML + CSS,少量原生 JS;
  • 数据写死;
  • 能点、能看、能截图;
  • 修改成本极低;
  • 人可以快速评审“像不像设计稿”。

阶段 B:Vue SPA

目标:交互、状态、接口正确。

  • Vue 3 + TypeScript + Vite;
  • 组件化;
  • 路由、状态管理;
  • 接真实 API;
  • 错误、加载、空状态;
  • 单元测试 + E2E。

为什么不直接上 Vue?

因为当视觉和逻辑混在一起时,AI 改一个样式可能顺手改坏一个状态;改一个接口可能顺手改坏布局。分离之后,每次只验证一件事,返工成本最低。


2. 技术栈选择

推荐默认栈(适合大多数 AI 全流程项目):

层 选择 理由
构建 Vite 快、配置少、AI 熟悉
框架 Vue 3 + TypeScript 模板直观、单文件组件适合 AI 生成、生态成熟
路由 Vue Router 官方标准
状态 Pinia 简单、类型友好
请求 fetch / Axios AI 都熟;建议封装统一 client
样式 CSS 变量 + 原生 CSS 或 Tailwind token 驱动;Tailwind 可选,但不要让 AI 混用两套
表单 VeeValidate + Zod 或原生校验 统一错误展示
测试 Vitest + @vue/test-utils 与 Vite 集成好
E2E Playwright 或 Chrome MCP Chrome MCP 用于 AI 驱动的验收
类型生成 openapi-typescript 从 OpenAPI 生成 API 类型
代码质量 ESLint + Prettier + vue-tsc CI 门禁

如果团队已有 React 栈,把 Vue 换成 React 即可,方法论不变。关键是“契约驱动 + 状态完整 + 测试可证”。


3. 阶段 A:静态原型

3.1 目录

frontend/
├── prototype/                 # 静态原型,可独立打开
│   ├── index.html             # 页面索引
│   ├── login.html
│   ├── projects.html
│   ├── project-board.html
│   ├── styles/
│   │   ├── tokens.css
│   │   ├── base.css
│   │   └── components.css
│   └── assets/
├── src/                       # Vue 应用(阶段 B)
├── package.json
└── vite.config.ts

3.2 静态原型规则

  1. 一个页面一个 HTML,方便单独打开和截图。
  2. 共用 tokens.css,禁止页面内写死颜色/间距。
  3. 每个页面包含状态切换:用 URL 参数或按钮切换正常/加载/空/错误,方便评审。
  4. 使用语义化标签:header/nav/main/section/article/button,不要全是 div。
  5. 图标用内联 SVG,统一 currentColor 和 stroke-width。
  6. 字体预加载,避免评审时字体闪烁。
  7. 响应式断点统一:1280px、768px。
  8. 不写业务逻辑,只做视觉和简单交互。

3.3 让 AI 生成静态原型的提示词

你是资深前端工程师。请基于以下输入生成静态 HTML/CSS 原型:

- 页面:P-003 项目看板
- 设计规格:@docs/02-ui/ui-spec.md#p-003
- token:@docs/02-ui/design-tokens.json
- 设计稿:@docs/02-ui/screens/p003.png

要求:
1. 生成单个 `frontend/prototype/project-board.html`,可直接用浏览器打开。
2. 引入 `styles/tokens.css`,不要写死颜色和间距。
3. 使用语义化 HTML5 标签。
4. 包含四个状态,通过 URL 参数 `?state=default|loading|empty|error` 切换。
5. 1440/1024/390 三个断点都要可用。
6. 不引入任何 CSS/JS 框架。
7. 所有图标用内联 SVG。
8. 可点击元素有 hover/focus 样式。
9. 输出后自查:有没有魔法值?有没有 div 滥用?有没有缺失状态?

3.4 原型验收

用 Chrome MCP 检查:

请打开 frontend/prototype/project-board.html,
分别在 1440x900、1024x768、390x844 三个视口截图,
保存到 docs/02-ui/screens/prototype-p003-*.png。
然后用 chrome_javascript 检查:
1. 所有按钮是否有可访问名称
2. 所有图片是否有 alt
3. 页面是否有横向滚动条
4. 主内容的 computed style 是否符合 token
只输出检查结果,不要读取截图。

人工验收重点:

  • 和设计稿的关键间距、字号、颜色是否一致;
  • 三种断点是否都能用;
  • 四个状态是否都能切换;
  • 键盘 Tab 是否能走通;
  • 控制台是否无报错。

4. 阶段 B:初始化 Vue 应用

4.1 创建项目

cd frontend
npm create vite@latest . -- --template vue-ts
npm install
npm install vue-router pinia axios
npm install -D vitest @vue/test-utils jsdom @types/node openapi-typescript eslint prettier

4.2 目录结构

frontend/src/
├── main.ts
├── App.vue
├── router/
│   └── index.ts
├── stores/
│   ├── auth.ts
│   ├── project.ts
│   └── task.ts
├── api/
│   ├── client.ts              # 统一请求封装
│   ├── auth.ts
│   ├── projects.ts
│   ├── tasks.ts
│   └── types.ts               # 从 OpenAPI 生成
├── components/
│   ├── ui/                    # 通用组件
│   │   ├── AppButton.vue
│   │   ├── AppInput.vue
│   │   ├── AppModal.vue
│   │   ├── AppTable.vue
│   │   ├── EmptyState.vue
│   │   ├── ErrorState.vue
│   │   └── LoadingSkeleton.vue
│   ├── layout/
│   │   ├── AppHeader.vue
│   │   ├── AppSidebar.vue
│   │   └── AppLayout.vue
│   ├── project/
│   └── task/
├── views/
│   ├── LoginView.vue
│   ├── ProjectListView.vue
│   ├── ProjectBoardView.vue
│   ├── MemberListView.vue
│   └── NotFoundView.vue
├── composables/
│   ├── useAsync.ts
│   ├── useForm.ts
│   └── useToast.ts
├── styles/
│   ├── tokens.css
│   ├── base.css
│   └── components.css
└── types/
    └── domain.ts

4.3 统一 API 客户端

不要让 AI 在每个页面里直接写 fetch。统一封装:

// src/api/client.ts
import axios, { AxiosError } from "axios";

export interface ApiError {
  code: string;
  message: string;
  request_id?: string;
  details?: Record<string, unknown>;
}

export const client = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || "/api/v1",
  timeout: 15000,
  withCredentials: true, // 如果使用 httpOnly cookie
});

client.interceptors.request.use((config) => {
  const token = localStorage.getItem("access_token");
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  config.headers["X-Request-Id"] = crypto.randomUUID();
  return config;
});

client.interceptors.response.use(
  (response) => response,
  (error: AxiosError<ApiError>) => {
    const apiError: ApiError = error.response?.data ?? {
      code: "NETWORK_ERROR",
      message: "网络异常,请稍后重试",
    };
    if (error.response?.status === 401) {
      // 触发登出或刷新令牌
      window.dispatchEvent(new CustomEvent("auth:unauthorized"));
    }
    return Promise.reject(apiError);
  }
);

规则:

  • 所有错误必须转换成统一的 ApiError。
  • UI 层只处理 ApiError,不直接处理 Axios 的复杂错误对象。
  • 请求头带 X-Request-Id,方便和后端日志对齐。
  • 401 统一处理,不要在 20 个页面里各写一遍。

4.4 从 OpenAPI 生成类型

npx openapi-typescript ../docs/01-design/api/openapi.yaml -o src/api/types.ts

然后在 API 模块中使用:

// src/api/tasks.ts
import { client } from "./client";
import type { paths } from "./types";

type CreateTaskRequest =
  paths["/tasks"]["post"]["requestBody"]["content"]["application/json"];
type TaskResponse =
  paths["/tasks"]["post"]["responses"]["201"]["content"]["application/json"];

export async function createTask(body: CreateTaskRequest): Promise<TaskResponse> {
  const { data } = await client.post<TaskResponse>("/tasks", body);
  return data;
}

这一步非常关键:后端改字段,前端类型生成会立刻报错。这是防止前后端契约漂移的最有效手段。


5. 组件设计:让 AI 有章可循

5.1 组件分层

views/            页面级:负责取数、组合业务组件、处理路由
  components/     业务组件:项目卡片、任务卡片、看板列
    ui/           基础组件:按钮、输入框、弹窗、表格

规则:

  • views 可以依赖 components,反之不行。
  • ui 组件不依赖业务 store、不直接请求 API。
  • 业务组件可以依赖 store,但尽量通过 props/emits 保持可测试。
  • 一个组件只做一件事;超过 200 行考虑拆分。

5.2 基础组件示例:AppButton

<script setup lang="ts">
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md" | "lg";

withDefaults(
  defineProps<{
    variant?: Variant;
    size?: Size;
    loading?: boolean;
    disabled?: boolean;
    type?: "button" | "submit" | "reset";
  }>(),
  {
    variant: "primary",
    size: "md",
    loading: false,
    disabled: false,
    type: "button",
  }
);

defineEmits<{ click: [event: MouseEvent] }>();
</script>

<template>
  <button
    :type="type"
    :class="['app-button', `app-button--${variant}`, `app-button--${size}`]"
    :disabled="disabled || loading"
    :aria-busy="loading"
    @click="$emit('click', $event)"
  >
    <span v-if="loading" class="app-button__spinner" aria-hidden="true" />
    <slot />
  </button>
</template>

<style scoped>
.app-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  border: 1px solid transparent;
  border-radius: var(--radius-md);
  font-family: var(--font-sans);
  font-weight: var(--weight-medium);
  cursor: pointer;
  transition: background-color 150ms ease, box-shadow 150ms ease;
}
.app-button:focus-visible {
  outline: 2px solid var(--color-brand-500);
  outline-offset: 2px;
}
.app-button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
.app-button--sm { height: 32px; padding: 0 var(--space-3); font-size: var(--text-sm); }
.app-button--md { height: 40px; padding: 0 var(--space-4); font-size: var(--text-sm); }
.app-button--lg { height: 48px; padding: 0 var(--space-6); font-size: var(--text-md); }
.app-button--primary { background: var(--color-brand-600); color: #fff; }
.app-button--primary:hover:not(:disabled) { background: var(--color-brand-700); }
.app-button--secondary { background: var(--color-neutral-0); border-color: var(--color-neutral-300); color: var(--color-neutral-900); }
.app-button--ghost { background: transparent; color: var(--color-neutral-700); }
.app-button--danger { background: var(--color-danger-500); color: #fff; }
.app-button__spinner {
  width: 14px;
  height: 14px;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin 600ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>

5.3 状态组件

每个数据页面必须处理五种状态:

状态 组件 行为
loading <LoadingSkeleton> 骨架屏,保持布局稳定
empty <EmptyState> 说明 + 主操作
error <ErrorState> 错误信息 + 重试
no-permission <NoPermissionState> 说明 + 返回/申请权限
success 正常内容

用 useAsync 统一管理:

// src/composables/useAsync.ts
import { ref } from "vue";
import type { ApiError } from "@/api/client";

export function useAsync<T>(fn: () => Promise<T>) {
  const data = ref<T | null>(null);
  const loading = ref(false);
  const error = ref<ApiError | null>(null);

  async function run() {
    loading.value = true;
    error.value = null;
    try {
      data.value = await fn();
    } catch (e) {
      error.value = e as ApiError;
    } finally {
      loading.value = false;
    }
  }

  return { data, loading, error, run };
}

页面中使用:

<script setup lang="ts">
import { onMounted } from "vue";
import { useAsync } from "@/composables/useAsync";
import { listTasks } from "@/api/tasks";
import LoadingSkeleton from "@/components/ui/LoadingSkeleton.vue";
import EmptyState from "@/components/ui/EmptyState.vue";
import ErrorState from "@/components/ui/ErrorState.vue";

const { data, loading, error, run } = useAsync(() => listTasks({ project_id: "demo" }));
onMounted(run);
</script>

<template>
  <LoadingSkeleton v-if="loading" />
  <ErrorState v-else-if="error" :error="error" @retry="run" />
  <EmptyState v-else-if="!data?.items.length" title="还没有任务" action-text="新建任务" />
  <KanbanBoard v-else :tasks="data.items" />
</template>

规则:页面不允许出现 v-if="data" 这种“有数据就显示、没数据就白屏”的写法。 必须显式处理五种状态。


6. 路由与鉴权

6.1 路由结构

// src/router/index.ts
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "@/stores/auth";

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: "/login", name: "login", component: () => import("@/views/LoginView.vue"), meta: { public: true } },
    {
      path: "/",
      component: () => import("@/components/layout/AppLayout.vue"),
      children: [
        { path: "", redirect: "/projects" },
        { path: "projects", name: "projects", component: () => import("@/views/ProjectListView.vue") },
        { path: "projects/:id", name: "project-board", component: () => import("@/views/ProjectBoardView.vue"), props: true },
        { path: "projects/:id/members", name: "project-members", component: () => import("@/views/MemberListView.vue"), props: true },
      ],
    },
    { path: "/:pathMatch(.*)*", name: "not-found", component: () => import("@/views/NotFoundView.vue"), meta: { public: true } },
  ],
});

router.beforeEach(async (to) => {
  const auth = useAuthStore();
  if (to.meta.public) return true;
  if (!auth.isAuthenticated) {
    await auth.restore();
  }
  if (!auth.isAuthenticated) {
    return { name: "login", query: { redirect: to.fullPath } };
  }
  return true;
});

export default router;

6.2 鉴权的三个层次

  1. 路由层:未登录跳登录页。
  2. 页面层:无权限显示 NoPermissionState,不要白屏。
  3. 操作层:按钮是否禁用/隐藏,但后端必须再校验一次。

前端鉴权只是体验,不是安全边界。 任何权限判断都必须有后端对应的 403。


7. 表单与校验

7.1 校验规则要前后端一致

前端校验为了体验,后端校验为了安全。两边规则必须来自同一份定义(例如 OpenAPI schema 或共享的 JSON Schema)。

// src/schemas/task.ts
import { z } from "zod";

export const createTaskSchema = z.object({
  title: z.string().trim().min(1, "标题不能为空").max(100, "标题不能超过 100 字"),
  description: z.string().max(2000, "描述不能超过 2000 字").optional(),
  assignee_id: z.string().uuid("请选择负责人"),
  due_date: z.string().date("日期格式不正确").optional(),
  priority: z.enum(["low", "medium", "high"]),
});

7.2 错误展示

  • 字段错误显示在字段下方,不要只弹 toast。
  • 表单级错误显示在表单顶部,带错误码。
  • 提交失败保留用户输入,不要清空。
  • 提交中禁用按钮,显示 loading。
  • 成功后给出明确反馈(toast/跳转)。

8. 前端测试

8.1 单元测试(Vitest)

优先测:

  • 纯函数(格式化、校验、状态转换);
  • 组件渲染(props、emits、状态);
  • store 的 action。
// src/components/ui/__tests__/AppButton.spec.ts
import { mount } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import AppButton from "../AppButton.vue";

describe("AppButton", () => {
  it("renders slot content", () => {
    const wrapper = mount(AppButton, { slots: { default: "保存" } });
    expect(wrapper.text()).toContain("保存");
  });

  it("is disabled while loading", () => {
    const wrapper = mount(AppButton, { props: { loading: true } });
    expect(wrapper.attributes("disabled")).toBeDefined();
    expect(wrapper.attributes("aria-busy")).toBe("true");
  });

  it("emits click when enabled", async () => {
    const wrapper = mount(AppButton);
    await wrapper.trigger("click");
    expect(wrapper.emitted("click")).toHaveLength(1);
  });
});

8.2 组件测试的边界

不要让 AI 写“只验证组件存在”的假测试。好测试应该验证 行为:

  • 点击后是否调用 API;
  • 加载时是否禁用;
  • 错误时是否显示错误信息;
  • 空数据是否显示 empty state。

8.3 E2E

两种方式:

  1. Playwright:适合 CI 中稳定运行。
  2. Chrome MCP:适合 AI 驱动的探索式测试和截图取证。

两者不冲突。CI 用 Playwright 保证回归,验收用 Chrome MCP 生成可读的 Word 报告。


9. 性能与体验

9.1 必做项

  • 路由懒加载:() => import(...)。
  • 图片使用合适尺寸,loading="lazy"。
  • 列表超过 100 条考虑虚拟滚动或分页。
  • 避免在 watch 中做重计算,必要时用 computed。
  • 请求去重/取消:切换页面时取消未完成请求。
  • 骨架屏保持布局稳定,避免 CLS。

9.2 不要过早优化

AI 很容易在没有数据的情况下加一堆“优化”。先让功能正确,再用 Lighthouse/Performance 面板找瓶颈。


10. 可访问性

最低要求:

  • 所有图片有 alt(装饰图用 alt="")。
  • 所有按钮/链接有可访问名称。
  • 表单 label 关联 input。
  • 弹窗打开时焦点锁定,Esc 关闭,关闭后焦点回到触发元素。
  • 错误用 aria-describedby 关联。
  • 颜色对比度 ≥ 4.5:1。
  • 键盘可以完成核心流程。

检查命令(可用 Chrome MCP 执行):

// 找出没有可访问名称的按钮
[...document.querySelectorAll("button")].filter(b =>
  !b.textContent.trim() && !b.getAttribute("aria-label") && !b.getAttribute("title")
).map(b => b.outerHTML)

11. 构建与环境变量

11.1 环境变量

# .env.development
VITE_API_BASE_URL=http://localhost:8080/api/v1

# .env.staging
VITE_API_BASE_URL=https://staging-api.example.com/api/v1

# .env.production
VITE_API_BASE_URL=https://api.example.com/api/v1

不要把密钥写进前端环境变量。 Vite 的 VITE_* 会打包进客户端代码,任何人都能看到。

11.2 构建

npm run typecheck   # vue-tsc --noEmit
npm run lint
npm run test
npm run build

CI 中四个命令必须全绿。


12. AI 生成前端代码的提示词

12.1 页面实现

你是资深 Vue 3 + TypeScript 工程师。请实现 P-003 项目看板页面。

输入:
- 设计规格:@docs/02-ui/ui-spec.md#p-003
- API 契约:@docs/01-design/api/openapi.yaml
- 生成类型:@frontend/src/api/types.ts
- 现有组件:@frontend/src/components/ui/
- 项目约定:@CLAUDE.md

要求:
1. 只修改 frontend/src/views/ProjectBoardView.vue 和 frontend/src/components/task/ 下的文件。
2. 处理 loading/empty/error/no-permission 四种状态。
3. 所有颜色/间距使用 CSS 变量。
4. 使用 useAsync 统一处理请求。
5. 不直接写 fetch/axios,使用 src/api/tasks.ts。
6. 拖拽更新使用乐观更新,失败回滚并提示。
7. 写 Vitest 单元测试,覆盖 loading/empty/error/正常渲染。
8. 运行 npm run typecheck && npm run test && npm run lint,全绿后才算完成。
9. 输出改动文件清单和测试结果。

12.2 组件重构

请把 ProjectBoardView.vue 中重复的任务卡片抽成 TaskCard.vue。

约束:
- 不改变现有视觉和交互。
- Props 使用 TypeScript 接口定义。
- 所有事件用 emits 声明。
- 新组件放在 components/task/TaskCard.vue。
- 补充 TaskCard 的单元测试。
- 运行 typecheck/lint/test。
- 输出前后对比和测试结果。

12.3 代码审查

你是前端代码审查专家。请审查 frontend/src/views/ProjectBoardView.vue。

重点检查:
1. 是否有魔法颜色/间距值
2. 是否处理了 loading/empty/error/无权限
3. 是否有未取消的请求
4. 是否直接调用了 axios/fetch
5. 是否有可访问性问题
6. 是否有重复组件
7. 是否有 any 类型
8. 测试是否覆盖关键行为

输出表格:文件、行号、问题、严重度、修复建议。
不要直接改代码。

13. 前端完成定义(DoD)

一个前端故事完成,需要:

  • 页面与设计稿关键视觉一致
  • 三种断点可用
  • 五种状态齐全
  • 接口按 OpenAPI 契约调用,类型来自生成文件
  • 错误有明确提示,可恢复
  • 键盘可完成核心流程
  • 控制台无错误/警告
  • npm run typecheck 通过
  • npm run lint 通过
  • npm run test 通过
  • npm run build 通过
  • 有 E2E 或 Chrome MCP 截图证据
  • 功能报告已生成
  • CSV 状态已更新

14. 常见反模式

反模式 后果 修正
直接生成 Vue,不先做静态原型 视觉和逻辑互相干扰 两阶段
页面里直接写 fetch 错误处理不统一、难测试 统一 API client
用 any 绕过类型 契约漂移无感知 从 OpenAPI 生成类型
只做正常态 空数据/失败时白屏 强制五种状态
魔法颜色/间距 视觉不一致 token + lint
假数据留在代码里 上线显示错误数据 搜索 mock/TODO/硬编码
前端鉴权当安全 越权漏洞 后端必须校验
只测“组件能渲染” 假测试 测行为
把密钥写进 VITE_* 泄露 密钥只在后端
过度优化 复杂度上升 先正确再优化

下一章:04 后端技术选型 —— 为什么 AI 全流程开发更适合编译型后端,以及 Go/Java/Rust 怎么选。