commit
3e78112545
28 changed files with 7555 additions and 0 deletions
-
5.gitignore
-
322checklist-v0.2.md
-
3env.d.ts
-
13index.html
-
820openapi.yaml
-
20openapitools.json
-
3566package-lock.json
-
33package.json
-
507prompt-agent2-frontend-v0.3.md
-
168scripts/acceptance/acc_234.cjs
-
259scripts/acceptance/acc_25.cjs
-
85src/App.vue
-
71src/api/client.ts
-
100src/components/ArticleCard.vue
-
52src/components/Pagination.vue
-
9src/main.ts
-
74src/router/index.ts
-
63src/stores/auth.ts
-
116src/views/ArticleDetailView.vue
-
65src/views/HomeView.vue
-
115src/views/LoginView.vue
-
119src/views/SearchView.vue
-
110src/views/admin/AdminLayout.vue
-
293src/views/admin/ArticleEditorView.vue
-
268src/views/admin/ArticleListView.vue
-
250src/views/admin/TagListView.vue
-
24tsconfig.json
-
25vite.config.ts
@ -0,0 +1,5 @@ |
|||||
|
node_modules |
||||
|
dist |
||||
|
*.log |
||||
|
.DS_Store |
||||
|
src/generated/api |
||||
@ -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" |
||||
|
``` |
||||
@ -0,0 +1,3 @@ |
|||||
|
/// <reference types="vite/client" />
|
||||
|
|
||||
|
interface Null {} |
||||
@ -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> |
||||
@ -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' |
||||
@ -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
File diff suppressed because it is too large
View File
@ -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 |
||||
|
} |
||||
|
} |
||||
@ -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 到远程? |
||||
@ -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) |
||||
|
})() |
||||
@ -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) |
||||
|
}) |
||||
@ -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> |
||||
@ -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) |
||||
@ -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> |
||||
@ -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> |
||||
@ -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') |
||||
@ -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 |
||||
@ -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 |
||||
|
} |
||||
|
}) |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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"] |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue