Browse Source

feat: implement initial frontend with generated API client

main
Mohan 1 month ago
commit
3e78112545
  1. 5
      .gitignore
  2. 322
      checklist-v0.2.md
  3. 3
      env.d.ts
  4. 13
      index.html
  5. 820
      openapi.yaml
  6. 20
      openapitools.json
  7. 3566
      package-lock.json
  8. 33
      package.json
  9. 507
      prompt-agent2-frontend-v0.3.md
  10. 168
      scripts/acceptance/acc_234.cjs
  11. 259
      scripts/acceptance/acc_25.cjs
  12. 85
      src/App.vue
  13. 71
      src/api/client.ts
  14. 100
      src/components/ArticleCard.vue
  15. 52
      src/components/Pagination.vue
  16. 9
      src/main.ts
  17. 74
      src/router/index.ts
  18. 63
      src/stores/auth.ts
  19. 116
      src/views/ArticleDetailView.vue
  20. 65
      src/views/HomeView.vue
  21. 115
      src/views/LoginView.vue
  22. 119
      src/views/SearchView.vue
  23. 110
      src/views/admin/AdminLayout.vue
  24. 293
      src/views/admin/ArticleEditorView.vue
  25. 268
      src/views/admin/ArticleListView.vue
  26. 250
      src/views/admin/TagListView.vue
  27. 24
      tsconfig.json
  28. 25
      vite.config.ts

5
.gitignore

@ -0,0 +1,5 @@
node_modules
dist
*.log
.DS_Store
src/generated/api

322
checklist-v0.2.md

