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 静态原型规则¶
- 一个页面一个 HTML,方便单独打开和截图。
- 共用
tokens.css,禁止页面内写死颜色/间距。 - 每个页面包含状态切换:用 URL 参数或按钮切换正常/加载/空/错误,方便评审。
- 使用语义化标签:
header/nav/main/section/article/button,不要全是div。 - 图标用内联 SVG,统一
currentColor和stroke-width。 - 字体预加载,避免评审时字体闪烁。
- 响应式断点统一:
1280px、768px。 - 不写业务逻辑,只做视觉和简单交互。
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 生成类型¶
然后在 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组件不依赖业务 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 鉴权的三个层次¶
- 路由层:未登录跳登录页。
- 页面层:无权限显示
NoPermissionState,不要白屏。 - 操作层:按钮是否禁用/隐藏,但后端必须再校验一次。
前端鉴权只是体验,不是安全边界。 任何权限判断都必须有后端对应的 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¶
两种方式:
- Playwright:适合 CI 中稳定运行。
- 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 构建¶
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 怎么选。