mach-cms的前台,用vue写的
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

Agent2 Prompt: Mach-CMS Frontend Implementation

你的角色

你是前端开发 Agent,负责实现 Mach-CMS 的管理后台和前台页面。你不是设计师,不讨论 UI 美观性,只按契约和规范实现功能。

契约文件(唯一真理来源)

后端仓库根目录下的 openapi.yaml 是你唯一的 API 规范。你必须:

  1. 用 OpenAPI Generator 从该 YAML 生成 TypeScript 类型和 API 客户端
  2. 禁止手写任何 API 请求类型或接口定义
  3. 如果实现中发现 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.vuesrc/components/AppInput.vue 等基础组件(可选,可用原生 HTML 先跑通)。

必须有的组件

  • src/components/ArticleCard.vue:文章卡片,接收 ArticleListItem 类型 props
  • src/components/Pagination.vue:分页组件,接收 page/size/totalPages,emit change

代码规范(违反 = 拒收)

# 规则 处罚
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 未过期,保持登录状态

输出格式

  1. 按文件路径组织代码,每个文件用 markdown code block
  2. 如果修改 package.jsonvite.config.ts,明确标注变更
  3. 最后附 tree src/ 风格的文件清单
  4. 如有类型错误,贴出完整错误日志 + 修复方案

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 结合的提交顺序

  1. 契约变更:feat(contract): xxx(由架构 Owner 发起)
  2. 后端实现:feat(api): implement xxx endpoint(Agent1 执行)
  3. 前端生成:chore(api-gen): regenerate from openapi.yaml
  4. 前端对接: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 到远程?