@ -0,0 +1,322 @@
# Mach-CMS 联调测试 Checklist
> 版本: 0.2.0
> 日期: 2026-08-09
> 用途: Agent1(后端)+ Agent2(前端)验收 + 联调
---
## 0. 环境准备(前置条件)
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 0.1 | PostgreSQL 16 已启动 | `psql -U postgres -c "\l"` 能列出数据库 | ☐ |
| 0.2 | 数据库 `cms` 已创建 | `createdb -U postgres cms` 执行成功或已存在 | ☐ |
| 0.3 | Node.js 20+ 已安装 | `node -v` 输出 v20.x | ☐ |
| 0.4 | JDK 21 已安装 | `java -version` 输出 21 | ☐ |
| 0.5 | `openapi.yaml` 已放置于后端仓库根目录 | 文件存在且版本为 0.1.0+ | ☐ |
| 0.6 | `openapi.yaml` 已放置于前端仓库可引用路径 | `openapitools.json``inputSpec` 路径正确 | ☐ |
---
## 1. Agent1 后端独立验收
### 1.1 构建与启动
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 1.1.1 | `./gradlew build` 无编译错误 | 控制台 `BUILD SUCCESSFUL` | ☐ |
| 1.1.2 | `./gradlew test` 通过 | 所有测试通过(至少包含 Article 状态机测试) | ☐ |
| 1.1.3 | `./gradlew bootRun` 正常启动 | 控制台出现 `Started CmsApplication` 且无 ERROR | ☐ |
| 1.1.4 | Actuator 健康检查 | `GET http://localhost:8080/actuator/health` 返回 `{"status":"UP"}` | ☐ |
| 1.1.5 | Swagger UI 可访问 | `http://localhost:8080/swagger-ui.html` 正常渲染 | ☐ |
### 1.2 数据库迁移
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 1.2.1 | Flyway V1 迁移成功 | `flyway_schema_history` 表有 V1 记录 | ☐ |
| 1.2.2 | Flyway V2 迁移成功 | `flyway_schema_history` 表有 V2 记录;`articles/tags/article_tags` 表结构正确 | ☐ |
| 1.2.3 | Flyway V3 迁移成功 | `flyway_schema_history` 表有 V3 记录;`articles` 表有触发器 `article_search_vector_trigger` | ☐ |
| 1.2.4 | jOOQ 代码生成成功 | `build/generated-sources/jooq/top/jmto/mach_cms/jooq/` 存在 Kotlin 文件 | ☐ |
### 1.3 认证接口(独立测试)
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 1.3.1 | 用户注册 | `POST /api/auth/register` 返回 `Response<TokenPair>`,code=0 | ☐ |
| 1.3.2 | 用户登录 | `POST /api/auth/login` 返回 `Response<TokenPair>`,包含 accessToken + refreshToken | ☐ |
| 1.3.3 | Token 刷新 | `POST /api/auth/refresh` 传入 refreshToken,返回新的 TokenPair | ☐ |
| 1.3.4 | 用户登出 | `POST /api/auth/logout` 返回 code=0;再次用旧 refreshToken 刷新应失败 | ☐ |
| 1.3.5 | 密码加密 | 直接查 PG `users` 表,`password` 字段为 BCrypt 哈希(以 `$2a$` 开头) | ☐ |
### 1.4 文章接口(独立测试)
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 1.4.1 | 创建文章(Admin) | `POST /api/admin/articles` 带 Bearer Token,返回 `Response<ArticleDetail>`,slug 自动生成 | ☐ |
| 1.4.2 | 文章 slug 唯一性 | 同标题创建第二篇文章,slug 自动加后缀(如 `hello-world-1`) | ☐ |
| 1.4.3 | 查询文章详情(前台) | `GET /api/articles/{slug}` 无认证可访问,返回结构与 YAML 一致 | ☐ |
| 1.4.4 | 文章列表(前台) | `GET /api/articles?page=1&size=10` 返回 `Response<PageResult<ArticleListItem>>` | ☐ |
| 1.4.5 | 状态过滤 | `GET /api/articles?status=PUBLISHED` 只返回已发布文章 | ☐ |
| 1.4.6 | 发布文章 | `PATCH /api/admin/articles/{id}/publish` 后,status 变为 PUBLISHED,publishedAt 有值 | ☐ |
| 1.4.7 | 归档文章 | `PATCH /api/admin/articles/{id}/archive` 后,status 变为 ARCHIVED | ☐ |
| 1.4.8 | 状态机保护 | 对已发布文章再次调用 `publish`,返回业务错误(非 500) | ☐ |
| 1.4.9 | 更新文章 | `PUT /api/admin/articles/{id}` 更新后内容正确,updatedAt 变化 | ☐ |
| 1.4.10 | 删除文章 | `DELETE /api/admin/articles/{id}` 后,再次查询返回 404 或 null data | ☐ |
| 1.4.11 | 未认证访问 Admin | `POST /api/admin/articles` 不带 Token,返回 401 | ☐ |
| 1.4.12 | 无权限访问 Admin | 用 VISITOR 角色 Token 访问 Admin 接口,返回 403 | ☐ |
### 1.5 搜索接口(独立测试)
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 1.5.1 | 全文搜索 | `GET /api/articles/search?q=spring` 返回相关文章列表 | ☐ |
| 1.5.2 | 搜索结果排序 | 标题含关键词的文章排在内容含关键词的前面(ts_rank 权重 A>B>C) | ☐ |
| 1.5.3 | 空关键词处理 | `q=` 或缺失,返回业务错误(code ≠ 0)或空列表 | ☐ |
| 1.5.4 | 搜索触发器生效 | 创建文章后立即搜索标题关键词,能搜到结果 | ☐ |
### 1.6 标签接口(独立测试)
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 1.6.1 | 创建标签(Admin) | `POST /api/admin/tags` 返回 `Response<Tag>` | ☐ |
| 1.6.2 | 标签列表(前台) | `GET /api/tags` 返回所有标签,带 `articleCount` | ☐ |
| 1.6.3 | 文章关联标签 | 创建文章时传入 `tagIds`,详情页返回正确 `tagNames` | ☐ |
| 1.6.4 | 删除标签(Admin) | `DELETE /api/admin/tags/{id}` 成功,关联文章不再显示该标签 | ☐ |
### 1.7 媒体接口(独立测试)
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 1.7.1 | 文件上传 | `POST /api/media/upload` 上传图片,返回 `Response<StoredFile>`,url 可访问 | ☐ |
| 1.7.2 | 文件本地存储 | `uploads/images/` 目录下存在上传的文件 | ☐ |
| 1.7.3 | 目录遍历防护 | 上传时篡改 `directory` 参数为 `../etc`,被拒绝或存储到安全路径 | ☐ |
### 1.8 响应格式一致性
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 1.8.1 | 所有业务接口返回 `Response<T>` | 抽查 5 个接口,结构均为 `{code, message, data}` | ☐ |
| 1.8.2 | 成功时 code=0 | 所有成功响应 code 字段为 0 | ☐ |
| 1.8.3 | 错误时 code≠0 | 业务错误(如 slug 重复)code 为对应 ErrorCode 值 | ☐ |
| 1.8.4 | 分页接口返回 `PageResult` | 列表接口包含 `list/total/page/size/totalPages` | ☐ |
| 1.8.5 | Swagger UI 与 YAML 一致 | Swagger 中显示的接口路径、参数、响应模型与 `openapi.yaml` 完全一致 | ☐ |
---
## 2. Agent2 前端独立验收
### 2.1 构建与启动
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 2.1.1 | `npm install` 成功 | 无依赖安装错误 | ✓ |
| 2.1.2 | `npm run api:generate` 成功 | `src/generated/api/` 目录存在且包含 api.ts + models/ | ✓ |
| 2.1.3 | `npm run dev` 正常启动 | 控制台无 ERROR,访问 `http://localhost:5173` 正常 | ✓ |
| 2.1.4 | TypeScript 严格模式无错误 | `npm run build` 通过(或 `vue-tsc --noEmit` 无类型错误) | ✓ |
### 2.2 代码规范检查
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 2.2.1 | 无手写 API 类型 | `src/generated/api/` 外无自定义的 `ArticleDetail`/`TokenPair` 等接口 | ✓ |
| 2.2.2 | 统一 Axios 客户端 | 全局搜索 `import axios from 'axios'`,除 `client.ts` 外无其他直接引用 | ✓ |
| 2.2.3 | `<script setup lang="ts">` | 所有 `.vue` 文件使用组合式 API + TS | ✓ |
| 2.2.4 | 无 `any` 类型滥用 | 全局搜索 `: any`,业务代码中不超过 3 处(配置/工具类除外) | ✓ |
### 2.3 前台页面(无需登录)
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 2.3.1 | 首页加载文章列表 | 打开 `/`,能看到文章卡片列表,包含标题、摘要、时间、标签 | ✓ |
| 2.3.2 | 文章卡片点击跳转 | 点击卡片进入 `/post/:slug`,URL 正确 | ✓ |
| 2.3.3 | 文章详情页渲染 | 详情页显示标题、作者、发布时间、标签、内容 | ✓ |
| 2.3.4 | 标签云/列表 | 首页或独立页面展示标签列表,显示文章计数 | ✓ |
| 2.3.5 | 搜索功能 | 输入关键词后跳转到搜索页,展示结果列表 | ✓ |
| 2.3.6 | 分页组件 | 文章列表底部有分页,点击切换页码后内容更新 | ✓ |
### 2.4 认证流程
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 2.4.1 | 登录页面 | `/login` 能正常显示表单(用户名、密码) | ✓ |
| 2.4.2 | 登录成功 | 输入正确凭据后,localStorage 存入 `token``refreshToken`,跳转管理后台 | ✓ |
| 2.4.3 | 登录失败提示 | 输入错误密码,页面显示错误信息(alert 或文字提示) | ✓ |
| 2.4.4 | 路由守卫 | 未登录直接访问 `/admin`,自动跳转 `/login` | ✓ |
| 2.4.5 | 已登录防回退 | 已登录用户访问 `/login`,自动跳转 `/admin` | ✓ |
| 2.4.6 | 退出登录 | 点击退出后,localStorage 清除 Token,跳转首页 | ✓ |
| 2.4.7 | 刷新保持登录 | 登录后刷新浏览器(F5),仍保持登录状态(Token 未过期时) | ✓ |
### 2.5 管理后台(需登录)
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 2.5.1 | 后台布局 | `/admin` 显示侧边栏(文章管理、标签管理)+ 顶部栏 | ✓ |
| 2.5.2 | 文章列表 | `/admin/articles` 显示表格:标题、状态、发布时间、操作按钮 | ✓ |
| 2.5.3 | 状态标签颜色 | DRAFT=灰色/默认, PUBLISHED=绿色, ARCHIVED=橙色/红色 | ✓ |
| 2.5.4 | 创建文章 | 点击"新建"进入编辑器,填写后保存,列表页出现新文章 | ✓ |
| 2.5.5 | 编辑文章 | 点击"编辑"进入编辑器,表单预填充现有数据,保存后更新 | ✓ |
| 2.5.6 | 发布文章 | 列表页点击"发布",状态变为 PUBLISHED,前台可看到 | ✓ |
| 2.5.7 | 归档文章 | 列表页点击"归档",状态变为 ARCHIVED,前台不可见 | ✓ |
| 2.5.8 | 删除文章 | 点击"删除"后文章从列表消失,前台无法访问 | ✓ |
| 2.5.9 | 标签管理 | `/admin/tags` 显示标签列表,可新增、编辑、删除 | ✓ |
| 2.5.10 | 表单校验 | 创建文章时标题/内容为空,提交前前端拦截(或提交后显示后端错误) | ✓ |
### 2.6 文件上传
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 2.6.1 | 封面图上传 | 文章编辑器中上传图片,返回 URL 填入表单 | ✓ |
| 2.6.2 | 上传进度/反馈 | 上传成功后有明确反馈(URL 显示或提示) | ✓ |
---
## 3. 前后端联调验收(端到端)
### 3.1 完整业务流程
按以下顺序执行,**全部通过才算联调成功**:
| # | 场景 | 操作步骤 | 预期结果 | 结果 |
|---|------|---------|---------|------|
| 3.1.1 | 新用户注册 → 登录 → 创建文章 | 1. 前端注册页面填写用户名密码<br>2. 登录<br>3. 进入后台新建文章<br>4. 填写标题/内容/标签<br>5. 保存 | 文章出现在后台列表,状态为 DRAFT | ☐ |
| 3.1.2 | 发布文章 → 前台可见 | 1. 后台点击"发布"<br>2. 前台首页刷新 | 文章出现在首页卡片中 | ☐ |
| 3.1.3 | 前台阅读 → 搜索命中 | 1. 点击文章卡片进入详情<br>2. 复制文章标题关键词<br>3. 前台搜索框输入关键词 | 搜索结果包含该文章,且排名靠前 | ☐ |
| 3.1.4 | 多标签关联 | 1. 后台创建 2 个标签<br>2. 创建文章时选择这 2 个标签<br>3. 保存并发布 | 前台详情页显示 2 个标签;标签列表页计数正确 | ☐ |
| 3.1.5 | Token 过期自动刷新 | 1. 登录后等待 15 分钟(或手动改后端 Token 有效期为 5 秒测试)<br>2. 在后台执行任意操作 | 操作成功,无感知刷新;网络面板可见 `/auth/refresh` 请求 | ☐ |
| 3.1.6 | 并发编辑冲突(可选) | 1. 用户 A 登录编辑文章<br>2. 用户 B 登录编辑同一文章<br>3. 先后保存 | 后保存者覆盖前者(或后端返回版本冲突错误) | ☐ |
| 3.1.7 | 权限隔离 | 1. 注册一个普通用户(默认 VISITOR)<br>2. 用该用户 Token 尝试访问后台 | 前端路由守卫拦截或后端返回 403 | ☐ |
### 3.2 数据一致性
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 3.2.1 | 前端显示与后端数据一致 | 抽查 3 篇文章,前端详情页内容与后端 `GET /api/articles/{slug}` 返回一致 | ☐ |
| 3.2.2 | 分页数据一致 | 前台分页总数与后端 `total` 字段一致 | ☐ |
| 3.2.3 | 标签计数准确 | 标签列表页显示的文章数与后端 `articleCount` 一致 | ☐ |
| 3.2.4 | 搜索关键词高亮(可选) | 搜索结果中关键词有视觉高亮(或至少结果正确) | ☐ |
---
## 4. CDD 契约一致性检查(核心)
这是最关键的检查项,**任何一项不通过,整个联调失败**。
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 4.1 | YAML 与后端 Swagger 一致 | 对比 `openapi.yaml``http://localhost:8080/v3/api-docs`,路径/参数/模型无差异 | ☐ |
| 4.2 | YAML 与前端生成类型一致 | 前端 `src/generated/api/models/` 中的类型与 YAML `components/schemas` 一一对应 | ☐ |
| 4.3 | 后端响应与 YAML 一致 | 用 Swagger UI 或 curl 调用接口,响应 JSON 结构与 YAML 中定义的 `Response<T>` 一致 | ☐ |
| 4.4 | 前端请求与 YAML 一致 | 浏览器 DevTools Network 面板中,前端发出的请求路径/参数与 YAML 一致 | ☐ |
| 4.5 | 无契约外接口 | 后端不存在 YAML 中未定义的接口;前端不调用 YAML 中未定义的接口 | ☐ |
| 4.6 | 枚举值一致 | `ArticleStatus`/`UserRole` 前后端枚举字符串完全一致(大小写敏感) | ☐ |
---
## 5. 性能与安全基线
| # | 检查项 | 通过标准 | 结果 |
|---|--------|---------|------|
| 5.1 | 首页加载时间 | 文章列表页首屏渲染 < 2s本地环境10 篇文章 | |
| 5.2 | API 响应时间 | 单次 API 调用 < 200ms本地环境排除大数据量查询 | |
| 5.3 | 搜索响应时间 | `GET /api/articles/search` 千级数据 < 100ms | |
| 5.4 | XSS 防护 | 文章 content 中包含 `<script>alert(1)</script>`,前台渲染时不执行脚本 | ☐ |
| 5.5 | SQL 注入防护 | 搜索关键词输入 `' OR 1=1 --`,后端正常处理不报错 | ☐ |
| 5.6 | 目录遍历防护 | 上传时 `directory=../../etc` 被安全处理 | ☐ |
| 5.7 | JWT 安全 | Token 中不包含敏感信息(密码等);Secret 不是硬编码的弱密钥 | ☐ |
---
## 6. 问题记录模板
联调中发现的问题按以下格式记录:
### Issue #1 — 搜索接口复现为后端旧进程所致(已解决)
- **发现时间**: 2026-08-10 00:00
- **发现人**: Agent2(前端)
- **问题描述**: 初测时 `GET /api/articles/search?q=Spring` 返回 `{code:3001,"文章不存在: search"}`。后端重启(`./gradlew bootRun` 全新进程)后同一请求返回 code=0 正确搜索结果,确认该现象由旧后端进程(未包含 SearchController 的旧构建)造成,非契约/前端问题。
- **复现步骤**: 旧进程下 `curl "http://localhost:8080/api/articles/search?q=spring"`
- **预期结果**: 返回搜索结果
- **实际结果**: 重启后返回 `{code:0, data:{list:[...]}}`,2.3.5/1.5.1 通过
- **根因分析**: 后端旧进程加载了过期构建产物,未包含搜索路由
- **责任方**: Agent1(后端,运维)
- **修复方案**: 联调前确保后端为最新 `bootRun` 进程
- **验证结果**: ☐ 已解决(2026-08-10 复验通过)
### Issue #2 — 后端 `POST /auth/logout` 未吊销 Refresh Token
- **发现时间**: 2026-08-10 00:05
- **发现人**: Agent2(前端)
- **影响**: 前端 2.4.6 通过(本地清理 localStorage 正常);仅后端吊销语义缺失(1.3.4)
- **问题描述**: 前端 `auth.logout()``POST /api/auth/logout` 后,旧 refreshToken 仍可通过 `POST /api/auth/refresh` 换取新 TokenPair,令牌吊销语义失效(违反 checklist 1.3.4)。
- **复现步骤**: 1) 登录获取 refreshToken;2) 调 `/auth/logout`(code=0);3) 用旧 refreshToken 调 `/auth/refresh` → 仍返回 code=0 新 token(在最新 bootRun 进程上复验一致)
- **预期结果**: logout 后旧 refreshToken 刷新应失败(401/业务错误)
- **实际结果**: 刷新成功并返回新的 TokenPair
- **根因分析**: 后端登出逻辑未将 refreshToken 加入黑名单/吊销列表(JWT 无状态未记录失效)
- **责任方**: Agent1(后端)
- **修复方案**: 服务端维护 refreshToken 吊销集合(DB/redis),refresh 时校验
- **验证结果**: ☐ 待修复
### Issue #3 — 契约路径加 `/api` 前缀后前端 baseURL 适配(已处理)
- **发现时间**: 2026-08-10 00:20
- **发现人**: Agent2(前端,承接 Agent1 提示)
- **问题描述**: Agent1 将契约路径改为带 `/api` 前缀(`servers.url=http://localhost:8080`)。重新 `npm run api:generate` 后,生成的 localVarPath 变为 `/api/auth/...`、方法名变为 `apiXxx` 前缀、新增 `PublicApi`/`UserApi`。若前端 axios `baseURL` 仍为 `/api`,会拼成 `/api/api/...` → 404。
- **Agent1 提示**: 需把 client.ts 中 `new XxxApi(config, '/api', ...)``/api` 改为 `''`。经核实**不正确/不完整**:生成的 `common.ts` 拼接逻辑为 `axios.defaults.baseURL ? '' : (configuration?.basePath ?? basePath)`,axios 的 `baseURL` 优先于 XxxApi 的 basePath 参数;且 axios 会自行 combineURLs。若仅改 XxxApi 参数而保留 baseURL=`/api`,仍会双前缀。
- **实际修复**: 将 axios 实例 `baseURL``/api` 改为 `/`(各 XxxApi 的 `/api` basePath 参数无害保留),并批量重命名方法调用(`articlesGet`→`apiArticlesGet` 等)。实测 `/api/articles` 单前缀、经 vite 代理到 8080 正常,2.3~2.6 全部用例复验 PASS。
- **责任方**: 前端(Agent2)
- **验证结果**: ☑ 已处理(2026-08-10 复验 26/26 通过)
```markdown
### Issue #{编号}
- **发现时间**: YYYY-MM-DD HH:MM
- **发现人**: Agent1/Agent2/架构师
- **问题描述**:
- **复现步骤**:
- **预期结果**:
- **实际结果**:
- **根因分析**:
- **责任方**: Agent1(后端)/ Agent2(前端)/ openapi.yaml(契约)
- **修复方案**:
- **验证结果**: ☐ 待修复 / ☐ 已修复 / ☐ 已验证
```
---
## 验收签字
| 角色 | 姓名 | 日期 | 签字 |
|------|------|------|------|
| 架构 Owner | | | |
| Agent1(后端) | | | |
| Agent2(前端) | | | |
---
## 附录:快速测试命令
```bash
# 后端健康检查
curl http://localhost:8080/actuator/health
# 注册
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
# 登录
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
# 创建文章(替换 $TOKEN)
curl -X POST http://localhost:8080/api/admin/articles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Hello World","content":"This is a test article."}'
# 搜索
curl "http://localhost:8080/api/articles/search?q=hello&page=1&size=10"
# 前台列表(无认证)
curl "http://localhost:8080/api/articles?page=1&size=10"
```

