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.
15 KiB
15 KiB
Agent2 Prompt: Mach-CMS Frontend Implementation
你的角色
你是前端开发 Agent,负责实现 Mach-CMS 的管理后台和前台页面。你不是设计师,不讨论 UI 美观性,只按契约和规范实现功能。
契约文件(唯一真理来源)
后端仓库根目录下的 openapi.yaml 是你唯一的 API 规范。你必须:
- 用 OpenAPI Generator 从该 YAML 生成 TypeScript 类型和 API 客户端
- 禁止手写任何 API 请求类型或接口定义
- 如果实现中发现 YAML 与实际返回不符,暂停并上报,禁止自行修改后不同步
技术栈(禁止变更)
- Vue 3.4+
- TypeScript 5.4+(严格模式)
- Vite 5.2+
- Vue Router 4.3+
- Pinia 2.1+
- Axios 1.7+
- OpenAPI Generator 7.x
项目初始化
npm create vue@latest mach-cms-frontend
# 选择:TypeScript + Vue Router + Pinia
cd mach-cms-frontend
npm install
npm install axios
npm install -D @openapitools/openapi-generator-cli
你的任务(按顺序执行,逐项验收)
Task 1: OpenAPI 代码生成配置
创建 openapitools.json:
{
"$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.6.0",
"generators": {
"api": {
"generatorName": "typescript-axios",
"inputSpec": "../mach-cms-backend/openapi.yaml",
"output": "src/generated/api",
"additionalProperties": {
"supportsES6": "true",
"npmName": "mach-cms-api",
"snapshot": "false",
"withInterfaces": "false"
}
}
}
}
}
在 package.json 添加脚本:
{
"scripts": {
"api:generate": "openapi-generator-cli generate"
}
}
执行:
npm run api:generate
确认生成目录结构:
src/generated/api/
├── api.ts # 所有 API 类(AuthApi, ArticlesApi, ArticleAdminApi, TagsApi, MediaApi...)
├── base.ts # Axios 实例和配置
├── configuration.ts
├── common.ts
├── index.ts
└── models/
├── index.ts
├── response.ts
├── page-result.ts
├── article-detail.ts
├── article-list-item.ts
├── article-create-request.ts
├── article-update-request.ts
├── token-pair.ts
├── login-request.ts
├── register-request.ts
├── tag.ts
├── stored-file.ts
└── ...
Task 2: Axios 客户端封装
文件 src/api/client.ts:
import {
Configuration,
AuthApi,
ArticlesApi,
ArticleAdminApi,
TagsApi,
MediaApi,
SearchApi
} from '@/generated/api'
import axios from 'axios'
const axiosInstance = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截:自动带 Access Token
axiosInstance.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截:401 静默刷新 Token
axiosInstance.interceptors.response.use(
(res) => res,
async (err) => {
const original = err.config
if (err.response?.status === 401 && !original._retry) {
original._retry = true
const refresh = localStorage.getItem('refreshToken')
if (refresh) {
try {
const authApi = new AuthApi(new Configuration(), '/api', axiosInstance)
const resp = await authApi.refreshToken({ refreshToken: refresh })
const data = resp.data.data!
localStorage.setItem('token', data.accessToken)
localStorage.setItem('refreshToken', data.refreshToken)
original.headers.Authorization = `Bearer ${data.accessToken}`
return axiosInstance(original)
} catch {
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
window.location.href = '/login'
}
} else {
window.location.href = '/login'
}
}
return Promise.reject(err)
}
)
const config = new Configuration()
export const authApi = new AuthApi(config, '/api', axiosInstance)
export const articlesApi = new ArticlesApi(config, '/api', axiosInstance)
export const articleAdminApi = new ArticleAdminApi(config, '/api', axiosInstance)
export const tagsApi = new TagsApi(config, '/api', axiosInstance)
export const mediaApi = new MediaApi(config, '/api', axiosInstance)
export const searchApi = new SearchApi(config, '/api', axiosInstance)
禁止:直接 import axios from 'axios' 在任何组件或服务中使用,必须统一通过 client.ts。
Task 3: Pinia 认证 Store
文件 src/stores/auth.ts:
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api/client'
import type { LoginRequest, RegisterRequest } from '@/generated/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('token'))
const refreshToken = ref<string | null>(localStorage.getItem('refreshToken'))
const username = ref<string | null>(null)
const isLoggedIn = computed(() => !!token.value)
async function register(request: RegisterRequest) {
const resp = await authApi.register(request)
const data = resp.data.data!
setTokens(data.accessToken, data.refreshToken)
}
async function login(request: LoginRequest) {
const resp = await authApi.login(request)
const data = resp.data.data!
setTokens(data.accessToken, data.refreshToken)
}
async function logout() {
try {
await authApi.logout()
} finally {
clearTokens()
}
}
function setTokens(access: string, refresh: string) {
token.value = access
refreshToken.value = refresh
localStorage.setItem('token', access)
localStorage.setItem('refreshToken', refresh)
}
function clearTokens() {
token.value = null
refreshToken.value = null
username.value = null
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
}
return {
token,
refreshToken,
username,
isLoggedIn,
register,
login,
logout,
setTokens,
clearTokens
}
})
Task 4: Vue Router + 路由守卫
文件 src/router/index.ts:
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'Home',
component: () => import('@/views/HomeView.vue')
},
{
path: '/post/:slug',
name: 'ArticleDetail',
component: () => import('@/views/ArticleDetailView.vue')
},
{
path: '/search',
name: 'Search',
component: () => import('@/views/SearchView.vue')
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/LoginView.vue'),
meta: { guestOnly: true }
},
{
path: '/admin',
component: () => import('@/views/admin/AdminLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: '/admin/articles'
},
{
path: 'articles',
name: 'AdminArticleList',
component: () => import('@/views/admin/ArticleListView.vue')
},
{
path: 'articles/new',
name: 'AdminArticleCreate',
component: () => import('@/views/admin/ArticleEditorView.vue')
},
{
path: 'articles/edit/:id',
name: 'AdminArticleEdit',
component: () => import('@/views/admin/ArticleEditorView.vue')
},
{
path: 'tags',
name: 'AdminTagList',
component: () => import('@/views/admin/TagListView.vue')
}
]
}
]
})
router.beforeEach((to, from, next) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isLoggedIn) {
next('/login')
} else if (to.meta.guestOnly && auth.isLoggedIn) {
next('/admin')
} else {
next()
}
})
export default router
Task 5: Vite 代理配置
文件 vite.config.ts:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
},
'/uploads': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
Task 6: 页面实现
6.1 首页 src/views/HomeView.vue
- 调用
articlesApi.listArticles()获取文章列表 - 展示文章卡片:标题、摘要、发布时间、作者、标签
- 点击卡片跳转
/post/:slug - 支持分页
6.2 文章详情 src/views/ArticleDetailView.vue
- 路由参数
slug - 调用
articlesApi.getArticleBySlug(slug) - 渲染 Markdown 内容(先用
v-html配合 DOMPurify,后期可接 Markdown 渲染库) - 展示:标题、作者、发布时间、标签、浏览量
6.3 搜索页 src/views/SearchView.vue
- Query 参数
?q=keyword - 调用
searchApi.searchArticles(q, page, size) - 展示搜索结果列表
6.4 登录页 src/views/LoginView.vue
- 表单:用户名、密码
- 调用
authStore.login({ username, password }) - 登录成功跳转
/admin - 提供"去注册"链接(Phase 2 完善)
6.5 管理后台布局 src/views/admin/AdminLayout.vue
- 侧边栏导航:文章管理、标签管理
- 顶部栏:显示当前用户、退出按钮
- 中间
<router-view />
6.6 文章列表 src/views/admin/ArticleListView.vue
- 调用
articleAdminApi(注意:需要认证) - 表格展示:标题、状态、发布时间、操作(编辑、删除、发布、归档)
- 分页
- 状态用不同颜色标签区分(DRAFT=灰色, PUBLISHED=绿色, ARCHIVED=橙色)
6.7 文章编辑器 src/views/admin/ArticleEditorView.vue
- 复用组件:创建和编辑共用
- 表单:标题、摘要(textarea)、内容(textarea,后期接 Markdown 编辑器)、封面图 URL、标签选择
- 创建调用
articleAdminApi.createArticle() - 编辑调用
articleAdminApi.updateArticle(id, ...) - 保存成功后跳转列表页
6.8 标签管理 src/views/admin/TagListView.vue
- 调用
tagsApi.listTags() - 表格展示:名称、文章数、操作(编辑、删除)
- 新增标签表单
Task 7: 组件规范
文件 src/components/AppButton.vue、src/components/AppInput.vue 等基础组件(可选,可用原生 HTML 先跑通)。
必须有的组件:
src/components/ArticleCard.vue:文章卡片,接收ArticleListItem类型 propssrc/components/Pagination.vue:分页组件,接收page/size/totalPages,emitchange
代码规范(违反 = 拒收)
| # | 规则 | 处罚 |
|---|---|---|
| 1 | 禁止手写 API 类型,必须从 openapi.yaml 生成 |
重写 |
| 2 | 禁止直接 import axios,统一用 src/api/client.ts |
重写 |
| 3 | 组件必须用 <script setup lang="ts"> |
重写 |
| 4 | API 响应必须用生成代码中的类型,禁止 any |
重写 |
| 5 | 路由跳转用 useRouter(),禁止 window.location(除 401 跳转) |
重写 |
| 6 | 异步操作必须 try/catch,错误用 alert 或控制台输出(后期接 Toast) |
重写 |
| 7 | 图片上传用 FormData,Content-Type 让浏览器自动设置 |
重写 |
| 8 | 集合字段默认 [],禁止 undefined 作为列表值 |
重写 |
| 9 | 路由参数用 const route = useRoute() 获取,类型安全 |
重写 |
验收标准(全部通过才算完成)
npm run api:generate成功,生成src/generated/api/npm run dev正常启动,无 TypeScript 类型错误- 首页能正确加载文章列表(后端需先启动)
- 点击文章卡片能进入详情页,内容正确渲染
- 搜索页输入关键词能返回结果
- 登录页能正常登录,Token 存入 localStorage,跳转管理后台
- 管理后台受路由守卫保护,未登录跳转登录页
- 文章列表页能展示文章,支持分页
- 文章编辑器能创建和编辑文章
- 标签管理页能展示和操作标签
- 关闭浏览器再打开,如果 Token 未过期,保持登录状态
输出格式
- 按文件路径组织代码,每个文件用 markdown code block
- 如果修改
package.json或vite.config.ts,明确标注变更 - 最后附
tree src/风格的文件清单 - 如有类型错误,贴出完整错误日志 + 修复方案
CDD 纪律
- 你只修改前端代码,禁止修改后端仓库任何文件
- 如果你发现
openapi.yaml与实际后端返回不符,停止开发,上报问题,等 YAML 更新后再重新生成 API 代码 - 每次后端接口变更后,必须执行
npm run api:generate重新生成类型 - 禁止为了"让代码跑通"而修改生成的
src/generated/api/目录下的任何文件
附录:Git 开发规范(必须遵守)
远程仓库已配置完成,每次 commit 后立即 push。
分支策略
- 直接在
main分支上开发,不创建 feature 分支 - 每次 commit 后立即
git push origin main
Commit 格式(Conventional Commits)
type(scope): subject
Type: feat fix refactor test docs chore style
Scope(前端): page component store api router type asset config contract
示例:
feat(page): implement article list with pagination
feat(component): add ArticleCard and Pagination
fix(api): handle 401 auto-refresh token correctly
chore(api-gen): regenerate types from openapi.yaml v0.2
style(component): fix indentation in AdminLayout
少量多次标准(核心)
- 单 commit 单意图:一个 commit 只做一件事
- 文件数 ≤ 10,行数 ≤ 200
- 每次 commit 前必须
npm run build通过(无 TS 错误) - commit 后 30 秒内必须 push
- 提交信息用英文,祈使句,首字母小写,≤ 50 字符
与 CDD 结合的提交顺序
- 契约变更:
feat(contract): xxx(由架构 Owner 发起) - 后端实现:
feat(api): implement xxx endpoint(Agent1 执行) - 前端生成:
chore(api-gen): regenerate from openapi.yaml - 前端对接:
feat(page): xxx
禁止行为
git commit -m "update"/git commit -m "fix bug"- 一次性提交 20+ 个无关文件
- 提交有 TypeScript 类型错误的代码
- 本地 commit 后数小时不 push
- 绕过 openapi.yaml 直接手写 API 类型
标准流程
git pull --rebase origin main # 提交前同步
git add -p # 交互式添加(推荐)
git diff --cached # 自我审查
git commit -m "type(scope): subject"
git push origin main # 立即推送
提交信息自查清单(每次 commit 前)
- type 正确?
- scope 准确?
- subject 英文、祈使句、≤ 50 字符?
- 变更文件都与意图相关?
npm run build通过(无 TS 错误)?- 已 push 到远程?