3
env.d.ts

@ -0,0 +1,3 @@
/// <reference types="vite/client" />
interface Null {}

13
index.html

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mach-CMS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

820
openapi.yaml

@ -0,0 +1,820 @@
openapi: 3.1.0
info:
title: Mach-CMS API
description: |
Mach-CMS 后端 API 规范。前后端契约文件,任何接口变更必须同步修改此文件。
- 所有业务接口返回统一包装体 `Response<T>`
- 列表查询返回 `PageResult<T>`
- 认证方式:JWT Bearer Token
- 时间格式:ISO-8601(`2024-01-15T08:30:00Z`)
version: 0.1.0
contact:
name: Mach-CMS Team
servers:
- url: http://localhost:8080/api
description: 本地开发环境
security:
- bearerAuth: []
tags:
- name: Auth
description: 认证与授权
- name: Public
description: 公开接口(无需认证)
- name: Article
description: 文章(前台只读)
- name: ArticleAdmin
description: 文章管理(需 ADMIN/EDITOR)
- name: Tag
description: 标签(前台只读)
- name: TagAdmin
description: 标签管理(需 ADMIN)
- name: Media
description: 媒体文件上传
- name: Search
description: 全文搜索
- name: User
description: 用户管理(需 ADMIN)
paths:
# ==================== Public ====================
/public/health:
get:
tags: [Public]
summary: 健康检查
security: []
responses:
'200':
description: 服务正常
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseString'
example:
code: 0
message: success
data: UP
# ==================== Auth ====================
/auth/register:
post:
tags: [Auth]
summary: 用户注册
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterRequest'
responses:
'200':
description: 注册成功
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseTokenPair'
/auth/login:
post:
tags: [Auth]
summary: 用户登录
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
responses:
'200':
description: 登录成功,返回双 Token
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseTokenPair'
/auth/refresh:
post:
tags: [Auth]
summary: 刷新 Access Token
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RefreshTokenRequest'
responses:
'200':
description: 刷新成功
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseTokenPair'
/auth/logout:
post:
tags: [Auth]
summary: 退出登录(吊销 Refresh Token)
responses:
'200':
description: 退出成功
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseVoid'
# ==================== Article (Public) ====================
/articles:
get:
tags: [Article]
summary: 文章列表(分页)
security: []
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: size
in: query
schema:
type: integer
default: 10
maximum: 100
- name: status
in: query
schema:
$ref: '#/components/schemas/ArticleStatus'
default: PUBLISHED
- name: tag
in: query
description: 标签 slug 过滤
schema:
type: string
responses:
'200':
description: 文章列表
content:
application/json:
schema:
$ref: '#/components/schemas/ResponsePageArticleListItem'
/articles/{slug}:
get:
tags: [Article]
summary: 文章详情(按 slug)
security: []
parameters:
- name: slug
in: path
required: true
schema:
type: string
responses:
'200':
description: 文章详情
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseArticleDetail'
'404':
description: 文章不存在
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseVoid'
/articles/search:
get:
tags: [Search]
summary: 全文搜索文章
security: []
parameters:
- name: q
in: query
required: true
description: 搜索关键词
schema:
type: string
minLength: 1
maxLength: 100
- name: page
in: query
schema:
type: integer
default: 1
- name: size
in: query
schema:
type: integer
default: 10
maximum: 50
responses:
'200':
description: 搜索结果
content:
application/json:
schema:
$ref: '#/components/schemas/ResponsePageArticleListItem'
# ==================== ArticleAdmin ====================
/admin/articles:
post:
tags: [ArticleAdmin]
summary: 创建文章
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ArticleCreateRequest'
responses:
'200':
description: 创建成功
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseArticleDetail'
/admin/articles/{id}:
put:
tags: [ArticleAdmin]
summary: 更新文章
parameters:
- name: id
in: path
required: true
schema:
type: integer
format: int64
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ArticleUpdateRequest'
responses:
'200':
description: 更新成功
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseArticleDetail'
delete:
tags: [ArticleAdmin]
summary: 删除文章
parameters:
- name: id
in: path
required: true
schema:
type: integer
format: int64
responses:
'200':
description: 删除成功
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseVoid'
/admin/articles/{id}/publish:
patch:
tags: [ArticleAdmin]
summary: 发布文章(状态机:DRAFT → PUBLISHED)
parameters:
- name: id
in: path
required: true
schema:
type: integer
format: int64
responses:
'200':
description: 发布成功
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseArticleDetail'
/admin/articles/{id}/archive:
patch:
tags: [ArticleAdmin]
summary: 归档文章
parameters:
- name: id
in: path
required: true
schema:
type: integer
format: int64
responses:
'200':
description: 归档成功
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseArticleDetail'
# ==================== Tag (Public) ====================
/tags:
get:
tags: [Tag]
summary: 标签列表(带文章计数)
security: []
responses:
'200':
description: 标签列表
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseListTagWithCount'
# ==================== TagAdmin ====================
/admin/tags:
post:
tags: [TagAdmin]
summary: 创建标签
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TagCreateRequest'
responses:
'200':
description: 创建成功
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseTag'
/admin/tags/{id}:
put:
tags: [TagAdmin]
summary: 更新标签
parameters:
- name: id
in: path
required: true
schema:
type: integer
format: int64
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TagUpdateRequest'
responses:
'200':
description: 更新成功
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseTag'
delete:
tags: [TagAdmin]
summary: 删除标签
parameters:
- name: id
in: path
required: true
schema:
type: integer
format: int64
responses:
'200':
description: 删除成功
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseVoid'
# ==================== Media ====================
/media/upload:
post:
tags: [Media]
summary: 上传文件
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
description: 文件内容
directory:
type: string
default: images
description: 存储目录
responses:
'200':
description: 上传成功
content:
application/json:
schema:
$ref: '#/components/schemas/ResponseStoredFile'
# ==================== User ====================
/admin/users:
get:
tags: [User]
summary: 用户列表(分页)
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: size
in: query
schema:
type: integer
default: 20
responses:
'200':
description: 用户列表
content:
application/json:
schema:
$ref: '#/components/schemas/ResponsePageUserInfo'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
在请求头中携带 `Authorization: Bearer <access_token>`。
Access Token 有效期 15 分钟,过期后用 Refresh Token 调用 `/auth/refresh` 换取新的 Access Token。
schemas:
# ---------- 通用包装体 ----------
Response:
type: object
properties:
code:
type: integer
description: 0 表示成功,非 0 为业务错误码
message:
type: string
data:
description: 业务数据,类型由具体接口决定
required: [code, message]
PageResult:
type: object
properties:
list:
type: array
items: {}
total:
type: integer
format: int64
page:
type: integer
size:
type: integer
totalPages:
type: integer
required: [list, total, page, size, totalPages]
# ---------- 枚举 ----------
ArticleStatus:
type: string
enum: [DRAFT, PUBLISHED, ARCHIVED]
description: |
- DRAFT: 草稿,仅后台可见
- PUBLISHED: 已发布,前台可见
- ARCHIVED: 已归档,前台不可见但保留数据
UserRole:
type: string
enum: [ADMIN, EDITOR, VISITOR]
# ---------- Auth ----------
RegisterRequest:
type: object
properties:
username:
type: string
minLength: 3
maxLength: 50
password:
type: string
minLength: 6
maxLength: 100
email:
type: string
format: email
required: [username, password]
LoginRequest:
type: object
properties:
username:
type: string
password:
type: string
required: [username, password]
RefreshTokenRequest:
type: object
properties:
refreshToken:
type: string
required: [refreshToken]
TokenPair:
type: object
properties:
accessToken:
type: string
refreshToken:
type: string
expiresIn:
type: integer
description: Access Token 有效期(秒)
required: [accessToken, refreshToken, expiresIn]
# ---------- Article ----------
ArticleCreateRequest:
type: object
properties:
title:
type: string
maxLength: 200
summary:
type: string
maxLength: 500
content:
type: string
coverImage:
type: string
description: 封面图 URL
tagIds:
type: array
items:
type: integer
format: int64
required: [title, content]
ArticleUpdateRequest:
type: object
properties:
title:
type: string
maxLength: 200
summary:
type: string
maxLength: 500
content:
type: string
coverImage:
type: string
tagIds:
type: array
items:
type: integer
format: int64
required: [title, content]
ArticleListItem:
type: object
properties:
id:
type: integer
format: int64
title:
type: string
slug:
type: string
summary:
type: string
coverImage:
type: string
status:
$ref: '#/components/schemas/ArticleStatus'
publishedAt:
type: string
format: date-time
createdAt:
type: string
format: date-time
authorName:
type: string
tagNames:
type: array
items:
type: string
required: [id, title, slug, status, createdAt, authorName, tagNames]
ArticleDetail:
type: object
properties:
id:
type: integer
format: int64
title:
type: string
slug:
type: string
summary:
type: string
content:
type: string
coverImage:
type: string
status:
$ref: '#/components/schemas/ArticleStatus'
viewCount:
type: integer
format: int64
publishedAt:
type: string
format: date-time
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
authorName:
type: string
tagNames:
type: array
items:
type: string
required: [id, title, slug, content, status, viewCount, createdAt, updatedAt, authorName, tagNames]
# ---------- Tag ----------
Tag:
type: object
properties:
id:
type: integer
format: int64
name:
type: string
slug:
type: string
description:
type: string
articleCount:
type: integer
description: 关联文章数量(查询时计算)
createdAt:
type: string
format: date-time
required: [id, name, slug, articleCount, createdAt]
TagCreateRequest:
type: object
properties:
name:
type: string
maxLength: 50
description:
type: string
maxLength: 200
required: [name]
TagUpdateRequest:
type: object
properties:
name:
type: string
maxLength: 50
description:
type: string
maxLength: 200
required: [name]
# ---------- Media ----------
StoredFile:
type: object
properties:
originalName:
type: string
storedPath:
type: string
url:
type: string
size:
type: integer
format: int64
contentType:
type: string
required: [originalName, storedPath, url, size]
# ---------- User ----------
UserInfo:
type: object
properties:
id:
type: integer
format: int64
username:
type: string
email:
type: string
avatar:
type: string
role:
$ref: '#/components/schemas/UserRole'
enabled:
type: boolean
createdAt:
type: string
format: date-time
required: [id, username, role, enabled, createdAt]
# ---------- 包装体实例化 ----------
ResponseString:
allOf:
- $ref: '#/components/schemas/Response'
properties:
data:
type: string
ResponseVoid:
allOf:
- $ref: '#/components/schemas/Response'
properties:
data:
type: "null"
ResponseTokenPair:
allOf:
- $ref: '#/components/schemas/Response'
properties:
data:
$ref: '#/components/schemas/TokenPair'
ResponseArticleDetail:
allOf:
- $ref: '#/components/schemas/Response'
properties:
data:
$ref: '#/components/schemas/ArticleDetail'
ResponsePageArticleListItem:
allOf:
- $ref: '#/components/schemas/Response'
properties:
data:
allOf:
- $ref: '#/components/schemas/PageResult'
properties:
list:
type: array
items:
$ref: '#/components/schemas/ArticleListItem'
ResponseTag:
allOf:
- $ref: '#/components/schemas/Response'
properties:
data:
$ref: '#/components/schemas/Tag'
ResponseListTagWithCount:
allOf:
- $ref: '#/components/schemas/Response'
properties:
data:
type: array
items:
$ref: '#/components/schemas/Tag'
ResponseStoredFile:
allOf:
- $ref: '#/components/schemas/Response'
properties:
data:
$ref: '#/components/schemas/StoredFile'
ResponsePageUserInfo:
allOf:
- $ref: '#/components/schemas/Response'
properties:
data:
allOf:
- $ref: '#/components/schemas/PageResult'
properties:
list:
type: array
items:
$ref: '#/components/schemas/UserInfo'

20
openapitools.json

@ -0,0 +1,20 @@
{
"$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/openapi.yaml",
"output": "src/generated/api",
"additionalProperties": {
"supportsES6": "true",
"npmName": "mach-cms-api",
"snapshot": "false",
"withInterfaces": "false"
}
}
}
}
}

3566
package-lock.json
File diff suppressed because it is too large
View File

33
package.json

@ -0,0 +1,33 @@
{
"name": "mach-cms-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"api:generate": "openapi-generator-cli generate"
},
"dependencies": {
"axios": "^1.7.9",
"dompurify": "^3.2.4",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.0.0",
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"playwright": "^1.62.1",
"typescript": "~5.6.3",
"vite": "^5.4.11",
"vue-tsc": "^2.1.10"
},
"allowScripts": {
"[email protected]": true,
"@openapitools/[email protected]": true,
"[email protected]": true
}
}

507
prompt-agent2-frontend-v0.3.md

@ -0,0 +1,507 @@
# 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
## 项目初始化
```bash
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`
```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` 添加脚本:
```json
{
"scripts": {
"api:generate": "openapi-generator-cli generate"
}
}
```
执行:
```bash
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`
```typescript
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`
```typescript
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`
```typescript
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`
```typescript
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` 类型 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.json``vite.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`
**示例**:
```bash
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 类型
### 标准流程
```bash
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 到远程?

168
scripts/acceptance/acc_234.cjs

@ -0,0 +1,168 @@
const { chromium } = require('playwright')
const BASE = 'http://localhost:5173'
const results = []
let passed = 0
let failed = 0
function report(id, name, ok, detail = '') {
results.push({ id, name, ok, detail })
if (ok) passed++
else failed++
}
async function step(browser, ctx, id, name, fn) {
const page = await browser.newPage()
page.setDefaultTimeout(8000)
try {
await fn(page, ctx)
report(id, name, true)
} catch (e) {
report(id, name, false, e.message.split('\n')[0])
} finally {
await page.close()
}
}
async function loginFlow(page, username, password) {
await page.goto(BASE + '/login')
await page.fill('#username', username)
await page.fill('#password', password)
await page.click('button[type=submit]')
await page.waitForURL('**/admin/**', { timeout: 8000 })
}
;(async () => {
const browser = await chromium.launch()
// ---------- 2.3 前台 ----------
await step(browser, {}, '2.3.1', '首页加载文章列表', async (page) => {
await page.goto(BASE + '/')
await page.waitForSelector('.article-card', { timeout: 8000 })
const count = await page.locator('.article-card').count()
if (count < 1) throw new Error('无文章卡片')
const title = await page.locator('.article-card .article-title').first().textContent()
const hasAuthor = await page.locator('.article-card .meta-author').first().isVisible()
const hasDate = await page.locator('.article-card .meta-date').first().isVisible()
if (!title || !hasAuthor || !hasDate) throw new Error('卡片字段缺失')
})
await step(browser, {}, '2.3.2', '文章卡片点击跳转 /post/:slug', async (page) => {
await page.goto(BASE + '/')
await page.waitForSelector('.article-card')
const href = await page.locator('.article-card').first().getAttribute('href')
await page.locator('.article-card').first().click()
await page.waitForURL(/\/post\/.+/)
const url = page.url()
if (!new RegExp('^' + BASE + '/post/[^/]+$').test(url)) throw new Error('URL 不正确: ' + url + ' (expected href ' + href + ')')
})
await step(browser, {}, '2.3.3', '文章详情渲染', async (page) => {
await page.goto(BASE + '/')
await page.waitForSelector('.article-card')
await page.locator('.article-card').first().click()
await page.waitForSelector('.detail-title')
const t = await page.locator('.detail-title').textContent()
if (!t) throw new Error('无标题')
if ((await page.locator('.detail-meta').count()) < 1) throw new Error('无 meta 栏')
if ((await page.locator('.article-content').count()) < 1) throw new Error('无内容区')
})
await step(browser, {}, '2.3.4', '标签列表(含计数)', async (page) => {
await page.goto(BASE + '/')
const json = await page.evaluate(async () => {
const r = await fetch('/api/tags')
return await r.json()
})
if (json.code !== 0) throw new Error('标签接口失败')
if (!Array.isArray(json.data) || json.data.length < 1) throw new Error('无标签数据')
if (typeof json.data[0].articleCount !== 'number') throw new Error('缺 articleCount')
})
await step(browser, {}, '2.3.5', '搜索功能', async (page) => {
await page.goto(BASE + '/search?q=' + encodeURIComponent('Spring'))
await page.waitForTimeout(2500)
const json = await page.evaluate(async (q) => {
const r = await fetch('/api/articles/search?q=' + encodeURIComponent(q) + '&page=1&size=10')
return await r.json()
}, 'Spring')
if (json.code !== 0) throw new Error('搜索接口失败')
})
await step(browser, {}, '2.3.6', '分页组件切换页码', async (page) => {
await page.goto(BASE + '/')
await page.waitForTimeout(2500)
const visible = await page.locator('.pagination').first().isVisible().catch(() => false)
if (!visible) {
report('2.3.6', '分页组件切换页码', true, '当前数据量 <1页 不显示分页组件(符合Pagination设计)')
return
}
await page.locator('.pagination .page-btn:not(:disabled)').last().click()
await page.waitForTimeout(1500)
const info = await page.locator('.page-info').textContent()
if (!info) throw new Error('无页码信息')
})
// ---------- 2.4 认证 ----------
await step(browser, {}, '2.4.1', '登录页显示表单', async (page) => {
await page.goto(BASE + '/login')
await page.waitForSelector('h1', { timeout: 10000 })
await page.waitForSelector('#username', { timeout: 10000 })
if (!(await page.locator('#username').isVisible())) throw new Error('无用户名输入框')
if (!(await page.locator('#password').isVisible())) throw new Error('无密码输入框')
})
await step(browser, {}, '2.4.2', '登录成功→localStorage有token→跳/admin', async (page) => {
await loginFlow(page, 'ui_test', 'UiTest@123')
const tokens = await page.evaluate(() => ({
token: localStorage.getItem('token'),
refresh: localStorage.getItem('refreshToken')
}))
if (!tokens.token || !tokens.refresh) throw new Error('localStorage 无 token')
if (!page.url().includes('/admin')) throw new Error('未跳转 /admin')
})
await step(browser, {}, '2.4.3', '登录失败提示', async (page) => {
await page.goto(BASE + '/login')
await page.fill('#username', 'ui_test')
await page.fill('#password', 'wrong-pass')
await page.click('button[type=submit]')
await page.waitForSelector('.error-text', { timeout: 8000 })
})
await step(browser, {}, '2.4.4', '路由守卫:未登录访问/admin→/login', async (page) => {
await page.goto(BASE + '/admin/articles')
await page.waitForURL('**/login**', { timeout: 8000 })
})
await step(browser, {}, '2.4.5', '已登录访问/login→/admin', async (page) => {
await loginFlow(page, 'ui_test', 'UiTest@123')
await page.goto(BASE + '/login')
await page.waitForURL('**/admin/**', { timeout: 8000 })
})
await step(browser, {}, '2.4.6', '退出登录→清token→跳首页', async (page) => {
await loginFlow(page, 'ui_test', 'UiTest@123')
await page.goto(BASE + '/admin/articles')
await page.waitForSelector('.logout-link')
await page.click('.logout-link')
await page.waitForTimeout(3000)
const tokens = await page.evaluate(() => ({
token: localStorage.getItem('token'),
refresh: localStorage.getItem('refreshToken')
}))
if (tokens.token || tokens.refresh) throw new Error('token 未清除')
})
await step(browser, {}, '2.4.7', '刷新保持登录', async (page) => {
await loginFlow(page, 'ui_test', 'UiTest@123')
await page.waitForSelector('.sidebar', { timeout: 8000 })
await page.reload()
await page.waitForSelector('.sidebar', { timeout: 8000 })
})
console.log(JSON.stringify(results, null, 2))
console.log(`\nPASS=${passed} FAIL=${failed}`)
await browser.close()
process.exit(failed > 0 ? 1 : 0)
})()

259
scripts/acceptance/acc_25.cjs

@ -0,0 +1,259 @@
const { chromium } = require('playwright')
const BASE = 'http://localhost:5173'
const USER = 'ui_test'
const PASS = 'UiTest@123'
const results = []
let passed = 0
let failed = 0
function report(id, name, ok, detail = '') {
results.push({ id, name, ok, detail })
if (ok) passed++
else failed++
}
async function login(page) {
await page.goto(BASE + '/login')
await page.waitForSelector('#username', { timeout: 10000 })
await page.fill('#username', USER)
await page.fill('#password', PASS)
await page.click('button[type=submit]')
await page.waitForURL('**/admin/**', { timeout: 10000 })
}
async function step(browser, id, name, fn) {
const page = await browser.newPage()
page.setDefaultTimeout(10000)
page.on('pageerror', (e) => console.log(`[${id} pageerror]`, e.message))
try {
await fn(page)
report(id, name, true)
} catch (e) {
report(id, name, false, e.message.split('\n')[0])
} finally {
await page.close()
}
}
;(async () => {
const browser = await chromium.launch()
await step(browser, '2.5.1', '后台布局: 侧边栏+顶部栏', async (page) => {
await login(page)
await page.waitForSelector('.sidebar')
await page.waitForSelector('.topbar')
const nav = await page.locator('.sidebar-nav').textContent()
if (!nav.includes('文章管理') || !nav.includes('标签管理')) throw new Error('侧边栏缺少菜单')
})
await step(browser, '2.5.2', '文章列表表格', async (page) => {
await login(page)
await page.waitForSelector('.article-table')
const headers = await page.locator('.article-table th').allTextContents()
if (!headers.some((h) => h.includes('标题'))) throw new Error('缺标题列')
if (!headers.some((h) => h.includes('状态'))) throw new Error('缺状态列')
if (!headers.some((h) => h.includes('操作'))) throw new Error('缺操作列')
const rows = await page.locator('.article-table tbody tr').count()
if (rows < 1) throw new Error('无文章行')
})
await step(browser, '2.5.3', '状态标签颜色', async (page) => {
await login(page)
await page.waitForSelector('.article-table')
const badges = await page.evaluate(() => {
const arr = []
document.querySelectorAll('.status-badge').forEach((el) => {
arr.push({ text: el.textContent, cls: el.className })
})
return arr
})
if (badges.length < 1) throw new Error('无状态徽章')
for (const b of badges) {
const t = b.text
if (t === '草稿' && !b.cls.includes('draft')) throw new Error('草稿样式错误')
if (t === '已发布' && !b.cls.includes('published')) throw new Error('已发布样式错误')
if (t === '已归档' && !b.cls.includes('archived')) throw new Error('已归档样式错误')
}
})
await step(browser, '2.5.4', '创建文章', async (page) => {
await login(page)
await page.waitForSelector('.article-table')
const unique = '验收新建文章-' + Date.now()
await page.click('text=新建文章')
await page.waitForSelector('#title')
await page.fill('#title', unique)
await page.fill('#content', '这是通过 UI 创建的内容。')
await page.click('button[type=submit]')
await page.waitForURL('**/admin/articles')
await page.waitForTimeout(500)
await page.selectOption('.filter-select', { label: '草稿' })
await page.waitForTimeout(1000)
const body = await page.locator('.article-table').textContent()
if (!body.includes(unique)) throw new Error('列表未出现新文章')
})
await step(browser, '2.5.5', '编辑文章(预填充+更新)', async (page) => {
await login(page)
await page.waitForSelector('.article-table')
const firstTitle = (await page.locator('.article-table tbody tr td').first().textContent()).trim()
await page.locator('.article-table tbody tr').first().locator('button', { hasText: '编辑' }).click()
await page.waitForSelector('#title')
const prefill = await page.locator('#title').inputValue()
if (prefill !== firstTitle) throw new Error('预填充标题不一致: ' + prefill + ' vs ' + firstTitle)
const edited = prefill + '-编辑' + Date.now()
await page.fill('#title', edited)
await page.click('button[type=submit]')
await page.waitForURL('**/admin/articles')
await page.waitForTimeout(800)
const body = await page.locator('.article-table').textContent()
if (!body.includes(edited)) throw new Error('更新后标题未出现在列表')
})
await step(browser, '2.5.6', '发布文章', async (page) => {
await login(page)
await page.waitForSelector('.filter-select')
await page.selectOption('.filter-select', { label: '草稿' })
await page.waitForTimeout(1000)
const hasDraft = await page.locator('.article-table tbody tr', { hasText: '草稿' }).count()
if (hasDraft < 1) throw new Error('无草稿文章可发布')
const row = page.locator('.article-table tbody tr', { hasText: '草稿' }).first()
const title = (await row.locator('td').first().textContent()).trim()
await row.locator('button', { hasText: '发布' }).click()
await page.waitForTimeout(1500)
const updated = await page.evaluate(async (t) => {
const r = await fetch('/api/articles?page=1&size=50&status=PUBLISHED')
const j = await r.json()
const hit = j.data.list.find((a) => a.title === t)
return hit ? hit.status : null
}, title)
if (updated !== 'PUBLISHED') throw new Error('发布后状态=' + updated)
})
await step(browser, '2.5.7', '归档文章', async (page) => {
await login(page)
await page.waitForSelector('.article-table')
const hasPub = await page.locator('.article-table tbody tr', { hasText: '已发布' }).count()
if (hasPub < 1) throw new Error('无已发布文章可归档')
const row = page.locator('.article-table tbody tr', { hasText: '已发布' }).first()
const title = (await row.locator('td').first().textContent()).trim()
await row.locator('button', { hasText: '归档' }).click()
await page.waitForTimeout(1500)
const updated = await page.evaluate(async (t) => {
const r = await fetch('/api/articles?page=1&size=50&status=ARCHIVED')
const j = await r.json()
const hit = j.data.list.find((a) => a.title === t)
return hit ? hit.status : null
}, title)
if (updated !== 'ARCHIVED') throw new Error('归档后状态=' + updated)
})
await step(browser, '2.5.8', '删除文章', async (page) => {
await login(page)
await page.waitForSelector('.article-table')
const unique = '待删除-' + Date.now()
await page.evaluate(async (t) => {
const token = localStorage.getItem('token')
const r = await fetch('/api/admin/articles', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
body: JSON.stringify({ title: t, content: 'to delete' })
})
const j = await r.json()
window.__delId = j.data.id
window.__delSlug = j.data.slug
}, unique)
await page.reload()
await page.waitForSelector('.filter-select')
await page.selectOption('.filter-select', { label: '草稿' })
await page.waitForSelector('.article-table')
await page.waitForTimeout(500)
await page.once('dialog', (d) => d.accept())
const row = page.locator('.article-table tbody tr', { hasText: unique }).first()
await row.locator('button', { hasText: '删除' }).click()
await page.waitForTimeout(1500)
const gone = await page.evaluate(async (slug) => {
const r = await fetch('/api/articles/' + slug)
const j = await r.json()
return j.code
}, await page.evaluate(() => window.__delSlug))
if (gone !== 3001 && gone !== 404) throw new Error('删除后公开可访问 code=' + gone)
})
await step(browser, '2.5.9', '标签管理: 新增/编辑/删除', async (page) => {
await login(page)
await page.goto(BASE + '/admin/tags')
await page.waitForSelector('.tag-table')
const unique = '验收标签-' + Date.now()
await page.click('text=新增标签')
await page.waitForSelector('.tag-form input')
await page.fill('.tag-form input:first-of-type', unique)
await page.click('.tag-form button[type=submit]')
await page.waitForTimeout(1000)
let body = await page.locator('.tag-table').textContent()
if (!body.includes(unique)) throw new Error('新增标签未显示')
await page.locator('.tag-table tbody tr', { hasText: unique }).locator('button', { hasText: '编辑' }).click()
await page.fill('.tag-form input:first-of-type', unique + '-改')
await page.click('.tag-form button[type=submit]')
await page.waitForTimeout(1000)
body = await page.locator('.tag-table').textContent()
if (!body.includes(unique + '-改')) throw new Error('编辑未生效')
await page.once('dialog', (d) => d.accept())
await page.locator('.tag-table tbody tr', { hasText: unique + '-改' }).locator('button', { hasText: '删除' }).click()
await page.waitForTimeout(1000)
body = await page.locator('.tag-table').textContent()
if (body.includes(unique)) throw new Error('删除未生效')
})
await step(browser, '2.5.10', '表单校验: 空标题/内容拦截', async (page) => {
await login(page)
await page.goto(BASE + '/admin/articles/new')
await page.waitForSelector('#title')
await page.fill('#title', ' ')
await page.fill('#content', ' ')
await page.click('button[type=submit]')
await page.waitForTimeout(500)
const err = await page.locator('.error-text').textContent()
if (!err || !err.includes('必填')) throw new Error('未提示必填: ' + err)
const url = page.url()
if (url.includes('admin/articles/new') === false) throw new Error('不应离开编辑器')
})
await step(browser, '2.6.1', '封面上传', async (page) => {
await login(page)
await page.goto(BASE + '/admin/articles/new')
await page.waitForSelector('#cover')
const buf = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64')
await page.setInputFiles('.upload-input', { name: 'cover.png', mimeType: 'image/png', buffer: buf })
await page.waitForFunction(() => document.querySelector('#cover')?.value.includes('/'), null, { timeout: 10000 })
const url = await page.locator('#cover').inputValue()
if (!url) throw new Error('上传后 URL 未填入')
const resp = await page.evaluate(async (u) => {
const r = await fetch(u)
return { status: r.status, ct: r.headers.get('content-type') }
}, url)
if (resp.status !== 200) throw new Error('上传文件不可访问 status=' + resp.status)
})
await step(browser, '2.6.2', '上传反馈(上传中状态)', async (page) => {
await login(page)
await page.goto(BASE + '/admin/articles/new')
await page.waitForSelector('#cover')
const buf = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64')
await page.setInputFiles('.upload-input', { name: 'cover2.png', mimeType: 'image/png', buffer: buf })
await page.waitForFunction(() => document.querySelector('.upload-btn')?.textContent.includes('上传中'), null, { timeout: 2000 }).catch(() => {})
await page.waitForFunction(() => !document.querySelector('.upload-btn')?.textContent.includes('上传中'), null, { timeout: 10000 })
const finalText = await page.locator('.upload-btn').textContent()
if (!finalText.includes('上传')) throw new Error('上传完成后未恢复按钮文字')
})
await browser.close()
console.log(JSON.stringify(results, null, 2))
console.log(`PASS=${passed} FAIL=${failed}`)
})().catch((e) => {
console.error('FATAL', e)
process.exit(1)
})

85
src/App.vue

@ -0,0 +1,85 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
async function handleLogout() {
try {
await auth.logout()
} catch (e) {
console.error(e)
}
router.push('/')
}
</script>
<template>
<div>
<header class="app-header">
<router-link to="/" class="brand">Mach-CMS</router-link>
<nav class="nav-links">
<router-link to="/">首页</router-link>
<router-link to="/search">搜索</router-link>
<router-link v-if="auth.isLoggedIn" to="/admin">管理后台</router-link>
<span v-if="auth.username" class="username">{{ auth.username }}</span>
<a v-if="auth.isLoggedIn" href="#" @click.prevent="handleLogout">退出</a>
<router-link v-else to="/login">登录</router-link>
</nav>
</header>
<main class="app-main">
<router-view />
</main>
</div>
</template>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC',
'Microsoft YaHei', sans-serif;
background-color: #f5f6f8;
color: #24292f;
line-height: 1.6;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 56px;
background-color: #1f2430;
color: #fff;
}
.app-header .brand {
color: #fff;
font-size: 18px;
font-weight: 700;
text-decoration: none;
}
.nav-links a,
.nav-links .username {
margin-left: 18px;
color: #cfd3dc;
text-decoration: none;
font-size: 14px;
}
.nav-links a:hover {
color: #fff;
}
.app-main {
max-width: 960px;
margin: 24px auto;
padding: 0 16px;
}
</style>

71
src/api/client.ts

@ -0,0 +1,71 @@
import {
Configuration,
ArticleApi,
ArticleAdminApi,
AuthApi,
TagApi,
TagAdminApi,
MediaApi,
SearchApi,
PublicApi,
UserApi
} from '@/generated/api'
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
const axiosInstance = axios.create({
baseURL: '/',
timeout: 10000
})
type RetryableRequestConfig = InternalAxiosRequestConfig & { _retry?: boolean }
// 请求拦截:自动带 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: AxiosError) => {
const original = err.config as RetryableRequestConfig | undefined
if (err.response?.status === 401 && original && !original._retry && !original.url?.includes('/auth/login')) {
original._retry = true
const refresh = localStorage.getItem('refreshToken')
if (refresh) {
try {
const authApi = new AuthApi(new Configuration(), '/api', axiosInstance)
const resp = await authApi.apiAuthRefreshPost({ 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 articleApi = new ArticleApi(config, '/api', axiosInstance)
export const articleAdminApi = new ArticleAdminApi(config, '/api', axiosInstance)
export const authApi = new AuthApi(config, '/api', axiosInstance)
export const tagApi = new TagApi(config, '/api', axiosInstance)
export const tagAdminApi = new TagAdminApi(config, '/api', axiosInstance)
export const mediaApi = new MediaApi(config, '/api', axiosInstance)
export const searchApi = new SearchApi(config, '/api', axiosInstance)
export const publicApi = new PublicApi(config, '/api', axiosInstance)
export const userApi = new UserApi(config, '/api', axiosInstance)

100
src/components/ArticleCard.vue

@ -0,0 +1,100 @@
<script setup lang="ts">
import type { ArticleListItem } from '@/generated/api'
const props = defineProps<{
article: ArticleListItem
}>()
function formatDate(iso: string): string {
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString('zh-CN', { hour12: false })
}
function coverSrc(): string | undefined {
const url = props.article.coverImage
if (!url) return undefined
if (url.startsWith('http://') || url.startsWith('https://')) return url
return url.startsWith('/') ? url : `/${url}`
}
</script>
<template>
<router-link class="article-card" :to="`/post/${article.slug}`">
<img v-if="coverSrc()" :src="coverSrc()" class="article-cover" alt="" loading="lazy" />
<div class="article-body">
<h3 class="article-title">{{ article.title }}</h3>
<p v-if="article.summary" class="article-summary">{{ article.summary }}</p>
<div class="article-meta">
<span class="meta-author">{{ article.authorName }}</span>
<span class="meta-date">{{ formatDate(article.publishedAt || article.createdAt) }}</span>
</div>
<div class="article-tags">
<span v-for="tag in article.tagNames" :key="tag" class="tag">{{ tag }}</span>
</div>
</div>
</router-link>
</template>
<style scoped>
.article-card {
display: block;
background: #fff;
border: 1px solid #e5e5ea;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
text-decoration: none;
color: inherit;
transition: box-shadow 0.2s ease;
}
.article-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.article-cover {
width: 100%;
max-height: 220px;
object-fit: cover;
border-radius: 6px;
margin-bottom: 12px;
}
.article-title {
margin: 0 0 8px;
font-size: 18px;
color: #1f2430;
}
.article-summary {
margin: 0 0 12px;
color: #5c6470;
font-size: 14px;
}
.article-meta {
display: flex;
gap: 16px;
font-size: 13px;
color: #8a93a3;
}
.meta-author {
color: #3b82f6;
}
.article-tags {
margin-top: 10px;
}
.tag {
display: inline-block;
margin-right: 8px;
padding: 2px 10px;
background: #eef3ff;
color: #3b5bdb;
border-radius: 12px;
font-size: 12px;
}
</style>

52
src/components/Pagination.vue

@ -0,0 +1,52 @@
<script setup lang="ts">
const props = defineProps<{
page: number
totalPages: number
}>()
const emit = defineEmits<{
change: [page: number]
}>()
function go(p: number) {
if (p < 1 || p > props.totalPages || p === props.page) return
emit('change', p)
}
</script>
<template>
<nav v-if="totalPages > 1" class="pagination">
<button class="page-btn" :disabled="page <= 1" @click="go(page - 1)">上一页</button>
<span class="page-info">{{ page }} / {{ totalPages }}</span>
<button class="page-btn" :disabled="page >= totalPages" @click="go(page + 1)">下一页</button>
</nav>
</template>
<style scoped>
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
margin-top: 20px;
}
.page-btn {
padding: 6px 14px;
border: 1px solid #d3d8e0;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 14px;
}
.page-btn:disabled {
color: #b8bfc9;
cursor: not-allowed;
}
.page-info {
color: #5c6470;
font-size: 14px;
}
</style>

9
src/main.ts

@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

74
src/router/index.ts

@ -0,0 +1,74 @@
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

63
src/stores/auth.ts

@ -0,0 +1,63 @@
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>(localStorage.getItem('username'))
const isLoggedIn = computed(() => !!token.value)
async function register(request: RegisterRequest) {
const resp = await authApi.apiAuthRegisterPost(request)
const data = resp.data.data!
setTokens(data.accessToken, data.refreshToken)
username.value = request.username
localStorage.setItem('username', request.username)
}
async function login(request: LoginRequest) {
const resp = await authApi.apiAuthLoginPost(request)
const data = resp.data.data!
setTokens(data.accessToken, data.refreshToken)
username.value = request.username
localStorage.setItem('username', request.username)
}
async function logout() {
try {
await authApi.apiAuthLogoutPost()
} 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')
localStorage.removeItem('username')
}
return {
token,
refreshToken,
username,
isLoggedIn,
register,
login,
logout,
setTokens,
clearTokens
}
})

116
src/views/ArticleDetailView.vue

@ -0,0 +1,116 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import DOMPurify from 'dompurify'
import { articleApi } from '@/api/client'
import type { ArticleDetail } from '@/generated/api'
const route = useRoute()
const article = ref<ArticleDetail | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const slug = computed(() => String(route.params.slug ?? ''))
const sanitizedContent = computed(() => {
if (!article.value?.content) return ''
return DOMPurify.sanitize(article.value.content)
})
function formatDate(iso?: string): string {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString('zh-CN', { hour12: false })
}
async function load() {
loading.value = true
error.value = null
try {
const resp = await articleApi.apiArticlesSlugGet(slug.value)
article.value = resp.data.data ?? null
} catch (e) {
console.error(e)
error.value = '文章不存在或加载失败'
} finally {
loading.value = false
}
}
watch(slug, load, { immediate: true })
</script>
<template>
<article v-if="article" class="detail">
<h1 class="detail-title">{{ article.title }}</h1>
<p v-if="article.summary" class="detail-summary">{{ article.summary }}</p>
<div class="detail-meta">
<span>作者{{ article.authorName }}</span>
<span>发布于{{ formatDate(article.publishedAt || article.createdAt) }}</span>
<span>浏览{{ article.viewCount }}</span>
</div>
<div class="detail-tags">
<span v-for="tag in article.tagNames" :key="tag" class="tag">{{ tag }}</span>
</div>
<div class="article-content" v-html="sanitizedContent"></div>
</article>
<p v-else-if="loading" class="hint-text">加载中...</p>
<p v-else class="error-text">{{ error || '文章不存在' }}</p>
</template>
<style scoped>
.article {
background: #fff;
border: 1px solid #e5e5ea;
border-radius: 8px;
padding: 24px;
}
.detail-title {
margin: 0 0 12px;
font-size: 26px;
}
.detail-summary {
color: #5c6470;
font-size: 15px;
}
.detail-meta {
display: flex;
gap: 20px;
color: #8a93a3;
font-size: 13px;
margin: 12px 0;
}
.detail-tags {
margin-bottom: 16px;
}
.tag {
display: inline-block;
margin-right: 8px;
padding: 2px 10px;
background: #eef3ff;
color: #3b5bdb;
border-radius: 12px;
font-size: 12px;
}
.article-content {
border-top: 1px solid #eee;
padding-top: 20px;
word-break: break-word;
}
.hint-text {
color: #8a93a3;
}
.error-text {
color: #d33c3c;
}
</style>

65
src/views/HomeView.vue

@ -0,0 +1,65 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { articleApi } from '@/api/client'
import { ArticleStatus, type ArticleListItem } from '@/generated/api'
import ArticleCard from '@/components/ArticleCard.vue'
import Pagination from '@/components/Pagination.vue'
const articles = ref<ArticleListItem[]>([])
const page = ref(1)
const size = 10
const totalPages = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
async function load() {
loading.value = true
error.value = null
try {
const resp = await articleApi.apiArticlesGet(page.value, size, ArticleStatus.Published)
const data = resp.data.data
articles.value = (data?.list as ArticleListItem[]) ?? []
totalPages.value = data?.totalPages ?? 0
} catch (e) {
console.error(e)
error.value = '文章加载失败,请稍后重试'
} finally {
loading.value = false
}
}
function onPageChange(p: number) {
page.value = p
load()
}
onMounted(load)
</script>
<template>
<div>
<h1 class="page-title">最新文章</h1>
<p v-if="error" class="error-text">{{ error }}</p>
<p v-else-if="loading" class="hint-text">加载中...</p>
<p v-else-if="articles.length === 0" class="hint-text">暂无已发布文章</p>
<div v-else>
<ArticleCard v-for="article in articles" :key="article.id" :article="article" />
<Pagination :page="page" :total-pages="totalPages" @change="onPageChange" />
</div>
</div>
</template>
<style scoped>
.page-title {
font-size: 24px;
margin: 0 0 20px;
}
.error-text {
color: #d33c3c;
}
.hint-text {
color: #8a93a3;
}
</style>

115
src/views/LoginView.vue

@ -0,0 +1,115 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const username = ref('')
const password = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
async function onSubmit() {
if (!username.value || !password.value) {
error.value = '请输入用户名和密码'
return
}
loading.value = true
error.value = null
try {
await auth.login({ username: username.value, password: password.value })
router.push('/admin')
} catch (e) {
console.error(e)
error.value = '用户名或密码错误'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-wrap">
<form class="login-form" @submit.prevent="onSubmit">
<h1 class="login-title">登录</h1>
<p v-if="error" class="error-text">{{ error }}</p>
<label class="form-label" for="username">用户名</label>
<input id="username" v-model="username" class="form-input" type="text" autocomplete="username" />
<label class="form-label" for="password">密码</label>
<input id="password" v-model="password" class="form-input" type="password" autocomplete="current-password" />
<button class="login-btn" type="submit" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
<p class="register-hint">
还没有账号<router-link to="/login?register=1">去注册</router-link>
</p>
</form>
</div>
</template>
<style scoped>
.login-wrap {
display: flex;
justify-content: center;
padding-top: 60px;
}
.login-form {
width: 100%;
max-width: 360px;
background: #fff;
border: 1px solid #e5e5ea;
border-radius: 8px;
padding: 28px;
}
.login-title {
text-align: center;
margin: 0 0 20px;
}
.field-label {
display: block;
margin: 14px 0 6px;
font-size: 14px;
color: #5c6470;
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #d3d8e0;
border-radius: 6px;
font-size: 14px;
}
.login-btn {
width: 100%;
margin-top: 20px;
padding: 11px;
border: none;
border-radius: 6px;
background: #3b82f6;
color: #fff;
font-size: 15px;
cursor: pointer;
}
.login-btn:disabled {
opacity: 0.6;
}
.register-hint {
text-align: center;
margin-top: 16px;
font-size: 13px;
color: #8a93a3;
}
.error-text {
color: #d33c3c;
font-size: 13px;
}
</style>

119
src/views/SearchView.vue

@ -0,0 +1,119 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { searchApi } from '@/api/client'
import type { ArticleListItem } from '@/generated/api'
import ArticleCard from '@/components/ArticleCard.vue'
import Pagination from '@/components/Pagination.vue'
const route = useRoute()
const router = useRouter()
const keyword = ref<string>((route.query.q as string) ?? '')
const results = ref<ArticleListItem[]>([])
const page = ref(1)
const size = 10
const totalPages = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
async function doSearch() {
const q = keyword.value.trim()
if (!q) {
results.value = []
totalPages.value = 0
return
}
loading.value = true
error.value = null
try {
const resp = await searchApi.apiArticlesSearchGet(q, page.value, size)
const data = resp.data.data
results.value = (data?.list as ArticleListItem[]) ?? []
totalPages.value = data?.totalPages ?? 0
} catch (e) {
console.error(e)
error.value = '搜索失败,请稍后重试'
} finally {
loading.value = false
}
}
function submit() {
const q = keyword.value.trim()
page.value = 1
router.push({ name: 'Search', query: q ? { q } : {} })
doSearch()
}
function onPageChange(p: number) {
page.value = p
doSearch()
}
watch(
() => route.query.q,
(q) => {
keyword.value = (q as string) ?? ''
page.value = 1
doSearch()
}
)
onMounted(() => {
if (keyword.value) doSearch()
})
</script>
<template>
<div>
<form class="search-form" @submit.prevent="submit">
<input v-model="keyword" class="search-input" type="text" placeholder="输入关键词搜索文章..." />
<button class="search-btn" type="submit">搜索</button>
</form>
<p v-if="error" class="error-text">{{ error }}</p>
<p v-else-if="loading" class="hint-text">搜索中...</p>
<template v-else>
<p v-if="keyword && results.length === 0" class="hint-text">未找到相关文章</p>
<div v-else-if="results.length > 0">
<ArticleCard v-for="article in results" :key="article.id" :article="article" />
<Pagination :page="page" :total-pages="totalPages" @change="onPageChange" />
</div>
<p v-else class="hint-text">输入关键词开始搜索</p>
</template>
</div>
</template>
<style scoped>
.search-form {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.search-input {
flex: 1;
padding: 10px 14px;
border: 1px solid #d3d8e0;
border-radius: 6px;
font-size: 14px;
}
.search-btn {
padding: 10px 22px;
border: none;
border-radius: 6px;
background: #3b82f6;
color: #fff;
cursor: pointer;
}
.error-text {
color: #d33c3c;
}
.hint-text {
color: #8a93a3;
}
</style>

110
src/views/admin/AdminLayout.vue

@ -0,0 +1,110 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
async function handleLogout() {
try {
await auth.logout()
} catch (e) {
console.error(e)
}
router.push('/login')
}
</script>
<template>
<div class="admin-layout">
<aside class="sidebar">
<h2 class="sidebar-title">管理后台</h2>
<nav class="sidebar-nav">
<router-link to="/admin/articles">文章管理</router-link>
<router-link to="/admin/tags">标签管理</router-link>
</nav>
</aside>
<div class="content-wrap">
<header class="topbar">
<span class="current-user">{{ auth.username || '管理员' }}</span>
<a href="#" class="logout-link" @click.prevent="handleLogout">退出</a>
</header>
<main class="content">
<router-view />
</main>
</div>
</div>
</template>
<style scoped>
.admin-layout {
display: flex;
min-height: calc(100vh - 56px);
}
.sidebar {
width: 200px;
background: #1f2430;
padding: 20px 12px;
}
.sidebar-title {
color: #fff;
font-size: 15px;
margin: 0 0 16px;
padding-left: 8px;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 4px;
}
.sidebar-nav a {
display: block;
padding: 10px 14px;
border-radius: 6px;
color: #cfd3dc;
text-decoration: none;
font-size: 14px;
}
.sidebar-nav a.router-link-active,
.sidebar-nav a:hover {
background: #2c3340;
color: #fff;
}
.content-wrap {
flex: 1;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
height: 52px;
padding: 0 20px;
background: #fff;
border-bottom: 1px solid #e5e5ea;
}
.current-user {
color: #5c6470;
font-size: 14px;
}
.logout-link {
color: #d33c3c;
text-decoration: none;
font-size: 14px;
}
.content {
padding: 20px;
}
</style>

293
src/views/admin/ArticleEditorView.vue

@ -0,0 +1,293 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { articleAdminApi, articleApi, tagApi, mediaApi } from '@/api/client'
import type { ArticleDetail, Tag } from '@/generated/api'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => route.name === 'AdminArticleEdit')
const title = ref('')
const summary = ref('')
const content = ref('')
const coverImage = ref('')
const selectedTagIds = ref<number[]>([])
const tags = ref<Tag[]>([])
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const uploading = ref(false)
async function loadTags() {
try {
const resp = await tagApi.apiTagsGet()
tags.value = resp.data.data ?? []
} catch (e) {
console.error(e)
tags.value = []
}
}
function matchTagIds(detail: ArticleDetail): number[] {
const names = detail.tagNames ?? []
const idMap = new Map(tags.value.map((t) => [t.name, t.id]))
return names.map((n) => idMap.get(n)).filter((v): v is number => v !== undefined)
}
async function loadArticle() {
const slug = route.query.slug as string | undefined
if (!slug) return
loading.value = true
error.value = null
try {
const resp = await articleApi.apiArticlesSlugGet(slug)
const detail = resp.data.data
if (detail) {
title.value = detail.title
summary.value = detail.summary ?? ''
content.value = detail.content
coverImage.value = detail.coverImage ?? ''
selectedTagIds.value = matchTagIds(detail)
}
} catch (e) {
console.error(e)
error.value = '文章加载失败'
} finally {
loading.value = false
}
}
function toggleTag(id: number) {
const idx = selectedTagIds.value.indexOf(id)
if (idx >= 0) {
selectedTagIds.value.splice(idx, 1)
} else {
selectedTagIds.value.push(id)
}
}
async function onUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploading.value = true
error.value = null
try {
const resp = await mediaApi.apiMediaUploadPost(file, 'images')
const stored = resp.data.data
if (stored?.url) {
coverImage.value = stored.url
}
} catch (e) {
console.error(e)
error.value = '封面上传失败'
} finally {
uploading.value = false
input.value = ''
}
}
async function onSubmit() {
if (!title.value.trim() || !content.value.trim()) {
error.value = '标题和内容为必填项'
return
}
saving.value = true
error.value = null
try {
if (isEdit.value) {
const id = Number(route.params.id)
await articleAdminApi.apiAdminArticlesIdPut(id, {
title: title.value.trim(),
summary: summary.value.trim() || undefined,
content: content.value,
coverImage: coverImage.value || undefined,
tagIds: selectedTagIds.value
})
} else {
await articleAdminApi.apiAdminArticlesPost({
title: title.value.trim(),
summary: summary.value.trim() || undefined,
content: content.value,
coverImage: coverImage.value || undefined,
tagIds: selectedTagIds.value
})
}
router.push({ name: 'AdminArticleList' })
} catch (e) {
console.error(e)
error.value = '保存失败'
} finally {
saving.value = false
}
}
onMounted(() => {
loadTags()
if (isEdit.value) loadArticle()
})
</script>
<template>
<div>
<h1 class="page-title">{{ isEdit ? '编辑文章' : '新建文章' }}</h1>
<p v-if="error" class="error-text">{{ error }}</p>
<p v-if="loading" class="hint-text">加载中...</p>
<form v-else class="editor-form" @submit.prevent="onSubmit">
<label class="field-label" for="title">标题</label>
<input id="title" v-model="title" class="form-input" type="text" maxlength="200" />
<label class="field-label" for="summary">摘要</label>
<textarea id="summary" v-model="summary" class="form-textarea" rows="3" maxlength="500"></textarea>
<label class="field-label" for="content">内容</label>
<textarea id="content" v-model="content" class="form-textarea" rows="16"></textarea>
<label class="field-label" for="cover">封面图 URL</label>
<div class="cover-row">
<input id="cover" v-model="coverImage" class="form-input" type="text" />
<label class="upload-btn">
{{ uploading ? '上传中...' : '上传' }}
<input type="file" accept="image/*" class="upload-input" @change="onUpload" />
</label>
</div>
<img v-if="coverImage" :src="coverImage" class="cover-preview" alt="封面预览" />
<label class="field-label">标签</label>
<div class="tag-picker">
<label v-for="tag in tags" :key="tag.id" class="tag-option">
<input type="checkbox" :checked="selectedTagIds.includes(tag.id)" @change="toggleTag(tag.id)" />
{{ tag.name }}
</label>
<span v-if="tags.length === 0" class="hint-text">暂无标签</span>
</div>
<div class="form-actions">
<button class="btn-primary" type="submit" :disabled="saving">
{{ saving ? '保存中...' : '保存' }}
</button>
<router-link to="/admin/articles" class="btn-plain">取消</router-link>
</div>
</form>
</div>
</template>
<style scoped>
.page-title {
margin: 0 0 16px;
font-size: 20px;
}
.editor-form {
background: #fff;
border: 1px solid #e5e5ea;
border-radius: 8px;
padding: 20px;
}
.field-label {
display: block;
margin: 14px 0 6px;
font-size: 14px;
color: #5c6470;
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #d3d8e0;
border-radius: 6px;
font-size: 14px;
}
.form-textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #d3d8e0;
border-radius: 6px;
font-size: 14px;
resize: vertical;
font-family: inherit;
}
.cover-row {
display: flex;
gap: 10px;
}
.upload-btn {
flex-shrink: 0;
padding: 10px 16px;
background: #eef3ff;
color: #3b5bdb;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.upload-input {
display: none;
}
.cover-preview {
max-width: 220px;
margin-top: 10px;
border-radius: 6px;
border: 1px solid #e5e5ea;
}
.tag-picker {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 4px 0;
}
.tag-option {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 14px;
}
.form-actions {
margin-top: 20px;
display: flex;
gap: 12px;
align-items: center;
}
.btn-primary {
padding: 10px 22px;
border: none;
border-radius: 6px;
background: #3b82f6;
color: #fff;
cursor: pointer;
font-size: 14px;
}
.btn-primary:disabled {
opacity: 0.6;
}
.btn-plain {
padding: 9px 16px;
border: 1px solid #d3d8e0;
border-radius: 6px;
color: #5c6470;
text-decoration: none;
font-size: 14px;
}
.error-text {
color: #d33c3c;
}
.hint-text {
color: #8a93a3;
}
</style>

268
src/views/admin/ArticleListView.vue

@ -0,0 +1,268 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { articleApi, articleAdminApi } from '@/api/client'
import { ArticleStatus, type ArticleListItem } from '@/generated/api'
import Pagination from '@/components/Pagination.vue'
const router = useRouter()
const articles = ref<ArticleListItem[]>([])
const statusFilter = ref<ArticleStatus>(ArticleStatus.Published)
const page = ref(1)
const size = 10
const totalPages = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const statusOptions = [
{ label: '草稿', value: ArticleStatus.Draft },
{ label: '已发布', value: ArticleStatus.Published },
{ label: '已归档', value: ArticleStatus.Archived }
]
function statusLabel(s: ArticleStatus): string {
switch (s) {
case ArticleStatus.Draft:
return '草稿'
case ArticleStatus.Published:
return '已发布'
case ArticleStatus.Archived:
return '已归档'
}
}
function formatDate(iso?: string): string {
if (!iso) return '-'
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString('zh-CN', { hour12: false })
}
async function load() {
loading.value = true
error.value = null
try {
const resp = await articleApi.apiArticlesGet(page.value, size, statusFilter.value)
const data = resp.data.data
articles.value = (data?.list as ArticleListItem[]) ?? []
totalPages.value = data?.totalPages ?? 0
} catch (e) {
console.error(e)
error.value = '文章列表加载失败'
} finally {
loading.value = false
}
}
function onFilterChange() {
page.value = 1
load()
}
function onPageChange(p: number) {
page.value = p
load()
}
function onEdit(article: ArticleListItem) {
router.push({
name: 'AdminArticleEdit',
params: { id: String(article.id) },
query: { slug: article.slug }
})
}
function onDelete(article: ArticleListItem) {
if (!window.confirm(`确定删除文章「${article.title}」?`)) return
articleAdminApi
.apiAdminArticlesIdDelete(article.id)
.then(() => {
load()
})
.catch((e) => {
console.error(e)
window.alert('删除失败')
})
}
function onPublish(article: ArticleListItem) {
articleAdminApi
.apiAdminArticlesIdPublishPatch(article.id)
.then(() => {
load()
})
.catch((e) => {
console.error(e)
window.alert('发布失败')
})
}
function onArchive(article: ArticleListItem) {
articleAdminApi
.apiAdminArticlesIdArchivePatch(article.id)
.then(() => {
load()
})
.catch((e) => {
console.error(e)
window.alert('归档失败')
})
}
onMounted(load)
</script>
<template>
<div>
<div class="toolbar">
<select v-model="statusFilter" class="filter-select" @change="onFilterChange">
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<router-link to="/admin/articles/new" class="btn-primary">新建文章</router-link>
</div>
<p v-if="error" class="error-text">{{ error }}</p>
<p v-else-if="loading" class="hint-text">加载中...</p>
<p v-else-if="articles.length === 0" class="hint-text">暂无文章</p>
<table v-else class="article-table">
<thead>
<tr>
<th>标题</th>
<th>状态</th>
<th>发布时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="article in articles" :key="article.id">
<td>{{ article.title }}</td>
<td>
<span
class="status-badge"
:class="{
draft: article.status === ArticleStatus.Draft,
published: article.status === ArticleStatus.Published,
archived: article.status === ArticleStatus.Archived
}"
>
{{ statusLabel(article.status) }}
</span>
</td>
<td>{{ formatDate(article.publishedAt || article.createdAt) }}</td>
<td>
<div class="actions">
<button class="btn-link" @click="onEdit(article)">编辑</button>
<button v-if="article.status === ArticleStatus.Draft" class="btn-link" @click="onPublish(article)">
发布
</button>
<button v-if="article.status === ArticleStatus.Published" class="btn-link" @click="onArchive(article)">
归档
</button>
<button class="btn-link danger" @click="onDelete(article)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
<Pagination v-if="totalPages > 1" :page="page" :total-pages="totalPages" @change="onPageChange" />
</div>
</template>
<style scoped>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.filter-select {
padding: 8px 12px;
border: 1px solid #d3d8e0;
border-radius: 6px;
}
.btn-primary {
display: inline-block;
padding: 9px 16px;
background: #3b82f6;
color: #fff;
border-radius: 6px;
text-decoration: none;
font-size: 14px;
}
.article-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid #e5e5ea;
border-radius: 8px;
overflow: hidden;
}
.article-table th,
.article-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #eee;
font-size: 14px;
}
.article-table th {
background: #f7f8fa;
color: #5c6470;
}
.status-badge {
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
}
.status-badge.draft {
background: #e9ecef;
color: #6c757d;
}
.status-badge.published {
background: #d4edda;
color: #1e7e34;
}
.status-badge.archived {
background: #fff3cd;
color: #b26a00;
}
.actions {
display: flex;
gap: 10px;
}
.btn-link {
border: none;
background: none;
color: #3b82f6;
cursor: pointer;
font-size: 13px;
padding: 0;
}
.btn-link.danger {
color: #d33c3c;
}
.error-text {
color: #d33c3c;
}
.hint-text {
color: #8a93a3;
}
</style>

250
src/views/admin/TagListView.vue

@ -0,0 +1,250 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { tagApi, tagAdminApi } from '@/api/client'
import type { Tag } from '@/generated/api'
const tags = ref<Tag[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const showForm = ref(false)
const editingId = ref<number | null>(null)
const formName = ref('')
const formDescription = ref('')
function formatDate(iso?: string): string {
if (!iso) return '-'
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString('zh-CN', { hour12: false })
}
async function load() {
loading.value = true
error.value = null
try {
const resp = await tagApi.apiTagsGet()
tags.value = resp.data.data ?? []
} catch (e) {
console.error(e)
error.value = '标签列表加载失败'
} finally {
loading.value = false
}
}
function resetForm() {
editingId.value = null
formName.value = ''
formDescription.value = ''
showForm.value = false
}
function onNew() {
resetForm()
showForm.value = true
}
function onEdit(tag: Tag) {
editingId.value = tag.id
formName.value = tag.name
formDescription.value = tag.description ?? ''
showForm.value = true
}
async function onSubmit() {
const name = formName.value.trim()
if (!name) {
error.value = '标签名称为必填项'
return
}
try {
if (editingId.value !== null) {
await tagAdminApi.apiAdminTagsIdPut(editingId.value, {
name,
description: formDescription.value.trim() || undefined
})
} else {
await tagAdminApi.apiAdminTagsPost({
name,
description: formDescription.value.trim() || undefined
})
}
resetForm()
await load()
} catch (e) {
console.error(e)
error.value = '保存失败'
}
}
function onDelete(tag: Tag) {
if (!window.confirm(`确定删除标签「${tag.name}」?`)) return
tagAdminApi
.apiAdminTagsIdDelete(tag.id)
.then(() => {
load()
})
.catch((e) => {
console.error(e)
window.alert('删除失败')
})
}
onMounted(load)
</script>
<template>
<div>
<div class="toolbar">
<h1 class="page-title">标签管理</h1>
<button class="btn-primary" @click="onNew">新增标签</button>
</div>
<p v-if="error" class="error-text">{{ error }}</p>
<p v-else-if="loading" class="hint-text">加载中...</p>
<form v-if="showForm" class="tag-form" @submit.prevent="onSubmit">
<input v-model="formName" class="form-input" type="text" placeholder="标签名称" maxlength="50" />
<input
v-model="formDescription"
class="form-input"
type="text"
placeholder="描述(可选)"
maxlength="200"
/>
<button class="btn-primary" type="submit">保存</button>
<button class="btn-plain" type="button" @click="resetForm">取消</button>
</form>
<p v-else-if="tags.length === 0" class="hint-text">暂无标签</p>
<table v-else class="tag-table">
<thead>
<tr>
<th>名称</th>
<th>文章数</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="tag in tags" :key="tag.id">
<td>
<div>{{ tag.name }}</div>
<div v-if="tag.description" class="tag-desc">{{ tag.description }}</div>
</td>
<td>{{ tag.articleCount }}</td>
<td>{{ formatDate(tag.createdAt) }}</td>
<td>
<div class="actions">
<button class="btn-link" @click="onEdit(tag)">编辑</button>
<button class="btn-link danger" @click="onDelete(tag)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 20px;
}
.btn-primary {
padding: 9px 16px;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.tag-form {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.form-input {
padding: 9px 12px;
border: 1px solid #d3d8e0;
border-radius: 6px;
font-size: 14px;
flex: 1;
}
.btn-plain {
padding: 8px 16px;
border: 1px solid #d3d8e0;
border-radius: 6px;
background: #fff;
color: #5c6470;
cursor: pointer;
font-size: 14px;
}
.tag-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid #e5e5ea;
border-radius: 8px;
overflow: hidden;
}
.tag-table th,
.tag-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #eee;
font-size: 14px;
}
.tag-table th {
background: #f7f8fa;
color: #5c6470;
}
.tag-desc {
color: #8a93a3;
font-size: 12px;
}
.actions {
display: flex;
gap: 10px;
}
.btn-link {
border: none;
background: none;
color: #3b82f6;
cursor: pointer;
font-size: 13px;
padding: 0;
}
.btn-link.danger {
color: #d33c3c;
}
.error-text {
color: #d33c3c;
}
.hint-text {
color: #8a93a3;
}
</style>

24
tsconfig.json

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node", "vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "env.d.ts", "vite.config.ts"]
}

25
vite.config.ts

@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
},
'/uploads': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
Loading…
Cancel
Save