Compare commits

...

2 Commits

Author SHA1 Message Date
冯聪聪 fe99b024c9 gitignore 提交 2026-05-18 03:22:40 +08:00
冯聪聪 511890166e bug修复 2026-05-18 03:16:37 +08:00
91 changed files with 6561 additions and 77 deletions
+14
View File
@@ -1,3 +1,17 @@
# IDE
/.idea/
/.vscode/
# Java / Maven
*/target/
target/
# Frontend dependencies
/jog-frontend/node_modules/
# Logs
*.log
# OS files
.DS_Store
Thumbs.db
+478
View File
@@ -0,0 +1,478 @@
# Jog System 自动化测试计划
| 项目 | 内容 |
| :--- | :--- |
| 文档版本 | V1.0 |
| 生成日期 | 2026-05-18 |
| 适用范围 | Jog System 后端 Maven 多模块、Vue 3 前端、数据库迁移与部署验证 |
| 测试目标 | 建立可持续运行的自动化测试体系,覆盖核心业务、接口契约、安全控制、前端流程和发布质量门禁 |
---
## 1. 项目扫描结论
### 1.1 技术与模块
本项目为前后端分离的博客系统:
- 后端:Spring Boot 3.4.5、Java 21、Maven 多模块、Spring Security + JWT、MyBatis-Plus、MySQL、Redis、Flyway、SpringDoc OpenAPI。
- 前端:Vue 3.5、Vite 6、Element Plus、Pinia、Vue Router、Axios、Tiptap。
- 模块结构:
- `jog-common`:统一响应、分页模型、异常、枚举、限流注解等公共能力。
- `jog-framework`:安全配置、JWT、鉴权过滤器、XSS 过滤、限流切面、全局异常。
- `jog-module-system`:登录、注册、用户信息、管理员用户管理。
- `jog-module-blog`:文章、分类、标签、点赞、公开列表、后台文章管理。
- `jog-module-comment`:评论、回复、点赞、审核、后台评论管理。
- `jog-admin`:启动入口、应用配置、Flyway 数据库迁移。
- `jog-frontend`:前台浏览、登录注册、个人中心、文章编辑、后台管理页面。
### 1.2 当前测试基础
- 后端仅 `jog-admin` 显式引入 `spring-boot-starter-test`,其余业务模块未单独声明测试依赖。
- 项目源码中未发现自有 `src/test` 测试目录。
- 前端 `package.json` 当前仅包含 `dev``build``preview`,未配置单元测试、组件测试、E2E 测试或 lint 脚本。
- `node_modules` 已存在于工作区,属于依赖产物,不纳入源码测试范围。
### 1.3 扫描发现的测试重点
- 后端 Controller 实际路径为 `/api/auth``/api/article``/api/comment``/api/admin/**``/api/public/**` 等。
- 前端 API 封装中存在 `/api/public/user/login``/api/app/article/mine``/api/app/comment/**` 等路径,与当前后端 Controller 的部分路径不一致,应将“前后端接口契约一致性”列为首要自动化检查。
- `SecurityConfig``/api/admin/**` 需要 `ADMIN` 角色,`/api/app/**` 需要 `USER``ADMIN`,但部分业务 Controller 未挂在 `/api/app` 下,需通过安全回归测试确认实际权限边界。
- `application.yml` 中含真实数据库、Redis 地址和密码样式配置,测试环境应改用隔离配置与环境变量,禁止自动化测试直接访问生产或公网中间件。
- 需求文档与当前代码实现存在差异,例如验证码、邮箱验证、多端会话上限、评论三级嵌套、图片上传、关注、通知、标签管理等能力并未完整落地,自动化测试应区分“已实现回归”和“需求缺口验证”。
---
## 2. 测试目标与质量门禁
### 2.1 测试目标
- 保证登录、注册、文章发布、文章浏览、点赞、评论、后台审核、分类和用户管理等核心链路稳定。
- 保证 JWT 鉴权、角色权限、Token 刷新/登出、限流、XSS 防护、统一异常响应可持续回归。
- 保证前后端 API 路径、请求方法、请求参数、响应结构一致。
- 保证 Flyway 迁移脚本、实体映射、MyBatis-Plus 逻辑删除与分页查询可靠。
- 保证前端路由守卫、表单校验、Axios 拦截器和关键页面交互可回归。
### 2.2 推荐质量门禁
| 阶段 | 必跑项 | 通过标准 |
| :--- | :--- | :--- |
| 本地提交前 | 后端单元测试、前端构建、前端单元测试 | 全部通过,无新增严重告警 |
| 合并请求 | 后端集成测试、API 契约测试、前端组件测试、E2E 冒烟 | 全部通过,核心模块覆盖率达标 |
| 发布前 | 全量 E2E、迁移脚本验证、安全回归、性能冒烟 | P0/P1 缺陷为 0,P2 有明确处理计划 |
| 上线后 | 健康检查、核心链路巡检、日志错误扫描 | 首页/API/登录/发文/评论均可用 |
### 2.3 覆盖率目标
| 层级 | 建议目标 |
| :--- | :--- |
| 后端 Service 单元测试 | 行覆盖率 >= 80%,分支覆盖率 >= 70% |
| 后端 Controller/API 测试 | 核心接口 100% 覆盖,错误路径至少覆盖主要业务错误码 |
| 安全与鉴权测试 | 所有公开、登录、普通用户、管理员接口权限矩阵 100% 覆盖 |
| 前端工具/API/Store 单元测试 | 行覆盖率 >= 70% |
| 前端关键页面组件测试 | 登录、注册、首页、详情、编辑器、后台列表页面全覆盖 |
| E2E 测试 | 核心用户旅程 100% 覆盖,非核心后台功能冒烟覆盖 |
---
## 3. 测试分层策略
### 3.1 后端单元测试
推荐工具:JUnit 5、Mockito、AssertJ、Spring Security Test。
重点覆盖:
- `AuthServiceImpl`
- 邮箱不存在、密码错误、账号锁定、登录成功。
- 管理员登录的角色校验。
- 注册邮箱唯一性、密码加密、默认用户状态。
- Refresh Token 校验失败、用户不存在、刷新成功。
- 登出时 Token 加入黑名单。
- `UserServiceImpl`
- 当前用户信息查询。
- 用户列表分页、状态筛选。
- 管理员重置密码、锁定/解锁用户。
- `ArticleServiceImpl`
- 创建草稿/发布文章、发布时间设置。
- 更新文章、标签重建、作者权限校验。
- 公开列表分页、分类/标签/关键词过滤。
- 我的文章、后台文章列表。
- 回收站、恢复、作者删除、管理员删除。
- 浏览量递增、点赞/取消点赞、文章不存在。
- `CategoryServiceImpl`
- 分类创建、重名、更新、删除。
- 启用分类列表排序。
- `CommentServiceImpl`
- 创建根评论和回复。
- 父评论不存在、回复层级限制。
- 评论列表根评论与回复排序。
- 作者删除权限、管理员审核、点赞/取消点赞。
- `RateLimitAspect``JwtTokenProvider``TokenBlacklistService``XssFilter`
- 限流 Key 与过期时间。
- Token 生成、解析、过期、非法签名。
- 黑名单命中。
- 常见 XSS payload 过滤。
### 3.2 后端集成测试
推荐工具:Spring Boot Test、MockMvc、TestcontainersMySQL、Redis)、Flyway、spring-security-test。
重点覆盖:
- 使用 Testcontainers 启动隔离 MySQL/Redis,运行 Flyway 迁移。
- 验证 `V1__init_schema.sql``V2__init_data.sql` 可重复初始化干净环境。
- 验证实体 `@TableName` 与实际表名一致,重点关注 `sys_user`/`blog_article` 等表与代码实体映射。
- 验证 MyBatis-Plus 分页插件、逻辑删除字段、唯一索引冲突、事务回滚。
- 使用 MockMvc 对 Controller 执行端到端接口测试,断言 HTTP 状态、统一响应结构、业务错误码和数据库副作用。
### 3.3 API 契约测试
推荐工具:SpringDoc OpenAPI 导出、Schemathesis 或 Dredd、Postman/Newman、或自定义 OpenAPI diff。
契约测试必须覆盖:
- 后端 OpenAPI 与前端 `src/api/*.js` 中路径、方法、参数一致。
- 统一响应结构 `ApiResult` 字段稳定:`code``message``data`
- 分页响应 `PageResult` 字段稳定:列表、总数、页码、页大小。
- 401、403、429、业务异常响应格式一致。
- Token 刷新接口路径一致。当前前端调用 `/api/public/refresh-token`,后端扫描到的是 `/api/auth/refresh`,需优先纳入失败用例。
### 3.4 前端单元与组件测试
推荐工具:Vitest、Vue Test Utils、jsdom、MSW、Testing Library。
重点覆盖:
- `utils/request.js`
- 请求自动附加 `Authorization`
- 业务 `code !== 200` 的错误处理。
- 401 清理本地 Token 并跳转登录。
- 403、429、网络错误提示。
- Token 刷新失败/成功后的重试逻辑。
- `router/index.js`
- 未登录访问 `/app/**``/admin/**` 跳转登录。
- 登录后路由放行。
- 页面标题设置。
- 管理员路由需要补充前端角色校验测试;当前代码只检查 token,未检查 `requiresAdmin`
- 页面组件
- `Login.vue``Register.vue`:表单校验、提交状态、错误提示、登录后跳转。
- `Home.vue`:列表加载、分页、分类筛选、空状态。
- `ArticleDetail.vue`:详情渲染、评论列表、点赞、评论提交。
- `ArticleEditor.vue`:标题/内容校验、草稿/发布、分类标签选择、编辑回填。
- 后台 `Users.vue``Articles.vue``Comments.vue``Categories.vue`:列表、筛选、分页、操作确认。
### 3.5 E2E 测试
推荐工具:Playwright。
建议用户旅程:
1. 访客打开首页,查看公开文章列表,进入文章详情。
2. 新用户注册、登录、进入个人控制台。
3. 用户创建草稿、发布文章、编辑文章、移入回收站、恢复文章、删除文章。
4. 另一用户浏览文章、点赞文章、发表评论、回复评论、取消点赞。
5. 管理员登录后台,查看用户列表、重置密码、锁定用户。
6. 管理员查看文章列表、删除违规文章。
7. 管理员查看评论列表、审核评论、删除评论。
8. 分类创建、编辑、删除后,前台筛选结果同步变化。
9. 未登录用户访问编辑页会被重定向到登录页,并在登录后返回原目标页。
10. 普通用户尝试访问后台应被拒绝。
### 3.6 安全自动化测试
重点用例:
- JWT
- 缺失 Token、伪造 Token、过期 Token、黑名单 Token。
- 用户 Token 访问管理员接口返回 403。
- 管理员 Token 访问后台接口成功。
- 限流
- 登录 60 秒内超过 5 次被限制。
- 注册 1 小时内超过 3 次被限制。
- 限流 Key 应区分 IP/用户/接口,避免全局误伤。
- XSS
- 文章和评论中提交 `<script>`、事件属性、`javascript:` 链接。
- 富文本合法标签不应被过度清洗。
- CORS/CSRF
- CORS 允许策略在生产环境应收敛,自动化检查不允许 `*` 与凭证同时进入生产配置。
- CSRF 已关闭,需确认仅用于 JWT 无状态 API。
- 敏感信息
- 扫描配置文件中数据库密码、Redis 密码、JWT 密钥、邮箱密码。
- 自动化测试禁止连接 `application.yml` 中公网数据库和 Redis。
### 3.7 性能与可靠性测试
推荐工具:k6、JMeter 或 Gatling。
冒烟指标:
- 首页文章列表 P95 < 500ms。
- 文章详情 P95 < 500ms。
- 登录接口 P95 < 800ms。
- 创建文章 P95 < 1000ms。
- 评论列表 P95 < 500ms。
- 50 并发用户下错误率 < 1%。
专项场景:
- 高频浏览文章详情时浏览量更新是否产生锁竞争或丢失更新。
- 点赞/取消点赞并发操作是否出现负数计数或唯一索引冲突。
- 评论列表在大量回复下的 N+1 查询问题。
- 后台文章/评论/用户分页在万级数据下的响应时间。
---
## 4. 测试环境规划
### 4.1 环境分层
| 环境 | 用途 | 数据策略 |
| :--- | :--- | :--- |
| Local | 开发者快速回归 | H2 或 Testcontainers,测试结束自动销毁 |
| CI | 合并请求质量门禁 | Testcontainers MySQL/Redis,固定种子数据 |
| Staging | 发布前全量验证 | 与生产拓扑接近,脱敏数据 |
| Prod Smoke | 上线后巡检 | 只执行只读或专用测试账号的轻量用例 |
### 4.2 测试配置要求
- 新增 `application-test.yml`,数据库和 Redis 全部来自 Testcontainers 或本地隔离实例。
- Flyway 在测试环境启用,保证迁移脚本真实可用。
- 禁止测试代码读取公网数据库地址、真实 Redis 密码、真实 SMTP。
- 测试账号和测试文章使用固定前缀,例如 `auto_test_`,便于清理。
- 前端 E2E 使用独立 `.env.test`,指向测试后端。
---
## 5. 测试数据设计
### 5.1 基础数据
| 类型 | 数据 |
| :--- | :--- |
| 管理员 | `admin@example.com`,角色 ADMIN |
| 普通用户 A | `user_a@example.com`,角色 USER |
| 普通用户 B | `user_b@example.com`,角色 USER |
| 锁定用户 | `locked@example.com`,状态 LOCKED |
| 分类 | 技术、生活、公告 |
| 标签 | Java、Vue、测试、随笔 |
| 文章 | 草稿、公开已发布、私密、回收站、已删除 |
| 评论 | 根评论、一级回复、待审核、已屏蔽、已删除 |
### 5.2 边界数据
- 标题空、标题 200 字、标题 201 字。
- 密码长度 5、6、20、21。
- 评论内容空、1000 字、超过 1000 字。
- 分页 `page=0``page=1``pageSize=1``pageSize=100``pageSize=101`
- 不存在的用户 ID、文章 ID、评论 ID、分类 ID。
- 重复邮箱、重复分类名、重复点赞。
- 富文本中包含合法 HTML、XSS payload、超长内容。
---
## 6. 自动化用例清单
### 6.1 认证与用户
| 编号 | 类型 | 场景 | 预期 |
| :--- | :--- | :--- | :--- |
| AUTH-001 | 单元/API | 正确邮箱密码登录 | 返回 accessToken、refreshToken、用户信息 |
| AUTH-002 | 单元/API | 邮箱不存在 | 返回用户不存在错误,记录失败日志 |
| AUTH-003 | 单元/API | 密码错误 | 返回密码错误,记录失败日志 |
| AUTH-004 | 单元/API | 锁定用户登录 | 返回账号锁定 |
| AUTH-005 | 单元/API | 普通用户调用管理员登录 | 返回权限不足 |
| AUTH-006 | API | 注册新邮箱 | 用户入库,密码为 BCrypt |
| AUTH-007 | API | 重复邮箱注册 | 返回邮箱已存在 |
| AUTH-008 | API | 刷新 Token | 返回新的 accessToken 和 refreshToken |
| AUTH-009 | API | 非法 refreshToken | 返回 Token 无效 |
| AUTH-010 | API | 登出后使用旧 Token | 被拒绝或命中黑名单 |
| USER-001 | API | 获取当前用户信息 | 返回登录用户资料 |
| USER-002 | API | 管理员分页查询用户 | 返回分页结构 |
| USER-003 | API | 管理员重置密码 | 新密码可登录,旧密码失效 |
| USER-004 | API | 管理员锁定用户 | 被锁定用户无法登录 |
### 6.2 文章与分类标签
| 编号 | 类型 | 场景 | 预期 |
| :--- | :--- | :--- | :--- |
| BLOG-001 | 单元/API | 创建草稿 | 状态为草稿,无发布时间 |
| BLOG-002 | 单元/API | 发布文章 | 状态为已发布,生成发布时间 |
| BLOG-003 | 单元/API | 更新文章标签 | 旧关联删除,新关联创建 |
| BLOG-004 | API | 非作者更新文章 | 返回权限不足 |
| BLOG-005 | API | 公开列表仅显示公开已发布 | 草稿、私密、回收站不出现 |
| BLOG-006 | API | 按分类筛选 | 只返回该分类文章 |
| BLOG-007 | API | 按标签筛选 | 只返回该标签文章 |
| BLOG-008 | API | 关键词搜索 | 标题或摘要匹配 |
| BLOG-009 | API | 我的文章列表 | 只返回当前用户文章 |
| BLOG-010 | API | 移入回收站 | 状态变为回收站 |
| BLOG-011 | API | 恢复文章 | 状态变为草稿 |
| BLOG-012 | API | 点赞文章 | 点赞记录创建,计数 +1 |
| BLOG-013 | API | 取消点赞 | 点赞记录删除,计数 -1 且不为负 |
| BLOG-014 | API | 后台文章列表 | 管理员可按状态/分类/关键词筛选 |
| CAT-001 | API | 创建分类 | 分类入库,可在公开列表读取 |
| CAT-002 | API | 重名分类 | 返回唯一性错误 |
| CAT-003 | API | 更新分类 | 名称、描述、排序生效 |
| CAT-004 | API | 删除分类 | 分类不可再查询或被逻辑处理 |
### 6.3 评论
| 编号 | 类型 | 场景 | 预期 |
| :--- | :--- | :--- | :--- |
| CMT-001 | 单元/API | 创建根评论 | 评论状态为通过,文章 ID 正确 |
| CMT-002 | 单元/API | 回复根评论 | parentId 正确 |
| CMT-003 | 单元/API | 回复二级评论 | 返回层级限制错误 |
| CMT-004 | API | 非作者删除评论 | 返回权限不足 |
| CMT-005 | API | 作者删除评论 | 评论被删除 |
| CMT-006 | API | 评论列表 | 根评论倒序,回复正序 |
| CMT-007 | API | 点赞评论 | 点赞记录创建,计数 +1 |
| CMT-008 | API | 取消点赞评论 | 点赞记录删除,计数 -1 且不为负 |
| CMT-009 | API | 管理员分页查询评论 | 返回分页结构 |
| CMT-010 | API | 管理员审核评论 | 评论状态更新 |
| CMT-011 | API | 管理员删除评论 | 评论不可见 |
### 6.4 前端页面与交互
| 编号 | 类型 | 场景 | 预期 |
| :--- | :--- | :--- | :--- |
| FE-001 | 单元 | Axios 自动附加 Token | 请求头包含 Bearer Token |
| FE-002 | 单元 | 业务响应 code 非 200 | 展示错误并 reject |
| FE-003 | 单元 | 401 响应 | 清理本地登录态并跳转登录 |
| FE-004 | 单元 | 未登录访问 `/app/dashboard` | 跳转登录并携带 redirect |
| FE-005 | 单元 | 未登录访问 `/admin` | 跳转登录 |
| FE-006 | 组件 | 登录表单提交成功 | 保存 Token 并跳转 |
| FE-007 | 组件 | 注册表单校验失败 | 阻止提交并提示 |
| FE-008 | 组件 | 首页列表加载 | 渲染文章卡片、分页和空状态 |
| FE-009 | 组件 | 文章详情加载 | 渲染正文、点赞、评论 |
| FE-010 | 组件 | 编辑器发布文章 | 调用创建/更新 API |
| FE-011 | 组件 | 后台列表操作 | 筛选、分页、删除/审核确认 |
| FE-012 | 契约 | 前端 API 路径与后端 OpenAPI 对比 | 不允许出现未实现路径 |
### 6.5 安全与异常
| 编号 | 类型 | 场景 | 预期 |
| :--- | :--- | :--- | :--- |
| SEC-001 | API | 无 Token 访问需登录接口 | 返回 401 |
| SEC-002 | API | 普通用户访问 `/api/admin/**` | 返回 403 |
| SEC-003 | API | 管理员访问后台接口 | 成功 |
| SEC-004 | API | 伪造 JWT | 返回 401 |
| SEC-005 | API | 黑名单 JWT | 返回 401 |
| SEC-006 | API | 登录限流 | 超限返回 429 或业务限流错误 |
| SEC-007 | API | 注册限流 | 超限返回 429 或业务限流错误 |
| SEC-008 | API | 文章提交 XSS | 脚本被过滤或拒绝 |
| SEC-009 | API | 评论提交 XSS | 脚本被过滤或拒绝 |
| SEC-010 | 静态扫描 | 配置文件敏感信息 | CI 阻断硬编码密码和密钥 |
---
## 7. CI/CD 自动化建议
### 7.1 推荐脚本
后端建议新增:
```bash
mvn -B clean verify
```
前端建议新增:
```bash
npm ci
npm run test:unit
npm run test:component
npm run build
npm run test:e2e
```
### 7.2 推荐新增依赖与脚本
后端:
- 在父 POM 统一管理测试依赖:JUnit 5、Mockito、AssertJ、Testcontainers MySQL/Redis、Spring Security Test、JaCoCo。
- 将测试依赖下沉到需要测试的模块,避免只有启动模块能写测试。
- 配置 Maven Surefire/Failsafe,区分单元测试和集成测试。
前端:
- 新增 Vitest、Vue Test Utils、jsdom、MSW、Playwright。
-`package.json` 中新增:
- `test:unit`
- `test:component`
- `test:e2e`
- `test:e2e:ui`
- `coverage`
### 7.3 Pipeline 阶段
1. 依赖安装与缓存。
2. 后端编译与单元测试。
3. 后端集成测试,启动 MySQL/Redis Testcontainers。
4. 生成 OpenAPI 文档。
5. 前端 API 封装与 OpenAPI 契约对比。
6. 前端单元/组件测试。
7. 前端生产构建。
8. 启动完整应用,运行 Playwright E2E 冒烟。
9. 上传测试报告、覆盖率报告和 E2E 截图/视频。
---
## 8. 缺陷分级与处理策略
| 等级 | 定义 | 示例 | 处理要求 |
| :--- | :--- | :--- | :--- |
| P0 | 系统不可用或核心数据损坏 | 无法启动、登录全失败、发文数据丢失 | 立即阻断发布 |
| P1 | 核心业务不可用或严重安全问题 | 普通用户可访问后台、Token 绕过、评论 XSS | 阻断合并/发布 |
| P2 | 重要功能异常但有规避方式 | 筛选错误、分页异常、后台单项操作失败 | 发布前修复或明确延期 |
| P3 | 轻微体验或边界问题 | 文案不准、空状态异常、非核心兼容问题 | 排入迭代 |
---
## 9. 当前优先落地路线
### 第一阶段:补齐基础测试设施
- 后端新增 `src/test` 目录、`application-test.yml`、JUnit/Mockito/Testcontainers/JaCoCo。
- 前端新增 Vitest、Vue Test Utils、MSW、Playwright。
- 建立 CI 基础命令:后端 `mvn verify`,前端 `npm run build` + 单元测试。
### 第二阶段:先保护最容易出事故的链路
- 认证:登录、注册、刷新、登出、管理员权限。
- 契约:前端 API 路径和后端 Controller/OpenAPI 对比。
- 文章:创建、发布、列表、详情、更新、删除、点赞。
- 评论:创建、列表、回复、删除、审核、点赞。
### 第三阶段:增强安全与发布信心
- XSS、限流、JWT 黑名单、CORS、敏感信息扫描。
- Flyway 迁移全量验证。
- Playwright 覆盖访客、普通用户、管理员三类角色端到端流程。
- k6/JMeter 做核心接口性能冒烟。
---
## 10. 风险清单
| 风险 | 影响 | 建议 |
| :--- | :--- | :--- |
| 前后端 API 路径不一致 | 前端页面调用失败 | 优先建立契约测试,并统一 `/api/auth``/api/article``/api/comment``/api/app/**` 路由规范 |
| 生产/公网中间件配置在默认配置中 | 自动化测试可能误连真实服务 | 建立 `application-test.yml`CI 强制覆盖数据源 |
| 自有测试目录缺失 | 回归依赖人工验证 | 先补 Service 单元测试和 Controller 集成测试 |
| 需求文档与代码实现差异 | 验收标准不清 | 将未实现需求标为 pending/xfail,并建立需求追踪 |
| 前端缺少测试脚本 | UI 回归成本高 | 引入 Vitest 和 Playwright,先覆盖核心旅程 |
| 点赞计数并发更新 | 可能出现计数错乱或负数 | 加并发集成测试,必要时改为原子 SQL 更新 |
| 评论和文章富文本安全 | XSS 风险 | 加 payload 回归测试,确认过滤链路覆盖请求体 |
---
## 11. 验收标准
自动化测试计划落地后,应满足:
- 每次合并请求自动运行后端单元测试、集成测试、前端单元/组件测试和构建。
- 每次发布前自动运行核心 E2E、数据库迁移验证和安全回归。
- 测试报告可追溯到用例、模块、提交和失败截图/日志。
- 新增核心业务功能必须同步新增或更新自动化用例。
- P0/P1 自动化失败不得合并,不得发布。
+246
View File
@@ -0,0 +1,246 @@
# Jog System 自动化测试 Bug 清单
| 项目 | 内容 |
| :--- | :--- |
| 生成日期 | 2026-05-18 |
| 测试依据 | `doc/jog-system-automated-test-plan.md` |
| 测试范围 | 后端 Maven 编译/测试、前端 Vite 构建、前后端 API 契约静态检查、数据库迁移/实体映射静态检查、安全配置静态检查 |
---
## 1. 测试执行摘要
| 测试项 | 命令/方式 | 结果 |
| :--- | :--- | :--- |
| 后端编译与测试 | `mvn -B test` | 通过编译,但所有模块均显示 `No tests to run`,未执行任何自有测试 |
| 前端构建(旧 Node | `npm run build` | 失败,Node `v16.20.2` 下 Vite 启动时报 `crypto$2.getRandomValues is not a function` |
| 前端构建(已切换 Node | `npm run build` | 通过,当前 Node `v24.15.0`、npm `11.12.1`,构建产物已输出到 `jog-admin/src/main/resources/static` |
| API 契约检查 | 对比 `jog-frontend/src/api/*.js` 与后端 Controller | 发现多处路径和 HTTP 方法不一致 |
| 数据库映射检查 | 对比实体 `@TableName` 与 Flyway 建表脚本 | 发现表名、字段名大面积不一致 |
| 安全配置检查 | 对比 JWT 角色生成、SecurityConfig、Controller 路径 | 发现管理员角色无法匹配、用户接口未被鉴权规则保护 |
---
## 1.1 本轮修复记录
| Bug | 当前状态 | 修复摘要 |
| :--- | :--- | :--- |
| BUG-003 | 已修复,待数据库集成验证 | 实体 `@TableName` 已对齐 `sys_*` / `blog_*` 表名 |
| BUG-004 | 已修复,待数据库集成验证 | 对差异字段补充 `@TableField`,覆盖用户类型、会话 token/过期时间、登录结果、评论回复目标等 |
| BUG-005 | 已修复 | JWT access token 角色改为 `ADMIN` / `USER`,过滤器兼容旧数字角色 |
| BUG-006 | 已修复 | 用户侧 Controller 已统一到 `/api/app/**`,并由 SecurityConfig 保护 |
| BUG-007 | 已修复 | 认证接口已对齐前端 `/api/public/user/login``/api/public/admin/login``/api/public/user/register``/api/public/refresh-token` |
| BUG-008 | 已修复 | 文章、评论、用户接口已对齐前端 `/api/app/**` 路径 |
| BUG-009 | 已修复 | 回收站、恢复、评论审核、用户重置密码、用户状态更新的 HTTP 方法已对齐前端 |
| BUG-010 | 部分修复 | 已补标签列表/创建、验证码占位接口、关注/取关接口;验证码当前仅返回占位数据,不做真实校验 |
| BUG-011 | 已修复 | 前端路由已根据 `requiresAdmin` 校验本地用户角色 |
| BUG-012 | 已修复 | `V2__init_data.sql` 已移除 `USE blog_db` |
| BUG-013 | 已修复 | 文章详情标签改为返回结构化 `Tag` 对象,匹配前端 `id/name` 使用方式 |
---
## 2. Bug 清单
### BUG-001 前端构建在旧 Node 版本下无法启动
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P3 |
| 状态 | 已验证解决:Node 已切换至 `v24.15.0` 后构建通过 |
| 位置 | `jog-frontend/package.json`、本机 Node 环境 |
| 复现 | 在 `jog-frontend` 执行 `npm run build` |
| 实际结果 | `TypeError: crypto$2.getRandomValues is not a function` |
| 期望结果 | 前端可完成生产构建 |
| 证据 | 旧 Node 版本为 `v16.20.2`,切换到 `v24.15.0``npm run build` 成功 |
| 建议 | 在 README/部署文档和 `package.json engines` 中声明 Node >= 20;本地和 CI 统一 Node 版本 |
### BUG-002 使用新版 Node 后前端构建失败
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P3 |
| 状态 | 无法复现/已恢复:用户切换 Node 后 `npm run build` 通过 |
| 位置 | `jog-frontend/vite.config.js:29` |
| 复现 | 使用 Node `v24.14.0` 执行 `node node_modules/vite/bin/vite.js build` |
| 实际结果 | `[vite:build-html] The "fileName" or "name" properties of emitted chunks and assets must be strings that are neither absolute nor relative paths, received "E:/person/Jog/jog-frontend/index.html"` |
| 期望结果 | 构建产物成功输出到后端静态资源目录 |
| 最新验证 | 当前 Node `v24.15.0`、npm `11.12.1` 下执行 `npm run build` 成功 |
| 建议 | 保留 CI 构建验证;若再次出现该错误,再检查 Node/npm/Vite 解析路径差异 |
### BUG-002A 前端构建存在低优先级告警
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P3 |
| 状态 | 待优化 |
| 位置 | `jog-frontend` 构建配置与本机 npm 配置 |
| 实际结果 | 构建成功,但 npm 输出多条 Unknown user config 警告;Vite 输出主 chunk 超过 500 kB 的警告 |
| 期望结果 | CI 构建日志干净,首屏资源大小可控 |
| 影响 | 暂不阻断发布,但会降低构建日志可读性,主包偏大可能影响首屏性能 |
| 建议 | 清理过期 npm 配置;为 Element Plus、Tiptap 等依赖配置合理分包或接受当前 warning 并设置 chunk 预算 |
### BUG-003 实体表名与 Flyway 建表脚本不一致,运行时会查不到表
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P0 |
| 状态 | 待修复 |
| 位置 | `jog-module-system/src/main/java/.../entity/*.java``jog-module-blog/src/main/java/.../entity/*.java``jog-module-comment/src/main/java/.../entity/*.java``jog-admin/src/main/resources/db/migration/V1__init_schema.sql` |
| 复现 | 启用 Flyway 初始化后调用任意 Mapper 查询 |
| 实际结果 | 实体使用 `@TableName("user")``article``comment``category` 等;SQL 创建的是 `sys_user``blog_article``blog_comment``blog_category` 等 |
| 期望结果 | 实体表名与数据库真实表名一致 |
| 影响 | 登录、注册、文章、评论、分类等数据库操作都会失败 |
| 建议 | 统一命名策略:要么实体改为 `sys_*`/`blog_*`,要么迁移脚本改为无前缀表;推荐按迁移脚本保留前缀并修正实体 |
### BUG-004 实体字段与数据库字段不一致,多个核心写入会失败或数据错列
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P0 |
| 状态 | 待修复 |
| 位置 | `User.java``UserSession.java``LoginLog.java``Comment.java``V1__init_schema.sql` |
| 实际结果 | 例如 `User.type` 对应代码字段,但 SQL 是 `user_type``UserSession.refreshToken/expiresAt` 对应 SQL 的 `token/expire_time``LoginLog.email/status/userAgent` 对应 SQL 的 `username/result` 且 SQL 无 `user_agent``Comment.replyToId` 对应 SQL 的 `reply_to_user_id` |
| 期望结果 | 字段名可通过 MyBatis-Plus 正确映射 |
| 影响 | 登录记录、会话保存、用户角色、评论回复等核心链路会失败 |
| 建议 | 为差异字段添加 `@TableField("实际字段名")`,或统一 SQL 字段命名;同时增加 Mapper 集成测试覆盖插入/查询 |
### BUG-005 JWT 角色值与 Spring Security 角色规则不匹配,管理员接口会 403
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P0 |
| 状态 | 待修复 |
| 位置 | `jog-framework/src/main/java/fun/nojava/framework/security/JwtTokenProvider.java:18``JwtAuthenticationFilter.java:39``SecurityConfig.java:51` |
| 实际结果 | Token 中 `role` 写入的是数字字符串 `0`/`1`,过滤器生成 `ROLE_0`/`ROLE_1`;但安全配置要求 `/api/admin/**` 具备 `ROLE_ADMIN` |
| 期望结果 | 管理员 Token 应匹配 `ROLE_ADMIN`,普通用户 Token 应匹配 `ROLE_USER` |
| 影响 | 管理后台 API 无法正常访问;普通用户权限判断也不稳定 |
| 建议 | Token 中存储 `ADMIN`/`USER`,或在过滤器中将 `UserType` code 转换为角色名 |
### BUG-006 用户文章/评论接口未被 Spring Security 鉴权规则保护
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P0 |
| 状态 | 待修复 |
| 位置 | `SecurityConfig.java:52``ArticleController.java:16``CommentController.java:19``UserController.java:15` |
| 实际结果 | 安全配置只保护 `/api/app/**`,但后端用户业务接口实际是 `/api/article``/api/comment``/api/user`;这些路径最终落入 `anyRequest().permitAll()` |
| 期望结果 | 创建/更新/删除文章、发表评论、删除评论、获取当前用户等接口必须要求登录 |
| 影响 | 未登录用户可能访问写接口,`@AuthenticationPrincipal Long userId` 为空后还可能导致脏数据或空指针 |
| 建议 | 统一后端用户接口前缀为 `/api/app/**`,或在 `SecurityConfig` 中显式保护 `/api/article/**``/api/comment/**``/api/user/**` |
### BUG-007 前后端认证接口路径完全不一致
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P0 |
| 状态 | 待修复 |
| 位置 | `jog-frontend/src/api/auth.js:4``auth.js:8``auth.js:12``auth.js:20``jog-module-system/src/main/java/.../AuthController.java:18` |
| 实际结果 | 前端调用 `/api/public/admin/login``/api/public/user/login``/api/public/user/register``/api/public/refresh-token`;后端提供 `/api/auth/admin/login``/api/auth/login``/api/auth/register``/api/auth/refresh` |
| 期望结果 | 前端 API 封装与后端 Controller 路径一致 |
| 影响 | 登录、注册、Token 刷新全部 404,系统无法完成认证 |
| 建议 | 建立 OpenAPI 契约检查;统一使用 `/api/auth/**` 或调整后端兼容前端路径 |
### BUG-008 前后端文章/评论/用户接口路径不一致
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P0 |
| 状态 | 待修复 |
| 位置 | `jog-frontend/src/api/article.js:12``article.js:24``comment.js:4``auth.js:24`、后端 `ArticleController.java:16``CommentController.java:19``UserController.java:15` |
| 实际结果 | 前端使用 `/api/app/article``/api/app/article/mine``/api/app/comment``/api/app/user/me`;后端实际是 `/api/article``/api/article/my``/api/comment``/api/user/info` |
| 期望结果 | 前后端路径一致 |
| 影响 | 登录后的发文、我的文章、评论、当前用户信息等核心页面无法工作 |
| 建议 | 统一用户端路径前缀和资源命名,建议后端对齐 `/api/app/**` 或前端全部改为后端当前路径 |
### BUG-009 多个前端请求 HTTP 方法与后端不一致
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P1 |
| 状态 | 待修复 |
| 位置 | `jog-frontend/src/api/article.js:28``article.js:32``comment.js:24``admin.js:8``admin.js:12`;后端对应 Controller |
| 实际结果 | 前端对回收站/恢复/审核/重置密码/状态更新使用 `PUT`;后端分别使用 `POST` |
| 期望结果 | 同一接口请求方法一致 |
| 影响 | 即使路径修正,仍会返回 405 或无法命中接口 |
| 建议 | 按 REST 风格统一:状态变更可用 `PUT/PATCH`,动作型接口可用 `POST`,但前后端必须一致 |
### BUG-010 前端调用了后端未实现的接口
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P1 |
| 状态 | 待修复 |
| 位置 | `jog-frontend/src/api/auth.js:16``auth.js:36``auth.js:40``article.js:44``article.js:60` |
| 实际结果 | 前端调用验证码 `/api/public/captcha`、关注/取关、公开标签列表、管理员创建标签;后端未发现对应 Controller 映射 |
| 期望结果 | 前端只调用已实现接口,或后端补齐对应接口 |
| 影响 | 登录页验证码、关注功能、文章编辑标签选择、后台标签管理会失败 |
| 建议 | 根据需求决定补实现还是移除前端入口;未实现需求应在自动化测试中标记为 pending |
### BUG-011 前端管理员路由只校验 Token,不校验管理员角色
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P1 |
| 状态 | 待修复 |
| 位置 | `jog-frontend/src/router/index.js` |
| 实际结果 | 路由定义有 `requiresAdmin`,但 `beforeEach` 只检查 `requiresAuth` 和 token,不检查用户类型 |
| 期望结果 | 普通用户访问 `/admin/**` 应在前端被拒绝或重定向 |
| 影响 | 普通用户可进入后台页面,虽然后端也应拦截,但会造成越权界面暴露和误导 |
| 建议 | 从 `user_info` 或 Store 中读取用户类型,补充 `requiresAdmin` 判断;后端仍保留最终权限校验 |
### BUG-012 V2 初始化脚本写死 `USE blog_db`,与当前配置不一致
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P1 |
| 状态 | 待修复 |
| 位置 | `jog-admin/src/main/resources/db/migration/V2__init_data.sql:4``application.yml` |
| 实际结果 | 初始化脚本写死 `USE blog_db;`,当前应用数据源库名不是同一个命名 |
| 期望结果 | Flyway 迁移脚本不应写死切库,应该使用当前连接的 schema |
| 影响 | 首次初始化可能写入错误数据库或直接失败 |
| 建议 | 删除 `USE blog_db;`,由 JDBC URL 指定 schemaCI 中增加 Flyway clean/migrate 测试 |
### BUG-013 文章详情返回的标签结构与前端期望不一致
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P2 |
| 状态 | 待修复 |
| 位置 | `ArticleServiceImpl.java:283``ArticleVO.java``jog-frontend/src/views/ArticleDetail.vue:13``ArticleEditor.vue:100` |
| 实际结果 | 后端 `ArticleVO.tags``List<String>`;前端按 `tag.id``tag.name` 使用 |
| 期望结果 | 标签返回结构与前端一致 |
| 影响 | 文章详情标签显示异常,编辑文章时标签无法正确回填 |
| 建议 | 后端返回 `List<TagVO>`,或前端改为字符串数组渲染;编辑器需要 ID 时推荐返回结构化对象 |
### BUG-014 限流 Key 未包含 IP 或用户维度,所有用户共享同一限流桶
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P2 |
| 状态 | 待修复 |
| 位置 | `jog-framework/src/main/java/fun/nojava/framework/aspect/RateLimitAspect.java:41` |
| 实际结果 | Redis Key 仅为 `rate_limit:` + 注解 key,例如所有用户共享 `rate_limit:login` |
| 期望结果 | 登录、注册等限流至少按 IP 或账号维度隔离 |
| 影响 | 一个用户频繁请求会误伤所有用户;也无法实现需求中的“同一 IP”限流 |
| 建议 | 将 IP、用户 ID、请求路径或账号加入限流 Key;补充并发/限流集成测试 |
### BUG-015 当前项目没有自有自动化测试,质量门禁失效
| 字段 | 内容 |
| :--- | :--- |
| 严重级别 | P2 |
| 状态 | 待补齐 |
| 位置 | 全项目 |
| 实际结果 | `mvn -B test` 成功但所有模块均显示 `No tests to run`;前端没有 `test:unit``test:e2e` 等脚本 |
| 期望结果 | 后端至少有 Service 单元测试、Controller 集成测试;前端至少有组件测试和 E2E 冒烟 |
| 影响 | 构建通过不代表功能可用,无法防止上述契约/安全/数据库缺陷回归 |
| 建议 | 按测试计划第一阶段补齐 JUnit/Mockito/Testcontainers/Vitest/Playwright,并纳入 CI |
---
## 3. 优先修复建议
1. 先修复 P0:数据库实体映射、JWT 角色映射、安全保护路径、前后端认证/业务 API 路径。
2. 再修复 P1:HTTP 方法不一致、缺失接口、管理员前端路由、Flyway `USE blog_db`
3. 最后补齐 P2:标签结构、限流维度、自动化测试设施。
4. 修复后第一批自动化测试应优先覆盖:登录/注册、管理员登录和访问后台、创建文章、我的文章列表、发表评论、Flyway 初始化、前端构建。
@@ -2,8 +2,9 @@ package fun.nojava.admin;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
@SpringBootApplication
@SpringBootApplication(exclude = {UserDetailsServiceAutoConfiguration.class})
public class JogApplication {
public static void main(String[] args) {
+6 -8
View File
@@ -8,13 +8,14 @@ spring:
name: jog-system
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/jog_db?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: root
url: jdbc:mysql://81.70.204.4:3306/jog?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: jog
password: Jg.123459
data:
redis:
host: localhost
host: 81.70.204.4
port: 6379
password: Rd.123459
database: 0
timeout: 5000ms
lettuce:
@@ -34,10 +35,7 @@ spring:
starttls:
enable: true
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
encoding: UTF-8
enabled: false
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
@@ -1,9 +1,6 @@
-- V1__init_schema.sql
-- 博客系统数据库初始化脚本
CREATE DATABASE IF NOT EXISTS blog_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE blog_db;
-- 用户表
CREATE TABLE IF NOT EXISTS sys_user (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -1,8 +1,6 @@
-- V2__init_data.sql
-- 初始化数据
USE blog_db;
-- 插入管理员账号(密码:Admin@123)
INSERT INTO sys_user (username, email, password, nickname, user_type, status, email_verified)
VALUES ('admin', 'admin@blog.com', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', '系统管理员', 0, 0, 1);
@@ -0,0 +1 @@
.admin-layout[data-v-32b7a9c5]{min-height:100vh}.el-aside[data-v-32b7a9c5]{background:#304156}.admin-logo[data-v-32b7a9c5]{height:60px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:18px;font-weight:700}.admin-header[data-v-32b7a9c5]{display:flex;align-items:center;justify-content:flex-end;gap:10px;background:#fff;border-bottom:1px solid #e4e7ed}.el-main[data-v-32b7a9c5]{background:#f5f7fa}
@@ -0,0 +1 @@
import{_ as D,a3 as k,S as A,U as t,af as o,g as M,ac as S,a6 as e,E as B,P as a,v as U,w as q,q as I,p as L,b as N,T as i,u as V}from"./index-Ce3OdoWB.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";import{u as $}from"./user-Ct5x882U.js";import"./request-D458e9ik.js";const T={class:"admin-layout"},H={__name:"AdminLayout",setup(P){const _=$(),m=S();async function p(){await _.logout(),m.push("/login")}return(u,n)=>{const c=e("DataAnalysis"),l=I,s=q,f=e("User"),v=e("Document"),x=e("ChatDotSquare"),E=e("Menu"),g=U,C=B,r=N,b=L,w=e("router-view"),y=V,d=M;return k(),A("div",T,[t(d,null,{default:o(()=>[t(C,{width:"220px"},{default:o(()=>[n[6]||(n[6]=a("div",{class:"admin-logo"},"管理后台",-1)),t(g,{"default-active":u.$route.path,router:"","background-color":"#304156","text-color":"#bfcbd9","active-text-color":"#409eff"},{default:o(()=>[t(s,{index:"/admin"},{default:o(()=>[t(l,null,{default:o(()=>[t(c)]),_:1}),n[1]||(n[1]=a("span",null,"仪表盘",-1))]),_:1}),t(s,{index:"/admin/users"},{default:o(()=>[t(l,null,{default:o(()=>[t(f)]),_:1}),n[2]||(n[2]=a("span",null,"用户管理",-1))]),_:1}),t(s,{index:"/admin/articles"},{default:o(()=>[t(l,null,{default:o(()=>[t(v)]),_:1}),n[3]||(n[3]=a("span",null,"文章管理",-1))]),_:1}),t(s,{index:"/admin/comments"},{default:o(()=>[t(l,null,{default:o(()=>[t(x)]),_:1}),n[4]||(n[4]=a("span",null,"评论管理",-1))]),_:1}),t(s,{index:"/admin/categories"},{default:o(()=>[t(l,null,{default:o(()=>[t(E)]),_:1}),n[5]||(n[5]=a("span",null,"分类管理",-1))]),_:1})]),_:1},8,["default-active"])]),_:1}),t(d,null,{default:o(()=>[t(b,{class:"admin-header"},{default:o(()=>[t(r,{onClick:n[0]||(n[0]=R=>u.$router.push("/"))},{default:o(()=>[...n[7]||(n[7]=[i("返回前台",-1)])]),_:1}),t(r,{onClick:p},{default:o(()=>[...n[8]||(n[8]=[i("退出",-1)])]),_:1})]),_:1}),t(y,null,{default:o(()=>[t(w)]),_:1})]),_:1})]),_:1})])}}},X=D(H,[["__scopeId","data-v-32b7a9c5"]]);export{X as default};
@@ -0,0 +1 @@
.app-layout[data-v-105f3892]{min-height:100vh}.el-aside[data-v-105f3892]{background:#fff;border-right:1px solid #e4e7ed}.el-main[data-v-105f3892]{background:#f5f7fa}
@@ -0,0 +1 @@
import{_ as E,a3 as v,S as x,U as t,af as e,g as w,a6 as o,E as y,v as A,w as D,q as B,P as s,u as C}from"./index-Ce3OdoWB.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";const I={},M={class:"app-layout"};function P(_,n){const p=o("DataAnalysis"),a=B,l=D,u=o("Document"),i=o("EditPen"),c=A,d=y,r=o("router-view"),m=C,f=w;return v(),x("div",M,[t(f,null,{default:e(()=>[t(d,{width:"200px"},{default:e(()=>[t(c,{"default-active":_.$route.path,router:""},{default:e(()=>[t(l,{index:"/app/dashboard"},{default:e(()=>[t(a,null,{default:e(()=>[t(p)]),_:1}),n[0]||(n[0]=s("span",null,"控制台",-1))]),_:1}),t(l,{index:"/app/articles"},{default:e(()=>[t(a,null,{default:e(()=>[t(u)]),_:1}),n[1]||(n[1]=s("span",null,"我的文章",-1))]),_:1}),t(l,{index:"/app/editor"},{default:e(()=>[t(a,null,{default:e(()=>[t(i)]),_:1}),n[2]||(n[2]=s("span",null,"写文章",-1))]),_:1})]),_:1},8,["default-active"])]),_:1}),t(m,null,{default:e(()=>[t(r)]),_:1})]),_:1})])}const L=E(I,[["render",P],["__scopeId","data-v-105f3892"]]);export{L as default};
@@ -0,0 +1 @@
.el-divider{position:relative}.el-divider--horizontal{border-top:1px var(--el-border-color) var(--el-border-style);width:100%;height:1px;margin:24px 0;display:block}.el-divider--vertical{vertical-align:middle;border-left:1px var(--el-border-color) var(--el-border-style);width:1px;height:1em;margin:0 8px;display:inline-block;position:relative}.el-divider__text{background-color:var(--el-bg-color);color:var(--el-text-color-primary);padding:0 20px;font-size:14px;font-weight:500;position:absolute}.el-divider__text.is-left{left:20px;transform:translateY(-50%)}.el-divider__text.is-center{left:50%;transform:translate(-50%)translateY(-50%)}.el-divider__text.is-right{right:20px;transform:translateY(-50%)}.article-detail[data-v-1b96cb74]{max-width:800px;margin:0 auto}.article-detail h1[data-v-1b96cb74]{font-size:28px;margin-bottom:16px}.meta[data-v-1b96cb74]{display:flex;gap:16px;align-items:center;color:#909399;font-size:14px;margin-bottom:12px}.meta span[data-v-1b96cb74]{display:flex;align-items:center;gap:4px}.tags[data-v-1b96cb74]{display:flex;gap:8px;margin-bottom:16px}.content[data-v-1b96cb74]{line-height:1.8;font-size:16px}.actions[data-v-1b96cb74]{margin:20px 0}.comments-section[data-v-1b96cb74]{margin-top:30px}.comment-form[data-v-1b96cb74]{margin-bottom:20px}.login-tip[data-v-1b96cb74]{color:#909399;margin-bottom:20px}.comment-item[data-v-1b96cb74]{padding:16px 0;border-bottom:1px solid #f0f0f0}.comment-header[data-v-1b96cb74]{display:flex;align-items:center;gap:8px;margin-bottom:8px}.nickname[data-v-1b96cb74]{font-weight:500}.time[data-v-1b96cb74]{color:#909399;font-size:12px}.comment-body[data-v-1b96cb74]{margin-left:40px}.comment-actions[data-v-1b96cb74]{margin-left:40px;margin-top:4px}.replies[data-v-1b96cb74]{margin-left:40px;padding:12px;background:#f5f7fa;border-radius:4px;margin-top:8px}.reply-item[data-v-1b96cb74]{padding:4px 0;font-size:14px}.reply-to[data-v-1b96cb74]{color:#409eff}
@@ -0,0 +1 @@
import{_ as G,a2 as J,ag as K,ad as O,a3 as i,S as c,N as g,P as a,a9 as n,U as e,af as o,T as u,Q as E,R as y,a5 as N,aa as S,a4 as v,q as W,M as X,b as Y,s as Z,a6 as p,ab as ee,i as te,a as ae,x as z}from"./index-Ce3OdoWB.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{f as ne,t as se}from"./article-9VCq7wnf.js";import{d as le,t as oe,c as ie}from"./comment-DHhatGB5.js";import{u as ue}from"./user-Ct5x882U.js";import"./request-D458e9ik.js";const ce={class:"article-detail"},re={class:"meta"},de={class:"tags"},_e=["innerHTML"],me={class:"actions"},ve={class:"comments-section"},pe={key:0,class:"comment-form"},fe={key:1,class:"login-tip"},ke={class:"comment-list"},ge={class:"comment-header"},ye={class:"nickname"},he={class:"time"},Ce={class:"comment-body"},we={class:"comment-actions"},Ne={key:0,class:"replies"},Te={class:"nickname"},xe={key:0,class:"reply-to"},Ae={class:"time"},De={__name:"ArticleDetail",setup(Le){const T=ee(),x=ue(),t=v(null),A=v([]),h=v(!0),m=v(""),C=v(!1),f=v(null);async function U(){h.value=!0;const s=await ne(T.params.id);t.value=s.data,h.value=!1}async function D(){const s=await le(T.params.id);A.value=s.data}async function B(){if(!x.isLoggedIn){z.warning("请先登录");return}const s=await se(t.value.id);t.value.liked=s.data,t.value.likeCount+=s.data?1:-1}function M(s){f.value=s,m.value=`@${s.nickname} `}async function q(){if(m.value.trim()){C.value=!0;try{const s={articleId:t.value.id,content:m.value};f.value&&(s.parentId=f.value.id,s.replyToUserId=f.value.userId),await ie(s),z.success("评论成功"),m.value="",f.value=null,D()}finally{C.value=!1}}}function w(s){return s?new Date(s).toLocaleString("zh-CN"):""}return J(()=>{U(),D()}),(s,r)=>{const R=p("User"),_=W,$=p("View"),L=p("ChatDotSquare"),H=p("Timer"),b=X,I=te,V=p("Star"),k=Y,F=Z,P=p("router-link"),Q=ae,j=O;return K((i(),c("div",ce,[t.value?(i(),c(g,{key:0},[a("h1",null,n(t.value.title),1),a("div",re,[a("span",null,[e(_,null,{default:o(()=>[e(R)]),_:1}),u(" "+n(t.value.authorNickname),1)]),a("span",null,[e(_,null,{default:o(()=>[e($)]),_:1}),u(" "+n(t.value.viewCount),1)]),a("span",null,[e(_,null,{default:o(()=>[e(L)]),_:1}),u(" "+n(t.value.commentCount),1)]),a("span",null,[e(_,null,{default:o(()=>[e(H)]),_:1}),u(" "+n(w(t.value.publishedAt)),1)]),t.value.categoryName?(i(),E(b,{key:0,size:"small"},{default:o(()=>[u(n(t.value.categoryName),1)]),_:1})):y("",!0)]),a("div",de,[(i(!0),c(g,null,N(t.value.tags,l=>(i(),E(b,{key:l.id,type:"info",size:"small"},{default:o(()=>[u(n(l.name),1)]),_:2},1024))),128))]),e(I),a("div",{class:"content",innerHTML:t.value.content},null,8,_e),e(I),a("div",me,[e(k,{type:t.value.liked?"danger":"default",onClick:B},{default:o(()=>[e(_,null,{default:o(()=>[e(V)]),_:1}),u(" "+n(t.value.liked?"已点赞":"点赞")+" ("+n(t.value.likeCount)+") ",1)]),_:1},8,["type"])]),a("div",ve,[a("h3",null,"评论 ("+n(t.value.commentCount)+")",1),S(x).isLoggedIn?(i(),c("div",pe,[e(F,{modelValue:m.value,"onUpdate:modelValue":r[0]||(r[0]=l=>m.value=l),type:"textarea",rows:3,placeholder:"写下你的评论..."},null,8,["modelValue"]),e(k,{type:"primary",onClick:q,loading:C.value,style:{"margin-top":"8px"}},{default:o(()=>[...r[1]||(r[1]=[u("发表评论",-1)])]),_:1},8,["loading"])])):(i(),c("div",fe,[e(P,{to:"/login"},{default:o(()=>[...r[2]||(r[2]=[u("登录",-1)])]),_:1}),r[3]||(r[3]=u(" 后可以评论 ",-1))])),a("div",ke,[(i(!0),c(g,null,N(A.value,l=>(i(),c("div",{key:l.id,class:"comment-item"},[a("div",ge,[e(Q,{size:32,src:l.avatar},null,8,["src"]),a("span",ye,n(l.nickname),1),a("span",he,n(w(l.createdAt)),1)]),a("div",Ce,n(l.content),1),a("div",we,[e(k,{text:"",size:"small",onClick:d=>M(l)},{default:o(()=>[e(_,null,{default:o(()=>[e(L)]),_:1}),r[4]||(r[4]=u(" 回复 ",-1))]),_:1},8,["onClick"]),e(k,{text:"",size:"small",onClick:d=>S(oe)(l)},{default:o(()=>[e(_,null,{default:o(()=>[e(V)]),_:1}),u(" "+n(l.likeCount),1)]),_:2},1032,["onClick"])]),l.children&&l.children.length?(i(),c("div",Ne,[(i(!0),c(g,null,N(l.children,d=>(i(),c("div",{key:d.id,class:"reply-item"},[a("span",Te,n(d.nickname),1),d.replyToNickname?(i(),c("span",xe,"回复 "+n(d.replyToNickname),1)):y("",!0),u(" "+n(d.content)+" ",1),a("span",Ae,n(w(d.createdAt)),1)]))),128))])):y("",!0)]))),128))])])],64)):y("",!0)])),[[j,h.value]])}}},$e=G(De,[["__scopeId","data-v-1b96cb74"]]);export{$e as default};
@@ -0,0 +1 @@
.el-radio{--el-radio-font-size:var(--el-font-size-base);--el-radio-text-color:var(--el-text-color-regular);--el-radio-font-weight:var(--el-font-weight-primary);--el-radio-input-height:14px;--el-radio-input-width:14px;--el-radio-input-border-radius:var(--el-border-radius-circle);--el-radio-input-bg-color:var(--el-fill-color-blank);--el-radio-input-border:var(--el-border);--el-radio-input-border-color:var(--el-border-color);--el-radio-input-border-color-hover:var(--el-color-primary);color:var(--el-radio-text-color);font-weight:var(--el-radio-font-weight);cursor:pointer;white-space:nowrap;font-size:var(--el-font-size-base);-webkit-user-select:none;user-select:none;outline:none;align-items:center;height:32px;margin-right:30px;display:inline-flex;position:relative}.el-radio.el-radio--large{height:40px}.el-radio.el-radio--small{height:24px}.el-radio.is-bordered{border-radius:var(--el-border-radius-base);border:var(--el-border);box-sizing:border-box;padding:0 15px 0 9px}.el-radio.is-bordered.is-checked{border-color:var(--el-color-primary)}.el-radio.is-bordered.is-disabled{cursor:not-allowed;border-color:var(--el-border-color-lighter)}.el-radio.is-bordered.el-radio--large{border-radius:var(--el-border-radius-base);padding:0 19px 0 11px}.el-radio.is-bordered.el-radio--large .el-radio__label{font-size:var(--el-font-size-base)}.el-radio.is-bordered.el-radio--large .el-radio__inner{width:14px;height:14px}.el-radio.is-bordered.el-radio--small{border-radius:var(--el-border-radius-base);padding:0 11px 0 7px}.el-radio.is-bordered.el-radio--small .el-radio__label{font-size:12px}.el-radio.is-bordered.el-radio--small .el-radio__inner{width:12px;height:12px}.el-radio:last-child{margin-right:0}.el-radio__input{white-space:nowrap;cursor:pointer;vertical-align:middle;outline:none;display:inline-flex;position:relative}.el-radio__input.is-disabled .el-radio__inner{background-color:var(--el-disabled-bg-color);border-color:var(--el-disabled-border-color);cursor:not-allowed}.el-radio__input.is-disabled .el-radio__inner:after{cursor:not-allowed;background-color:var(--el-disabled-bg-color)}.el-radio__input.is-disabled .el-radio__inner+.el-radio__label{cursor:not-allowed}.el-radio__input.is-disabled.is-checked .el-radio__inner{background-color:var(--el-disabled-bg-color);border-color:var(--el-disabled-border-color)}.el-radio__input.is-disabled.is-checked .el-radio__inner:after{background-color:var(--el-text-color-placeholder)}.el-radio__input.is-disabled+span.el-radio__label{color:var(--el-text-color-placeholder);cursor:not-allowed}.el-radio__input.is-checked .el-radio__inner{border-color:var(--el-color-primary);background:var(--el-color-primary)}.el-radio__input.is-checked .el-radio__inner:after{background-color:var(--el-color-white);transform:translate(-50%,-50%)scale(1)}.el-radio__input.is-checked+.el-radio__label{color:var(--el-color-primary)}.el-radio__input.is-focus .el-radio__inner{border-color:var(--el-radio-input-border-color-hover)}.el-radio__inner{border:var(--el-radio-input-border);border-radius:var(--el-radio-input-border-radius);width:var(--el-radio-input-width);height:var(--el-radio-input-height);background-color:var(--el-radio-input-bg-color);cursor:pointer;box-sizing:border-box;transition:all .3s;display:inline-block;position:relative}.el-radio__inner:hover{border-color:var(--el-radio-input-border-color-hover)}.el-radio__inner:after{border-radius:var(--el-radio-input-border-radius);content:"";width:4px;height:4px;transition:transform .15s ease-in;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)scale(0)}.el-radio__original{opacity:0;z-index:-1;outline:none;margin:0;position:absolute;top:0;bottom:0;left:0;right:0}.el-radio__original:focus-visible+.el-radio__inner{outline:2px solid var(--el-radio-input-border-color-hover);outline-offset:1px;border-radius:var(--el-radio-input-border-radius)}.el-radio:focus:not(:focus-visible):not(.is-focus):not(:active):not(.is-disabled) .el-radio__inner{box-shadow:0 0 2px 2px var(--el-radio-input-border-color-hover)}.el-radio__label{font-size:var(--el-radio-font-size);padding-left:8px}.el-radio.el-radio--large .el-radio__label{font-size:14px}.el-radio.el-radio--large .el-radio__inner{width:14px;height:14px}.el-radio.el-radio--small .el-radio__label{font-size:12px}.el-radio.el-radio--small .el-radio__inner{width:12px;height:12px}.el-radio-group{flex-wrap:wrap;align-items:center;font-size:0;display:inline-flex}.article-editor[data-v-bbd4a78f]{max-width:900px}.editor-wrapper[data-v-bbd4a78f]{border:1px solid #dcdfe6;border-radius:4px;width:100%}.toolbar[data-v-bbd4a78f]{padding:8px;border-bottom:1px solid #dcdfe6;background:#fafafa}.editor-content[data-v-bbd4a78f]{min-height:400px;padding:16px}.editor-content[data-v-bbd4a78f] .tiptap{outline:none;min-height:360px}.editor-content[data-v-bbd4a78f] .tiptap p.is-editor-empty:first-child:before{content:attr(data-placeholder);float:left;color:#adb5bd;pointer-events:none;height:0}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{a2 as S,a3 as v,S as D,P as f,U as e,af as o,ah as M,ag as N,ad as U,Q as z,a4 as i,s as I,F as K,J as P,A as j,a6 as q,b as F,q as J,z as L,K as O,M as Q,T as b,a9 as $,y as G,x as H}from"./index-Ce3OdoWB.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css */import{g as R,a as W}from"./article-9VCq7wnf.js";import"./request-D458e9ik.js";const X={style:{"margin-bottom":"16px",display:"flex",gap:"12px"}},ge={__name:"Articles",setup(Y){const m=i([]),r=i(!1),p=i(1),_=i(0),u=i(""),c=i(null);async function s(){r.value=!0;const l=await R({page:p.value,pageSize:15,keyword:u.value,status:c.value});m.value=l.data.records,_.value=l.data.total,r.value=!1}async function y(l){await G.confirm("确定删除该文章?","警告",{type:"warning"}),await W(l),H.success("已删除"),s()}function w(l){return[null,"success","warning","info"][l.status]||""}function x(l){return["草稿","已发布","回收站"][l.status]||"未知"}return S(s),(l,t)=>{const h=q("Search"),E=J,g=F,C=I,d=L,k=K,n=O,V=Q,T=P,A=j,B=U;return v(),D("div",null,[t[4]||(t[4]=f("h2",null,"文章管理",-1)),f("div",X,[e(C,{modelValue:u.value,"onUpdate:modelValue":t[0]||(t[0]=a=>u.value=a),placeholder:"搜索文章",clearable:"",style:{width:"300px"},onKeyup:M(s,["enter"])},{append:o(()=>[e(g,{onClick:s},{default:o(()=>[e(E,null,{default:o(()=>[e(h)]),_:1})]),_:1})]),_:1},8,["modelValue"]),e(k,{modelValue:c.value,"onUpdate:modelValue":t[1]||(t[1]=a=>c.value=a),placeholder:"状态",clearable:"",onChange:s},{default:o(()=>[e(d,{label:"草稿",value:0}),e(d,{label:"已发布",value:1}),e(d,{label:"回收站",value:2})]),_:1},8,["modelValue"])]),N((v(),z(T,{data:m.value},{default:o(()=>[e(n,{prop:"id",label:"ID",width:"80"}),e(n,{prop:"title",label:"标题"}),e(n,{prop:"authorNickname",label:"作者",width:"120"}),e(n,{prop:"viewCount",label:"浏览",width:"80"}),e(n,{prop:"likeCount",label:"点赞",width:"80"}),e(n,{label:"状态",width:"100"},{default:o(({row:a})=>[e(V,{type:w(a)},{default:o(()=>[b($(x(a)),1)]),_:2},1032,["type"])]),_:1}),e(n,{label:"操作",width:"120"},{default:o(({row:a})=>[e(g,{text:"",type:"danger",onClick:Z=>y(a.id)},{default:o(()=>[...t[3]||(t[3]=[b("删除",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[B,r.value]]),e(A,{"current-page":p.value,"onUpdate:currentPage":t[2]||(t[2]=a=>p.value=a),"page-size":15,total:_.value,layout:"prev, pager, next",onCurrentChange:s,style:{"margin-top":"20px","justify-content":"center"}},null,8,["current-page","total"])])}}};export{ge as default};
@@ -0,0 +1 @@
import{_ as L,a3 as l,S as f,U as e,af as t,p as M,u as S,m as V,ac as b,a6 as v,P as i,T as n,v as j,w as x,aa as u,Q as c,j as z,a as A,a9 as F,l as J,k as R,R as T,N as U,b as H}from"./index-Ce3OdoWB.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";import{u as P}from"./user-Ct5x882U.js";import"./request-D458e9ik.js";const Q={class:"jog-layout"},q={class:"header-content"},G={class:"header-right"},K={class:"user-info"},O={__name:"BlogLayout",setup(W){const r=P(),g=b();async function k(){await r.logout(),g.push("/")}return(a,o)=>{const p=v("router-link"),w=x,E=j,y=A,d=R,C=J,$=z,m=H,B=M,I=v("router-view"),N=S,D=V;return l(),f("div",Q,[e(B,{class:"header"},{default:t(()=>[i("div",q,[e(p,{to:"/",class:"logo"},{default:t(()=>[...o[4]||(o[4]=[n("Jog",-1)])]),_:1}),e(E,{mode:"horizontal",ellipsis:!1,class:"nav-menu"},{default:t(()=>[e(w,{index:"home"},{default:t(()=>[e(p,{to:"/"},{default:t(()=>[...o[5]||(o[5]=[n("首页",-1)])]),_:1})]),_:1})]),_:1}),i("div",G,[u(r).isLoggedIn?(l(),c($,{key:0},{dropdown:t(()=>[e(C,null,{default:t(()=>[e(d,{onClick:o[0]||(o[0]=s=>a.$router.push("/app/dashboard"))},{default:t(()=>[...o[6]||(o[6]=[n("控制台",-1)])]),_:1}),u(r).isAdmin?(l(),c(d,{key:0,onClick:o[1]||(o[1]=s=>a.$router.push("/admin"))},{default:t(()=>[...o[7]||(o[7]=[n("管理后台",-1)])]),_:1})):T("",!0),e(d,{divided:"",onClick:k},{default:t(()=>[...o[8]||(o[8]=[n("退出登录",-1)])]),_:1})]),_:1})]),default:t(()=>{var s,_;return[i("span",K,[e(y,{size:28,src:(s=u(r).userInfo)==null?void 0:s.avatar},null,8,["src"]),n(" "+F(((_=u(r).userInfo)==null?void 0:_.nickname)||"用户"),1)])]}),_:1})):(l(),f(U,{key:1},[e(m,{type:"primary",onClick:o[2]||(o[2]=s=>a.$router.push("/login"))},{default:t(()=>[...o[9]||(o[9]=[n("登录",-1)])]),_:1}),e(m,{onClick:o[3]||(o[3]=s=>a.$router.push("/register"))},{default:t(()=>[...o[10]||(o[10]=[n("注册",-1)])]),_:1})],64))])])]),_:1}),e(N,{class:"main"},{default:t(()=>[e(I)]),_:1}),e(D,{class:"footer"},{default:t(()=>[...o[11]||(o[11]=[n("Jog © 2026",-1)])]),_:1})])}}},lo=L(O,[["__scopeId","data-v-359ee45d"]]);export{lo as default};
@@ -0,0 +1 @@
.el-dropdown{--el-dropdown-menu-box-shadow:var(--el-box-shadow-light);--el-dropdown-menuItem-hover-fill:var(--el-color-primary-light-9);--el-dropdown-menuItem-hover-color:var(--el-color-primary);--el-dropdown-menu-index:10;color:var(--el-text-color-regular);font-size:var(--el-font-size-base);vertical-align:top;line-height:1;display:inline-flex;position:relative}.el-dropdown.is-disabled{color:var(--el-text-color-placeholder);cursor:not-allowed}.el-dropdown__popper{--el-dropdown-menu-box-shadow:var(--el-box-shadow-light);--el-dropdown-menuItem-hover-fill:var(--el-color-primary-light-9);--el-dropdown-menuItem-hover-color:var(--el-color-primary);--el-dropdown-menu-index:10}.el-dropdown__popper.el-popper{background:var(--el-bg-color-overlay);border:1px solid var(--el-border-color-light);box-shadow:var(--el-dropdown-menu-box-shadow)}.el-dropdown__popper.el-popper .el-popper__arrow:before{border:1px solid var(--el-border-color-light)}.el-dropdown__popper.el-popper[data-popper-placement^=top] .el-popper__arrow:before{border-top-color:#0000;border-left-color:#0000}.el-dropdown__popper.el-popper[data-popper-placement^=bottom] .el-popper__arrow:before{border-bottom-color:#0000;border-right-color:#0000}.el-dropdown__popper.el-popper[data-popper-placement^=left] .el-popper__arrow:before{border-bottom-color:#0000;border-left-color:#0000}.el-dropdown__popper.el-popper[data-popper-placement^=right] .el-popper__arrow:before{border-top-color:#0000;border-right-color:#0000}.el-dropdown__popper .el-dropdown-menu{border:none}.el-dropdown__popper .el-dropdown__popper-selfdefine{outline:none}.el-dropdown__popper .el-scrollbar__bar{z-index:calc(var(--el-dropdown-menu-index) + 1)}.el-dropdown__popper .el-dropdown__list{box-sizing:border-box;margin:0;padding:0;list-style:none}.el-dropdown .el-dropdown__caret-button{border-left:none;justify-content:center;align-items:center;width:32px;padding-left:0;padding-right:0;display:inline-flex}.el-dropdown .el-dropdown__caret-button>span{display:inline-flex}.el-dropdown .el-dropdown__caret-button:before{content:"";background:var(--el-overlay-color-lighter);width:1px;display:block;position:absolute;top:-1px;bottom:-1px;left:0}.el-dropdown .el-dropdown__caret-button.el-button:before{background:var(--el-border-color);opacity:.5}.el-dropdown .el-dropdown__caret-button .el-dropdown__icon{font-size:inherit;padding-left:0}.el-dropdown .el-dropdown-selfdefine{outline:none}.el-dropdown--large .el-dropdown__caret-button{width:40px}.el-dropdown--small .el-dropdown__caret-button{width:24px}.el-dropdown-menu{z-index:var(--el-dropdown-menu-index);background-color:var(--el-bg-color-overlay);border-radius:var(--el-border-radius-base);box-shadow:none;border:none;margin:0;padding:5px 0;list-style:none;position:relative;top:0;left:0}.el-dropdown-menu__item{white-space:nowrap;line-height:22px;font-size:var(--el-font-size-base);color:var(--el-text-color-regular);cursor:pointer;outline:none;align-items:center;margin:0;padding:5px 16px;list-style:none;display:flex}.el-dropdown-menu__item:not(.is-disabled):hover,.el-dropdown-menu__item:not(.is-disabled):focus{background-color:var(--el-dropdown-menuItem-hover-fill);color:var(--el-dropdown-menuItem-hover-color)}.el-dropdown-menu__item i{margin-right:5px}.el-dropdown-menu__item--divided{border-top:1px solid var(--el-border-color-lighter);margin:6px 0}.el-dropdown-menu__item.is-disabled{cursor:not-allowed;color:var(--el-text-color-disabled)}.el-dropdown-menu--large{padding:7px 0}.el-dropdown-menu--large .el-dropdown-menu__item{padding:7px 20px;font-size:14px;line-height:22px}.el-dropdown-menu--large .el-dropdown-menu__item--divided{margin:8px 0}.el-dropdown-menu--small{padding:3px 0}.el-dropdown-menu--small .el-dropdown-menu__item{padding:2px 12px;font-size:12px;line-height:20px}.el-dropdown-menu--small .el-dropdown-menu__item--divided{margin:4px 0}.jog-layout[data-v-359ee45d]{min-height:100vh;display:flex;flex-direction:column}.header[data-v-359ee45d]{border-bottom:1px solid #e4e7ed;background:#fff}.header-content[data-v-359ee45d]{max-width:1200px;margin:0 auto;display:flex;align-items:center;height:60px;padding:0 20px}.logo[data-v-359ee45d]{font-size:20px;font-weight:700;color:#409eff;text-decoration:none;margin-right:40px}.nav-menu[data-v-359ee45d]{flex:1;border-bottom:none}.header-right[data-v-359ee45d]{display:flex;align-items:center;gap:10px}.user-info[data-v-359ee45d]{display:flex;align-items:center;gap:8px;cursor:pointer}.main[data-v-359ee45d]{flex:1;max-width:1200px;width:100%;margin:0 auto;padding:20px}.footer[data-v-359ee45d]{text-align:center;color:#909399;border-top:1px solid #e4e7ed}
@@ -0,0 +1 @@
import{a2 as S,a3 as y,S as $,P as V,U as l,af as a,ag as F,ad as A,Q as H,b as J,a4 as u,J as K,h as L,T as d,K as P,M as Q,a9 as j,n as q,o as z,s as G,H as O,t as R,y as W,x as c}from"./index-Ce3OdoWB.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css */import{h as X,e as Y,l as Z,b as h}from"./article-9VCq7wnf.js";import"./request-D458e9ik.js";const ee={style:{"margin-bottom":"16px"}},ce={__name:"Categories",setup(le){const b=u([]),f=u(!1),s=u(!1),m=u(!1),v=u(null),o=u({name:"",description:"",enabled:!0,sort:0});async function _(){f.value=!0;const n=await X();b.value=n.data,f.value=!1}function C(){m.value=!1,v.value=null,o.value={name:"",description:"",enabled:!0,sort:0},s.value=!0}function E(n){m.value=!0,v.value=n.id,o.value={name:n.name,description:n.description,enabled:n.enabled,sort:n.sort},s.value=!0}async function w(){if(!o.value.name){c.warning("请输入分类名称");return}m.value?await Z(v.value,o.value):await h(o.value),c.success("操作成功"),s.value=!1,_()}async function x(n){await W.confirm(`确定删除分类"${n.name}"`,"提示");try{await Y(n.id),c.success("已删除"),_()}catch{}}return S(_),(n,e)=>{const r=J,i=P,k=Q,D=K,g=G,p=z,B=O,U=R,I=q,T=L,M=A;return y(),$("div",null,[e[11]||(e[11]=V("h2",null,"分类管理",-1)),V("div",ee,[l(r,{type:"primary",onClick:C},{default:a(()=>[...e[6]||(e[6]=[d("新增分类",-1)])]),_:1})]),F((y(),H(D,{data:b.value},{default:a(()=>[l(i,{prop:"id",label:"ID",width:"80"}),l(i,{prop:"name",label:"名称"}),l(i,{prop:"description",label:"描述"}),l(i,{prop:"articleCount",label:"文章数",width:"100"}),l(i,{label:"状态",width:"100"},{default:a(({row:t})=>[l(k,{type:t.enabled?"success":"danger"},{default:a(()=>[d(j(t.enabled?"启用":"禁用"),1)]),_:2},1032,["type"])]),_:1}),l(i,{label:"操作",width:"180"},{default:a(({row:t})=>[l(r,{text:"",type:"primary",onClick:N=>E(t)},{default:a(()=>[...e[7]||(e[7]=[d("编辑",-1)])]),_:1},8,["onClick"]),l(r,{text:"",type:"danger",onClick:N=>x(t)},{default:a(()=>[...e[8]||(e[8]=[d("删除",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[M,f.value]]),l(T,{modelValue:s.value,"onUpdate:modelValue":e[5]||(e[5]=t=>s.value=t),title:m.value?"编辑分类":"新增分类",width:"400px"},{footer:a(()=>[l(r,{onClick:e[4]||(e[4]=t=>s.value=!1)},{default:a(()=>[...e[9]||(e[9]=[d("取消",-1)])]),_:1}),l(r,{type:"primary",onClick:w},{default:a(()=>[...e[10]||(e[10]=[d("确定",-1)])]),_:1})]),default:a(()=>[l(I,{model:o.value,"label-width":"80px"},{default:a(()=>[l(p,{label:"名称"},{default:a(()=>[l(g,{modelValue:o.value.name,"onUpdate:modelValue":e[0]||(e[0]=t=>o.value.name=t)},null,8,["modelValue"])]),_:1}),l(p,{label:"描述"},{default:a(()=>[l(g,{modelValue:o.value.description,"onUpdate:modelValue":e[1]||(e[1]=t=>o.value.description=t),type:"textarea",rows:2},null,8,["modelValue"])]),_:1}),l(p,{label:"启用"},{default:a(()=>[l(B,{modelValue:o.value.enabled,"onUpdate:modelValue":e[2]||(e[2]=t=>o.value.enabled=t)},null,8,["modelValue"])]),_:1}),l(p,{label:"排序"},{default:a(()=>[l(U,{modelValue:o.value.sort,"onUpdate:modelValue":e[3]||(e[3]=t=>o.value.sort=t),min:0},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])]),_:1},8,["modelValue","title"])])}}};export{ce as default};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{a2 as V,a3 as b,S as T,P as y,U as t,af as l,ag as h,ad as M,Q as S,a4 as i,F as z,J as A,A as I,z as N,K as P,M as U,T as p,a9 as $,b as j,x as C,y as F}from"./index-Ce3OdoWB.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css */import{g as J,b as K,a as L}from"./comment-DHhatGB5.js";import"./request-D458e9ik.js";const O={style:{"margin-bottom":"16px",display:"flex",gap:"12px"}},it={__name:"Comments",setup(Q){const g=i([]),u=i(!1),r=i(1),_=i(0),d=i(null);async function s(){u.value=!0;const o=await J({page:r.value,pageSize:15,status:d.value});g.value=o.data.records,_.value=o.data.total,u.value=!1}async function v(o,e){await K(o,e),C.success("操作成功"),s()}async function x(o){await F.confirm("确定删除该评论?","警告",{type:"warning"}),await L(o),C.success("已删除"),s()}return V(s),(o,e)=>{const m=N,w=z,n=P,k=U,c=j,E=A,B=I,D=M;return b(),T("div",null,[e[5]||(e[5]=y("h2",null,"评论管理",-1)),y("div",O,[t(w,{modelValue:d.value,"onUpdate:modelValue":e[0]||(e[0]=a=>d.value=a),placeholder:"状态",clearable:"",onChange:s},{default:l(()=>[t(m,{label:"待审核",value:0}),t(m,{label:"已通过",value:1}),t(m,{label:"已屏蔽",value:2})]),_:1},8,["modelValue"])]),h((b(),S(E,{data:g.value},{default:l(()=>[t(n,{prop:"id",label:"ID",width:"80"}),t(n,{prop:"nickname",label:"评论者",width:"120"}),t(n,{prop:"content",label:"内容","show-overflow-tooltip":""}),t(n,{prop:"articleId",label:"文章ID",width:"80"}),t(n,{label:"状态",width:"100"},{default:l(({row:a})=>[t(k,{type:[null,"success","danger"][a.status]||"info"},{default:l(()=>[p($(["待审核","已通过","已屏蔽","已删除"][a.status]),1)]),_:2},1032,["type"])]),_:1}),t(n,{label:"操作",width:"200"},{default:l(({row:a})=>[t(c,{text:"",type:"success",onClick:f=>v(a.id,1)},{default:l(()=>[...e[2]||(e[2]=[p("通过",-1)])]),_:1},8,["onClick"]),t(c,{text:"",type:"warning",onClick:f=>v(a.id,2)},{default:l(()=>[...e[3]||(e[3]=[p("屏蔽",-1)])]),_:1},8,["onClick"]),t(c,{text:"",type:"danger",onClick:f=>x(a.id)},{default:l(()=>[...e[4]||(e[4]=[p("删除",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[D,u.value]]),t(B,{"current-page":r.value,"onUpdate:currentPage":e[1]||(e[1]=a=>r.value=a),"page-size":15,total:_.value,layout:"prev, pager, next",onCurrentChange:s,style:{"margin-top":"20px","justify-content":"center"}},null,8,["current-page","total"])])}}};export{it as default};
@@ -0,0 +1 @@
import{_,a3 as u,S as c,P as r,U as t,af as e,D as d,f,d as i,G as p}from"./index-Ce3OdoWB.js";/* empty css *//* empty css *//* empty css */const m={};function v(x,o){const l=p,a=i,n=f,s=d;return u(),c("div",null,[o[0]||(o[0]=r("h2",null,"管理后台仪表盘",-1)),t(s,{gutter:20,style:{"margin-top":"20px"}},{default:e(()=>[t(n,{span:6},{default:e(()=>[t(a,null,{default:e(()=>[t(l,{title:"用户数",value:0})]),_:1})]),_:1}),t(n,{span:6},{default:e(()=>[t(a,null,{default:e(()=>[t(l,{title:"文章数",value:0})]),_:1})]),_:1}),t(n,{span:6},{default:e(()=>[t(a,null,{default:e(()=>[t(l,{title:"评论数",value:0})]),_:1})]),_:1}),t(n,{span:6},{default:e(()=>[t(a,null,{default:e(()=>[t(l,{title:"今日访问",value:0})]),_:1})]),_:1})]),_:1})])}const g=_(m,[["render",v]]);export{g as default};
@@ -0,0 +1 @@
import{a3 as p,S as c,P as r,a9 as i,aa as m,U as t,af as e,D as d,f,d as E,G as S}from"./index-Ce3OdoWB.js";/* empty css *//* empty css *//* empty css */import{u as v}from"./user-Ct5x882U.js";import"./request-D458e9ik.js";const y={__name:"Dashboard",setup(x){const u=v();return(g,s)=>{var n;const a=S,l=E,o=f,_=d;return p(),c("div",null,[s[0]||(s[0]=r("h2",null,"控制台",-1)),r("p",null,"欢迎,"+i((n=m(u).userInfo)==null?void 0:n.nickname),1),t(_,{gutter:20,style:{"margin-top":"20px"}},{default:e(()=>[t(o,{span:8},{default:e(()=>[t(l,null,{default:e(()=>[t(a,{title:"我的文章",value:0})]),_:1})]),_:1}),t(o,{span:8},{default:e(()=>[t(l,null,{default:e(()=>[t(a,{title:"总浏览量",value:0})]),_:1})]),_:1}),t(o,{span:8},{default:e(()=>[t(l,null,{default:e(()=>[t(a,{title:"获赞数",value:0})]),_:1})]),_:1})]),_:1})])}}};export{y as default};
@@ -0,0 +1 @@
.el-image-viewer__wrapper{position:fixed;top:0;bottom:0;left:0;right:0}.el-image-viewer__wrapper:focus{outline:none!important}.el-image-viewer__btn{z-index:1;opacity:.8;cursor:pointer;box-sizing:border-box;-webkit-user-select:none;user-select:none;border-radius:50%;justify-content:center;align-items:center;display:flex;position:absolute}.el-image-viewer__btn .el-icon{cursor:pointer}.el-image-viewer__close{width:40px;height:40px;font-size:40px;top:40px;right:40px}.el-image-viewer__canvas{-webkit-user-select:none;user-select:none;justify-content:center;align-items:center;width:100%;height:100%;display:flex;position:static}.el-image-viewer__actions{background-color:var(--el-text-color-regular);border-color:#fff;border-radius:22px;height:44px;padding:0 23px;bottom:30px;left:50%;transform:translate(-50%)}.el-image-viewer__actions__inner{cursor:default;color:#fff;justify-content:space-around;align-items:center;gap:22px;width:100%;height:100%;padding:0 6px;font-size:23px;display:flex}.el-image-viewer__actions__divider{margin:0 -6px}.el-image-viewer__progress{cursor:default;color:#fff;bottom:90px;left:50%;transform:translate(-50%)}.el-image-viewer__prev{color:#fff;background-color:var(--el-text-color-regular);border-color:#fff;width:44px;height:44px;font-size:24px;top:50%;left:40px;transform:translateY(-50%)}.el-image-viewer__next{text-indent:2px;color:#fff;background-color:var(--el-text-color-regular);border-color:#fff;width:44px;height:44px;font-size:24px;top:50%;right:40px;transform:translateY(-50%)}.el-image-viewer__close{color:#fff;background-color:var(--el-text-color-regular);border-color:#fff;width:44px;height:44px;font-size:24px}.el-image-viewer__mask{opacity:.5;background:#000;width:100%;height:100%;position:absolute;top:0;left:0}.el-image-viewer-parent--hidden{overflow:hidden}.viewer-fade-enter-active{animation:viewer-fade-in var(--el-transition-duration)}.viewer-fade-leave-active{animation:viewer-fade-out var(--el-transition-duration)}@keyframes viewer-fade-in{0%{opacity:0;transform:translateY(-20px)}to{opacity:1;transform:translate(0)}}@keyframes viewer-fade-out{0%{opacity:1;transform:translate(0)}to{opacity:0;transform:translateY(-20px)}}.el-image__error,.el-image__placeholder,.el-image__wrapper,.el-image__inner{width:100%;height:100%}.el-image{display:inline-block;position:relative;overflow:hidden}.el-image__inner{vertical-align:top;opacity:1}.el-image__inner.is-loading{opacity:0}.el-image__wrapper{position:absolute;top:0;left:0}.el-image__placeholder{background:var(--el-fill-color-light)}.el-image__error{background:var(--el-fill-color-light);color:var(--el-text-color-placeholder);vertical-align:middle;justify-content:center;align-items:center;font-size:14px;display:flex}.el-image__preview{cursor:pointer}.hero[data-v-d23a1ade]{text-align:center;padding:60px 0 30px}.hero h1[data-v-d23a1ade]{font-size:32px;color:#303133}.hero p[data-v-d23a1ade]{color:#909399;font-size:16px}.filter-bar[data-v-d23a1ade]{display:flex;gap:16px;margin-bottom:20px;align-items:center}.article-list[data-v-d23a1ade]{display:flex;flex-direction:column;gap:16px}.article-card[data-v-d23a1ade]{cursor:pointer;transition:box-shadow .3s}.article-card[data-v-d23a1ade]:hover{box-shadow:0 4px 12px #0000001a}.article-card[data-v-d23a1ade] .el-card__body{display:flex;justify-content:space-between;align-items:flex-start}.article-content[data-v-d23a1ade]{flex:1}.article-content h3[data-v-d23a1ade]{margin:0 0 8px;font-size:18px}.summary[data-v-d23a1ade]{color:#606266;font-size:14px;margin:0 0 12px}.meta[data-v-d23a1ade]{display:flex;gap:16px;color:#909399;font-size:13px}.meta span[data-v-d23a1ade]{display:flex;align-items:center;gap:4px}.cover[data-v-d23a1ade]{width:160px;height:100px;border-radius:4px;margin-left:16px}.el-pagination[data-v-d23a1ade]{margin-top:20px;justify-content:center}
@@ -0,0 +1 @@
import{_ as q,a2 as F,a3 as r,S as m,P as a,U as t,af as n,ah as H,N as y,a5 as C,a4 as d,F as K,s as L,A as R,ac as $,a6 as p,Q as g,z as j,b as M,q as O,a9 as u,T as _,r as Q,R as G,d as J}from"./index-Ce3OdoWB.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{j as W,h as X}from"./article-9VCq7wnf.js";import"./request-D458e9ik.js";const Y={class:"home"},Z={class:"filter-bar"},ee={class:"article-list"},te={class:"article-content"},ae={class:"summary"},oe={class:"meta"},ne={__name:"Home",setup(le){const k=$(),v=d([]),f=d([]),h=d(0),l=d({page:1,pageSize:15,categoryId:null,keyword:""});async function c(){const o=await W(l.value);v.value=o.data.records,h.value=o.data.total}async function w(){const o=await X();f.value=o.data}function V(o){k.push(`/article/${o}`)}function b(o){return o?new Date(o).toLocaleDateString("zh-CN"):""}return F(()=>{c(),w()}),(o,s)=>{const E=j,I=K,S=p("Search"),i=O,x=M,D=L,N=p("User"),z=p("View"),U=p("ChatDotSquare"),B=p("Timer"),A=Q,P=J,T=R;return r(),m("div",Y,[s[3]||(s[3]=a("div",{class:"hero"},[a("h1",null,"欢迎来到博客系统"),a("p",null,"分享知识,记录生活")],-1)),a("div",Z,[t(I,{modelValue:l.value.categoryId,"onUpdate:modelValue":s[0]||(s[0]=e=>l.value.categoryId=e),placeholder:"选择分类",clearable:"",onChange:c},{default:n(()=>[(r(!0),m(y,null,C(f.value,e=>(r(),g(E,{key:e.id,label:e.name,value:e.id},null,8,["label","value"]))),128))]),_:1},8,["modelValue"]),t(D,{modelValue:l.value.keyword,"onUpdate:modelValue":s[1]||(s[1]=e=>l.value.keyword=e),placeholder:"搜索文章",clearable:"",style:{width:"300px"},onKeyup:H(c,["enter"])},{append:n(()=>[t(x,{onClick:c},{default:n(()=>[t(i,null,{default:n(()=>[t(S)]),_:1})]),_:1})]),_:1},8,["modelValue"])]),a("div",ee,[(r(!0),m(y,null,C(v.value,e=>(r(),g(P,{key:e.id,class:"article-card",onClick:se=>V(e.id)},{default:n(()=>[a("div",te,[a("h3",null,u(e.title),1),a("p",ae,u(e.summary),1),a("div",oe,[a("span",null,[t(i,null,{default:n(()=>[t(N)]),_:1}),_(" "+u(e.authorNickname),1)]),a("span",null,[t(i,null,{default:n(()=>[t(z)]),_:1}),_(" "+u(e.viewCount),1)]),a("span",null,[t(i,null,{default:n(()=>[t(U)]),_:1}),_(" "+u(e.commentCount),1)]),a("span",null,[t(i,null,{default:n(()=>[t(B)]),_:1}),_(" "+u(b(e.publishedAt)),1)])])]),e.coverImage?(r(),g(A,{key:0,src:e.coverImage,class:"cover",fit:"cover"},null,8,["src"])):G("",!0)]),_:2},1032,["onClick"]))),128))]),t(T,{"current-page":l.value.page,"onUpdate:currentPage":s[2]||(s[2]=e=>l.value.page=e),"page-size":l.value.pageSize,total:h.value,layout:"prev, pager, next",onCurrentChange:c},null,8,["current-page","page-size","total"])])}}},ye=q(ne,[["__scopeId","data-v-d23a1ade"]]);export{ye as default};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{_ as S,a3 as U,S as T,U as a,af as o,d as N,ac as K,ab as A,a6 as P,P as m,L as Q,I as j,n as z,ai as E,o as D,s as G,Q as H,R as J,b as O,T as i,e as W,a4 as n,x as F}from"./index-Ce3OdoWB.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{u as X,g as Y}from"./user-Ct5x882U.js";import"./request-D458e9ik.js";const Z={class:"login-page"},$={style:{display:"flex",gap:"8px"}},ee=["src"],ae={class:"footer-link"},oe={__name:"Login",setup(le){const c=K(),f=A(),_=X(),v=n("user"),d=n(!1),g=n(!1),y=n(""),t=n({account:"",password:"",captchaKey:"",captchaCode:""}),r=n({email:"",password:"",rememberMe:!1}),b=n(),w=n(),V={account:[{required:!0,message:"请输入账号",trigger:"blur"}],email:[{required:!0,message:"请输入邮箱",trigger:"blur"}],password:[{required:!0,message:"请输入密码",trigger:"blur"}],captchaCode:[{required:!0,message:"请输入验证码",trigger:"blur"}]};async function h(){const p=await Y();t.value.captchaKey=p.data.captchaKey,y.value=p.data.captchaImage,g.value=!0}async function L(){var p,e;await b.value.validate(),d.value=!0;try{await _.loginAdmin(t.value),F.success("登录成功"),c.push(f.query.redirect||"/admin")}catch(s){((e=(p=s==null?void 0:s.response)==null?void 0:p.data)==null?void 0:e.code)===1008&&h()}finally{d.value=!1}}async function R(){await w.value.validate(),d.value=!0;try{await _.loginUser(r.value),F.success("登录成功"),c.push(f.query.redirect||"/app/dashboard")}finally{d.value=!1}}return(p,e)=>{const s=G,u=D,x=O,k=z,C=j,q=W,I=Q,M=P("router-link"),B=N;return U(),T("div",Z,[a(B,{class:"login-card"},{default:o(()=>[e[12]||(e[12]=m("h2",null,"登录",-1)),a(I,{modelValue:v.value,"onUpdate:modelValue":e[6]||(e[6]=l=>v.value=l)},{default:o(()=>[a(C,{label:"管理员登录",name:"admin"},{default:o(()=>[a(k,{model:t.value,rules:V,ref_key:"adminFormRef",ref:b,onSubmit:E(L,["prevent"])},{default:o(()=>[a(u,{prop:"account"},{default:o(()=>[a(s,{modelValue:t.value.account,"onUpdate:modelValue":e[0]||(e[0]=l=>t.value.account=l),placeholder:"用户名或手机号","prefix-icon":"User"},null,8,["modelValue"])]),_:1}),a(u,{prop:"password"},{default:o(()=>[a(s,{modelValue:t.value.password,"onUpdate:modelValue":e[1]||(e[1]=l=>t.value.password=l),type:"password",placeholder:"密码","prefix-icon":"Lock","show-password":""},null,8,["modelValue"])]),_:1}),g.value?(U(),H(u,{key:0,prop:"captchaCode"},{default:o(()=>[m("div",$,[a(s,{modelValue:t.value.captchaCode,"onUpdate:modelValue":e[2]||(e[2]=l=>t.value.captchaCode=l),placeholder:"验证码"},null,8,["modelValue"]),m("img",{src:y.value,onClick:h,style:{cursor:"pointer",height:"40px"}},null,8,ee)])]),_:1})):J("",!0),a(u,null,{default:o(()=>[a(x,{type:"primary","native-type":"submit",loading:d.value,style:{width:"100%"}},{default:o(()=>[...e[7]||(e[7]=[i("登录",-1)])]),_:1},8,["loading"])]),_:1})]),_:1},8,["model"])]),_:1}),a(C,{label:"用户登录",name:"user"},{default:o(()=>[a(k,{model:r.value,rules:V,ref_key:"userFormRef",ref:w,onSubmit:E(R,["prevent"])},{default:o(()=>[a(u,{prop:"email"},{default:o(()=>[a(s,{modelValue:r.value.email,"onUpdate:modelValue":e[3]||(e[3]=l=>r.value.email=l),placeholder:"邮箱","prefix-icon":"Message"},null,8,["modelValue"])]),_:1}),a(u,{prop:"password"},{default:o(()=>[a(s,{modelValue:r.value.password,"onUpdate:modelValue":e[4]||(e[4]=l=>r.value.password=l),type:"password",placeholder:"密码","prefix-icon":"Lock","show-password":""},null,8,["modelValue"])]),_:1}),a(u,null,{default:o(()=>[a(q,{modelValue:r.value.rememberMe,"onUpdate:modelValue":e[5]||(e[5]=l=>r.value.rememberMe=l)},{default:o(()=>[...e[8]||(e[8]=[i("记住我",-1)])]),_:1},8,["modelValue"])]),_:1}),a(u,null,{default:o(()=>[a(x,{type:"primary","native-type":"submit",loading:d.value,style:{width:"100%"}},{default:o(()=>[...e[9]||(e[9]=[i("登录",-1)])]),_:1},8,["loading"])]),_:1})]),_:1},8,["model"])]),_:1})]),_:1},8,["modelValue"]),m("div",ae,[e[11]||(e[11]=i(" 还没有账号?",-1)),a(M,{to:"/register"},{default:o(()=>[...e[10]||(e[10]=[i("立即注册",-1)])]),_:1})])]),_:1})])}}},ce=S(oe,[["__scopeId","data-v-a0b7679b"]]);export{ce as default};
@@ -0,0 +1 @@
import{a2 as B,a3 as f,S as E,P as y,U as a,af as n,ag as $,ad as D,Q as M,b as N,a4 as p,J as T,A as h,T as i,K as z,a9 as P,y as v,x as b}from"./index-Ce3OdoWB.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css */import{i as S,m as V,d as j}from"./article-9VCq7wnf.js";import"./request-D458e9ik.js";const L={style:{display:"flex","justify-content":"space-between","align-items":"center","margin-bottom":"20px"}},et={__name:"MyArticles",setup(R){const c=p([]),u=p(!1),d=p(1),m=p(0);async function r(){u.value=!0;const e=await S({page:d.value,pageSize:15});c.value=e.data.records,m.value=e.data.total,u.value=!1}async function _(e){await v.confirm("确定移入回收站?","提示"),await V(e),b.success("已移入回收站"),r()}async function w(e){await v.confirm("确定永久删除?此操作不可恢复","警告",{type:"warning"}),await j(e),b.success("已删除"),r()}function C(e){return e?new Date(e).toLocaleString("zh-CN"):"-"}return B(r),(e,t)=>{const s=N,l=z,k=T,x=h,A=D;return f(),E("div",null,[y("div",L,[t[3]||(t[3]=y("h2",null,"我的文章",-1)),a(s,{type:"primary",onClick:t[0]||(t[0]=o=>e.$router.push("/app/editor"))},{default:n(()=>[...t[2]||(t[2]=[i("写文章",-1)])]),_:1})]),$((f(),M(k,{data:c.value},{default:n(()=>[a(l,{prop:"title",label:"标题"}),a(l,{prop:"categoryName",label:"分类",width:"120"}),a(l,{prop:"viewCount",label:"浏览",width:"80"}),a(l,{prop:"likeCount",label:"点赞",width:"80"}),a(l,{prop:"publishedAt",label:"发布时间",width:"180"},{default:n(({row:o})=>[i(P(C(o.publishedAt)),1)]),_:1}),a(l,{label:"操作",width:"200"},{default:n(({row:o})=>[a(s,{text:"",type:"primary",onClick:g=>e.$router.push(`/app/editor/${o.id}`)},{default:n(()=>[...t[4]||(t[4]=[i("编辑",-1)])]),_:1},8,["onClick"]),a(s,{text:"",type:"warning",onClick:g=>_(o.id)},{default:n(()=>[...t[5]||(t[5]=[i("回收站",-1)])]),_:1},8,["onClick"]),a(s,{text:"",type:"danger",onClick:g=>w(o.id)},{default:n(()=>[...t[6]||(t[6]=[i("删除",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[A,u.value]]),a(x,{"current-page":d.value,"onUpdate:currentPage":t[1]||(t[1]=o=>d.value=o),"page-size":15,total:m.value,layout:"prev, pager, next",onCurrentChange:r,style:{"margin-top":"20px","justify-content":"center"}},null,8,["current-page","total"])])}}};export{et as default};
@@ -0,0 +1 @@
import{_ as x,a3 as b,S as E,U as o,af as s,d as k,ac as P,a6 as R,P as m,n as U,ai as C,o as B,s as S,b as q,T as i,x as I,a4 as u}from"./index-Ce3OdoWB.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{u as M}from"./user-Ct5x882U.js";import"./request-D458e9ik.js";const N={class:"register-page"},F={class:"footer-link"},L={__name:"Register",setup(T){const p=P(),f=M(),n=u(!1),d=u(),r=u({email:"",password:"",confirmPassword:""}),g={email:[{required:!0,message:"请输入邮箱",trigger:"blur"},{type:"email",message:"邮箱格式不正确",trigger:"blur"}],password:[{required:!0,message:"请输入密码",trigger:"blur"},{min:8,max:32,message:"密码长度8-32位",trigger:"blur"}],confirmPassword:[{required:!0,message:"请确认密码",trigger:"blur"},{validator:(c,e,a)=>{e!==r.value.password?a(new Error("两次密码不一致")):a()},trigger:"blur"}]};async function _(){await d.value.validate(),n.value=!0;try{await f.registerUser(r.value),I.success("注册成功,请查看邮箱完成验证"),p.push("/login")}finally{n.value=!1}}return(c,e)=>{const a=S,l=B,v=q,w=U,V=R("router-link"),y=k;return b(),E("div",N,[o(y,{class:"register-card"},{default:s(()=>[e[6]||(e[6]=m("h2",null,"注册",-1)),o(w,{model:r.value,rules:g,ref_key:"formRef",ref:d,onSubmit:C(_,["prevent"])},{default:s(()=>[o(l,{prop:"email"},{default:s(()=>[o(a,{modelValue:r.value.email,"onUpdate:modelValue":e[0]||(e[0]=t=>r.value.email=t),placeholder:"邮箱","prefix-icon":"Message"},null,8,["modelValue"])]),_:1}),o(l,{prop:"password"},{default:s(()=>[o(a,{modelValue:r.value.password,"onUpdate:modelValue":e[1]||(e[1]=t=>r.value.password=t),type:"password",placeholder:"密码(8-32位,含大小写字母、数字和特殊字符中的至少三类)","prefix-icon":"Lock","show-password":""},null,8,["modelValue"])]),_:1}),o(l,{prop:"confirmPassword"},{default:s(()=>[o(a,{modelValue:r.value.confirmPassword,"onUpdate:modelValue":e[2]||(e[2]=t=>r.value.confirmPassword=t),type:"password",placeholder:"确认密码","prefix-icon":"Lock","show-password":""},null,8,["modelValue"])]),_:1}),o(l,null,{default:s(()=>[o(v,{type:"primary","native-type":"submit",loading:n.value,style:{width:"100%"}},{default:s(()=>[...e[3]||(e[3]=[i("注册",-1)])]),_:1},8,["loading"])]),_:1})]),_:1},8,["model"]),m("div",F,[e[5]||(e[5]=i(" 已有账号?",-1)),o(V,{to:"/login"},{default:s(()=>[...e[4]||(e[4]=[i("立即登录",-1)])]),_:1})])]),_:1})])}}},O=x(L,[["__scopeId","data-v-c2ac8d09"]]);export{O as default};
@@ -0,0 +1 @@
.register-page[data-v-c2ac8d09]{display:flex;justify-content:center;align-items:center;min-height:100vh;background:#f5f7fa}.register-card[data-v-c2ac8d09]{width:420px;padding:20px}.register-card h2[data-v-c2ac8d09]{text-align:center;margin-bottom:20px}.footer-link[data-v-c2ac8d09]{text-align:center;margin-top:16px;color:#909399}
@@ -0,0 +1 @@
import{a2 as P,a3 as v,S as V,P as y,U as e,af as s,ah as T,ag as M,ad as D,Q as I,a4 as i,s as K,J as N,A as q,a6 as z,b as L,q as j,K as A,T as w,a9 as J,y as x,x as b}from"./index-Ce3OdoWB.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css */import{r as g}from"./request-D458e9ik.js";function Q(o){return g.get("/api/admin/user/list",{params:o})}function R(o,l){return g.put(`/api/admin/user/${o}/reset-password`,null,{params:{newPassword:l}})}function F(o,l){return g.put(`/api/admin/user/${o}/status`,null,{params:{status:l}})}const G={style:{"margin-bottom":"16px",display:"flex",gap:"12px"}},it={__name:"Users",setup(o){const l=i([]),c=i(!1),d=i(1),f=i(0),m=i("");async function r(){c.value=!0;const a=await Q({page:d.value,pageSize:15,keyword:m.value});l.value=a.data.records,f.value=a.data.total,c.value=!1}async function k(a){const{value:t}=await x.prompt("请输入新密码","重置密码",{inputPattern:/^.{8,32}$/,inputErrorMessage:"密码长度8-32位"});await R(a,t),b.success("密码已重置")}async function C(a){const t=a.status===0?1:0,u=t===1?"锁定":"解锁";await x.confirm(`确定${u}该用户?`,"提示"),await F(a.id,t),b.success(`${u}`),r()}return P(r),(a,t)=>{const u=z("Search"),E=j,_=L,h=K,p=A,S=N,U=q,$=D;return v(),V("div",null,[t[3]||(t[3]=y("h2",null,"用户管理",-1)),y("div",G,[e(h,{modelValue:m.value,"onUpdate:modelValue":t[0]||(t[0]=n=>m.value=n),placeholder:"搜索用户",clearable:"",style:{width:"300px"},onKeyup:T(r,["enter"])},{append:s(()=>[e(_,{onClick:r},{default:s(()=>[e(E,null,{default:s(()=>[e(u)]),_:1})]),_:1})]),_:1},8,["modelValue"])]),M((v(),I(S,{data:l.value},{default:s(()=>[e(p,{prop:"id",label:"ID",width:"80"}),e(p,{prop:"nickname",label:"昵称",width:"150"}),e(p,{prop:"email",label:"邮箱"}),e(p,{label:"操作",width:"240"},{default:s(({row:n})=>[e(_,{text:"",type:"warning",onClick:B=>k(n.id)},{default:s(()=>[...t[2]||(t[2]=[w("重置密码",-1)])]),_:1},8,["onClick"]),e(_,{text:"",type:n.status===1?"success":"danger",onClick:B=>C(n)},{default:s(()=>[w(J(n.status===1?"解锁":"锁定"),1)]),_:2},1032,["type","onClick"])]),_:1})]),_:1},8,["data"])),[[$,c.value]]),e(U,{"current-page":d.value,"onUpdate:currentPage":t[1]||(t[1]=n=>d.value=n),"page-size":15,total:f.value,layout:"prev, pager, next",onCurrentChange:r,style:{"margin-top":"20px","justify-content":"center"}},null,8,["current-page","total"])])}}};export{it as default};
@@ -0,0 +1 @@
import{r as e}from"./request-D458e9ik.js";function r(t){return e.get("/api/public/article/list",{params:t})}function n(t){return e.get(`/api/public/article/${t}`)}function c(t){return e.post("/api/app/article",t)}function u(t,i){return e.put(`/api/app/article/${t}`,i)}function p(t){return e.delete(`/api/app/article/${t}`)}function l(t){return e.get("/api/app/article/mine",{params:t})}function o(t){return e.put(`/api/app/article/${t}/recycle`)}function s(t){return e.post(`/api/app/article/${t}/like`)}function g(){return e.get("/api/public/category/list")}function f(){return e.get("/api/public/tag/list")}function d(t){return e.post("/api/admin/category",t)}function m(t,i){return e.put(`/api/admin/category/${t}`,i)}function y(t){return e.delete(`/api/admin/category/${t}`)}function A(t){return e.get("/api/admin/article/list",{params:t})}function $(t){return e.delete(`/api/admin/article/${t}`)}export{$ as a,d as b,c,p as d,y as e,n as f,A as g,g as h,l as i,r as j,f as k,m as l,o as m,s as t,u};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{r as e}from"./request-D458e9ik.js";function a(t){return e.get(`/api/app/comment/article/${t}`)}function i(t){return e.post("/api/app/comment",t)}function o(t){return e.post(`/api/app/comment/${t}/like`)}function r(t){return e.get("/api/admin/comment/list",{params:t})}function p(t,n){return e.put(`/api/admin/comment/${t}/audit`,null,{params:{status:n}})}function u(t){return e.delete(`/api/admin/comment/${t}`)}export{u as a,p as b,i as c,a as d,r as g,o as t};
@@ -0,0 +1 @@
.el-avatar{--el-avatar-text-color:var(--el-color-white);--el-avatar-bg-color:var(--el-text-color-disabled);--el-avatar-text-size:14px;--el-avatar-icon-size:18px;--el-avatar-border-radius:var(--el-border-radius-base);--el-avatar-size-large:56px;--el-avatar-size:40px;--el-avatar-size-small:24px;box-sizing:border-box;text-align:center;color:var(--el-avatar-text-color);background:var(--el-avatar-bg-color);width:var(--el-avatar-size);height:var(--el-avatar-size);font-size:var(--el-avatar-text-size);outline:none;justify-content:center;align-items:center;display:inline-flex;overflow:hidden}.el-avatar>img{width:100%;height:100%;display:block}.el-avatar--circle{border-radius:50%}.el-avatar--square{border-radius:var(--el-avatar-border-radius)}.el-avatar--icon{font-size:var(--el-avatar-icon-size)}.el-avatar--small{--el-avatar-size:24px}.el-avatar--large{--el-avatar-size:56px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.el-card{--el-card-border-color:var(--el-border-color-light);--el-card-border-radius:4px;--el-card-padding:20px;--el-card-bg-color:var(--el-fill-color-blank);border-radius:var(--el-card-border-radius);border:1px solid var(--el-card-border-color);background-color:var(--el-card-bg-color);color:var(--el-text-color-primary);transition:var(--el-transition-duration);flex-direction:column;display:flex;overflow:hidden}.el-card.is-always-shadow,.el-card.is-hover-shadow:hover,.el-card.is-hover-shadow:focus{box-shadow:var(--el-box-shadow-light)}.el-card__header{padding:calc(var(--el-card-padding) - 2px) var(--el-card-padding);border-bottom:1px solid var(--el-card-border-color);box-sizing:border-box}.el-card__body{padding:var(--el-card-padding);flex-grow:1;overflow:auto}.el-card__footer{padding:calc(var(--el-card-padding) - 2px) var(--el-card-padding);border-top:1px solid var(--el-card-border-color);box-sizing:border-box}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.el-aside{box-sizing:border-box;width:var(--el-aside-width,300px);flex-shrink:0;overflow:auto}.el-container{box-sizing:border-box;flex-direction:row;flex:auto;min-width:0;display:flex}.el-container.is-vertical{flex-direction:column}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
:root{--el-loading-spinner-size:42px;--el-loading-fullscreen-spinner-size:50px}.el-loading-parent--relative{position:relative!important}.el-loading-parent--hidden{overflow:hidden!important}.el-loading-mask{z-index:2000;background-color:var(--el-mask-color);transition:opacity var(--el-transition-duration);margin:0;position:absolute;top:0;bottom:0;left:0;right:0}.el-loading-mask.is-fullscreen{position:fixed}.el-loading-mask.is-fullscreen .el-loading-spinner{margin-top:calc((0px - var(--el-loading-fullscreen-spinner-size)) / 2)}.el-loading-mask.is-fullscreen .el-loading-spinner .circular{height:var(--el-loading-fullscreen-spinner-size);width:var(--el-loading-fullscreen-spinner-size)}.el-loading-spinner{margin-top:calc((0px - var(--el-loading-spinner-size)) / 2);text-align:center;width:100%;position:absolute;top:50%}.el-loading-spinner .el-loading-text{color:var(--el-color-primary);margin:3px 0;font-size:14px}.el-loading-spinner .circular{height:var(--el-loading-spinner-size);width:var(--el-loading-spinner-size);animation:2s linear infinite loading-rotate;display:inline}.el-loading-spinner .path{stroke-dasharray:90 150;stroke-dashoffset:0;stroke-width:2px;stroke:var(--el-color-primary);stroke-linecap:round;animation:1.5s ease-in-out infinite loading-dash}.el-loading-spinner i{color:var(--el-color-primary)}.el-loading-fade-enter-from,.el-loading-fade-leave-to{opacity:0}@keyframes loading-rotate{to{transform:rotate(360deg)}}@keyframes loading-dash{0%{stroke-dasharray:1 200;stroke-dashoffset:0}50%{stroke-dasharray:90 150;stroke-dashoffset:-40px}to{stroke-dasharray:90 150;stroke-dashoffset:-120px}}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.el-popper{--el-popper-border-radius:var(--el-popover-border-radius,4px);--el-popper-bg-color-light:var(--el-bg-color-overlay);--el-popper-bg-color-dark:var(--el-text-color-primary);border-radius:var(--el-popper-border-radius);z-index:2000;overflow-wrap:break-word;word-break:normal;visibility:visible;min-width:10px;padding:5px 11px;font-size:12px;line-height:20px;position:absolute}.el-popper.is-dark{--el-fill-color-blank:var(--el-popper-bg-color-dark);color:var(--el-bg-color);background:var(--el-popper-bg-color-dark);border:1px solid var(--el-text-color-primary)}.el-popper.is-dark>.el-popper__arrow:before{border:1px solid var(--el-text-color-primary);background:var(--el-popper-bg-color-dark);right:0}.el-popper.is-light{--el-fill-color-blank:var(--el-popper-bg-color-light);background:var(--el-popper-bg-color-light);border:1px solid var(--el-border-color-light)}.el-popper.is-light>.el-popper__arrow:before{border:1px solid var(--el-border-color-light);background:var(--el-popper-bg-color-light);right:0}.el-popper.is-pure{padding:0}.el-popper__arrow{z-index:-1;width:10px;height:10px;position:absolute}.el-popper__arrow:before{z-index:-1;content:" ";background:var(--el-text-color-primary);box-sizing:border-box;width:10px;height:10px;position:absolute;transform:rotate(45deg)}.el-popper[data-popper-placement^=top]>.el-popper__arrow{bottom:-5px}.el-popper[data-popper-placement^=top]>.el-popper__arrow:before{border-bottom-right-radius:2px}.el-popper[data-popper-placement^=bottom]>.el-popper__arrow{top:-5px}.el-popper[data-popper-placement^=bottom]>.el-popper__arrow:before{border-top-left-radius:2px}.el-popper[data-popper-placement^=left]>.el-popper__arrow{right:-5px}.el-popper[data-popper-placement^=left]>.el-popper__arrow:before{border-top-right-radius:2px}.el-popper[data-popper-placement^=right]>.el-popper__arrow{left:-5px}.el-popper[data-popper-placement^=right]>.el-popper__arrow:before{border-bottom-left-radius:2px}.el-popper[data-popper-placement^=top]>.el-popper__arrow:before{border-top-color:#0000!important;border-left-color:#0000!important}.el-popper[data-popper-placement^=bottom]>.el-popper__arrow:before{border-bottom-color:#0000!important;border-right-color:#0000!important}.el-popper[data-popper-placement^=left]>.el-popper__arrow:before{border-bottom-color:#0000!important;border-left-color:#0000!important}.el-popper[data-popper-placement^=right]>.el-popper__arrow:before{border-top-color:#0000!important;border-right-color:#0000!important}
@@ -0,0 +1 @@
.el-scrollbar{--el-scrollbar-opacity:.3;--el-scrollbar-bg-color:var(--el-text-color-secondary);--el-scrollbar-hover-opacity:.5;--el-scrollbar-hover-bg-color:var(--el-text-color-secondary);height:100%;position:relative;overflow:hidden}.el-scrollbar__wrap{height:100%;overflow:auto}.el-scrollbar__wrap--hidden-default{scrollbar-width:none}.el-scrollbar__wrap--hidden-default::-webkit-scrollbar{display:none}.el-scrollbar__thumb{cursor:pointer;border-radius:inherit;background-color:var(--el-scrollbar-bg-color,var(--el-text-color-secondary));width:0;height:0;transition:var(--el-transition-duration) background-color;opacity:var(--el-scrollbar-opacity,.3);display:block;position:relative}.el-scrollbar__thumb:hover{background-color:var(--el-scrollbar-hover-bg-color,var(--el-text-color-secondary));opacity:var(--el-scrollbar-hover-opacity,.5)}.el-scrollbar__bar{z-index:1;border-radius:4px;position:absolute;bottom:2px;right:2px}.el-scrollbar__bar.is-vertical{width:6px;top:2px}.el-scrollbar__bar.is-vertical>div{width:100%}.el-scrollbar__bar.is-horizontal{height:6px;left:2px}.el-scrollbar__bar.is-horizontal>div{height:100%}.el-scrollbar-fade-enter-active{transition:opacity .34s ease-out}.el-scrollbar-fade-leave-active{transition:opacity .12s ease-out}.el-scrollbar-fade-enter-from,.el-scrollbar-fade-leave-active{opacity:0}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{X as d,a4 as u,O as g}from"./index-Ce3OdoWB.js";import{r as a}from"./request-D458e9ik.js";function y(t){return a.post("/api/public/admin/login",{email:t.email||t.account,password:t.password,rememberMe:t.rememberMe||!1})}function _(t){return a.post("/api/public/user/login",t)}function h(t){return a.post("/api/public/user/register",t)}function A(){return a.get("/api/public/captcha")}function U(){return a.post("/api/app/user/logout")}function w(){return a.get("/api/app/user/me")}const M=d("user",()=>{const t=u(localStorage.getItem("access_token")||""),s=u(localStorage.getItem("refresh_token")||""),r=u(JSON.parse(localStorage.getItem("user_info")||"null")),m=g(()=>!!t.value),c=g(()=>{var e;return((e=r.value)==null?void 0:e.role)==="ADMIN"});async function p(e){const o=(await y(e)).data;return i(o.accessToken,o.refreshToken),r.value=f(o,"ADMIN"),localStorage.setItem("user_info",JSON.stringify(r.value)),o}async function v(e){const o=(await _(e)).data;return i(o.accessToken,o.refreshToken),r.value=f(o,"USER"),localStorage.setItem("user_info",JSON.stringify(r.value)),o}async function I(e){return await h(e)}async function S(){const e=await w(),n=c.value?"ADMIN":"USER";r.value={...e.data,role:n},localStorage.setItem("user_info",JSON.stringify(r.value))}async function k(){try{await U()}catch{}l()}function i(e,n){t.value=e,s.value=n,localStorage.setItem("access_token",e),localStorage.setItem("refresh_token",n)}function l(){t.value="",s.value="",r.value=null,localStorage.removeItem("access_token"),localStorage.removeItem("refresh_token"),localStorage.removeItem("user_info")}function f(e,n){return{id:e.userId,email:e.email,nickname:e.nickname,avatar:e.avatar,type:e.type,role:n}}return{token:t,refreshToken:s,userInfo:r,isLoggedIn:m,isAdmin:c,loginAdmin:p,loginUser:v,registerUser:I,fetchUserInfo:S,logout:k,clearAuth:l}});export{A as g,M as u};
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>博客系统</title>
<script type="module" crossorigin src="/assets/index-Ce3OdoWB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DxOym-B1.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
@@ -15,6 +15,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.core.annotation.Order;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
@@ -33,13 +34,15 @@ public class SecurityConfig {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Bean
@Order(1)
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/oauth/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
@@ -47,6 +50,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/app/**").hasAnyRole("USER", "ADMIN")
.requestMatchers("/api/article/**", "/api/comment/**", "/api/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().permitAll()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
@@ -1,6 +1,7 @@
package fun.nojava.framework.security;
import fun.nojava.common.constant.SecurityConstants;
import fun.nojava.common.enums.UserType;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@@ -38,12 +39,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
Long userId = jwtTokenProvider.getUserIdFromToken(token);
String role = jwtTokenProvider.getRoleFromToken(token);
String roleName = normalizeRole(role);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userId,
null,
List.of(new SimpleGrantedAuthority("ROLE_" + role))
List.of(new SimpleGrantedAuthority("ROLE_" + roleName))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
@@ -59,4 +61,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
return null;
}
private String normalizeRole(String role) {
if ("0".equals(role) || "1".equals(role)) {
return UserType.fromCode(Integer.parseInt(role)).name();
}
return role;
}
}
@@ -1,6 +1,7 @@
package fun.nojava.framework.security;
import fun.nojava.common.constant.SecurityConstants;
import fun.nojava.common.enums.UserType;
import fun.nojava.framework.config.JwtProperties;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
@@ -22,10 +23,11 @@ public class JwtTokenProvider {
public String generateAccessToken(Long userId, Integer role) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtProperties.getAccessTokenExpire() * 1000);
String roleName = UserType.fromCode(role).name();
return Jwts.builder()
.subject(String.valueOf(userId))
.claim("role", String.valueOf(role))
.claim("role", roleName)
.issuedAt(now)
.expiration(expiryDate)
.signWith(jwtProperties.getSigningKey())
+5191
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,7 +1,11 @@
import request from '@/utils/request'
export function adminLogin(data) {
return request.post('/api/public/admin/login', data)
return request.post('/api/public/admin/login', {
email: data.email || data.account,
password: data.password,
rememberMe: data.rememberMe || false,
})
}
export function userLogin(data) {
+88
View File
@@ -0,0 +1,88 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+53
View File
@@ -0,0 +1,53 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElFooter: typeof import('element-plus/es')['ElFooter']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
+3
View File
@@ -109,8 +109,11 @@ const router = createRouter({
router.beforeEach((to, from, next) => {
document.title = to.meta.title ? `${to.meta.title} - 博客系统` : '博客系统'
const token = localStorage.getItem('access_token')
const userInfo = JSON.parse(localStorage.getItem('user_info') || 'null')
if (to.meta.requiresAuth && !token) {
next({ name: 'Login', query: { redirect: to.fullPath } })
} else if (to.meta.requiresAdmin && userInfo?.role !== 'ADMIN') {
next({ name: 'Home' })
} else {
next()
}
+13 -2
View File
@@ -14,7 +14,7 @@ export const useUserStore = defineStore('user', () => {
const res = await adminLogin(data)
const loginData = res.data
setTokens(loginData.accessToken, loginData.refreshToken)
userInfo.value = { ...loginData.userInfo, role: 'ADMIN' }
userInfo.value = toUserInfo(loginData, 'ADMIN')
localStorage.setItem('user_info', JSON.stringify(userInfo.value))
return loginData
}
@@ -23,7 +23,7 @@ export const useUserStore = defineStore('user', () => {
const res = await userLogin(data)
const loginData = res.data
setTokens(loginData.accessToken, loginData.refreshToken)
userInfo.value = { ...loginData.userInfo, role: 'USER' }
userInfo.value = toUserInfo(loginData, 'USER')
localStorage.setItem('user_info', JSON.stringify(userInfo.value))
return loginData
}
@@ -64,6 +64,17 @@ export const useUserStore = defineStore('user', () => {
localStorage.removeItem('user_info')
}
function toUserInfo(loginData, role) {
return {
id: loginData.userId,
email: loginData.email,
nickname: loginData.nickname,
avatar: loginData.avatar,
type: loginData.type,
role,
}
}
return {
token,
refreshToken,
+1 -1
View File
@@ -115,7 +115,7 @@ async function handleSubmit(publish) {
const data = {
...form.value,
content: editor.value?.getHTML() || '',
publish,
status: publish ? 1 : 0,
}
if (isEdit.value) {
await updateArticle(route.params.id, data)
@@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.*;
@Tag(name = "用户文章接口")
@RestController
@RequestMapping("/api/article")
@RequestMapping("/api/app/article")
@RequiredArgsConstructor
public class ArticleController {
@@ -38,7 +38,7 @@ public class ArticleController {
}
@Operation(summary = "我的文章列表")
@GetMapping("/my")
@GetMapping("/mine")
public ApiResult<PageResult<ArticleListVO>> listMyArticles(
@AuthenticationPrincipal Long userId,
@RequestParam(defaultValue = "1") int page,
@@ -48,7 +48,7 @@ public class ArticleController {
}
@Operation(summary = "移至回收站")
@PostMapping("/{id}/recycle")
@PutMapping("/{id}/recycle")
public ApiResult<Void> moveToRecycleBin(
@AuthenticationPrincipal Long userId,
@PathVariable Long id) {
@@ -57,7 +57,7 @@ public class ArticleController {
}
@Operation(summary = "从回收站恢复")
@PostMapping("/{id}/restore")
@PutMapping("/{id}/restore")
public ApiResult<Void> restoreArticle(
@AuthenticationPrincipal Long userId,
@PathVariable Long id) {
@@ -73,4 +73,12 @@ public class ArticleController {
articleService.deleteArticle(userId, id);
return ApiResult.success();
}
@Operation(summary = "点赞文章")
@PostMapping("/{id}/like")
public ApiResult<Boolean> toggleLike(
@PathVariable Long id,
@AuthenticationPrincipal Long userId) {
return ApiResult.success(articleService.toggleLike(userId, id));
}
}
@@ -35,20 +35,16 @@ class AdminCategoryController {
@Operation(summary = "创建分类")
@PostMapping
public ApiResult<Category> createCategory(
@RequestParam String name,
@RequestParam(required = false) String description) {
return ApiResult.success(categoryService.createCategory(name, description));
public ApiResult<Category> createCategory(@RequestBody Category category) {
return ApiResult.success(categoryService.createCategory(category.getName(), category.getDescription()));
}
@Operation(summary = "更新分类")
@PutMapping("/{id}")
public ApiResult<Void> updateCategory(
@PathVariable Long id,
@RequestParam(required = false) String name,
@RequestParam(required = false) String description,
@RequestParam(required = false) Boolean enabled) {
categoryService.updateCategory(id, name, description, enabled);
@RequestBody Category category) {
categoryService.updateCategory(id, category.getName(), category.getDescription(), category.getEnabled());
return ApiResult.success();
}
@@ -39,11 +39,4 @@ public class PublicArticleController {
return ApiResult.success(articleService.getArticleDetail(id, userId));
}
@Operation(summary = "点赞文章")
@PostMapping("/{id}/like")
public ApiResult<Boolean> toggleLike(
@PathVariable Long id,
@AuthenticationPrincipal Long userId) {
return ApiResult.success(articleService.toggleLike(userId, id));
}
}
@@ -0,0 +1,44 @@
package fun.nojava.module.blog.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import fun.nojava.common.model.ApiResult;
import fun.nojava.module.blog.entity.Tag;
import fun.nojava.module.blog.mapper.TagMapper;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@io.swagger.v3.oas.annotations.tags.Tag(name = "标签接口")
@RestController
@RequestMapping("/api/public/tag")
@RequiredArgsConstructor
class PublicTagController {
private final TagMapper tagMapper;
@Operation(summary = "标签列表")
@GetMapping("/list")
public ApiResult<List<Tag>> listTags() {
return ApiResult.success(tagMapper.selectList(new LambdaQueryWrapper<Tag>().orderByAsc(Tag::getName)));
}
}
@io.swagger.v3.oas.annotations.tags.Tag(name = "管理员-标签管理")
@RestController
@RequestMapping("/api/admin/tag")
@RequiredArgsConstructor
class AdminTagController {
private final TagMapper tagMapper;
@Operation(summary = "创建标签")
@PostMapping
public ApiResult<Tag> createTag(@RequestParam String name) {
Tag tag = new Tag();
tag.setName(name);
tagMapper.insert(tag);
return ApiResult.success(tag);
}
}
@@ -1,5 +1,6 @@
package fun.nojava.module.blog.dto;
import fun.nojava.module.blog.entity.Tag;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -34,7 +35,7 @@ public class ArticleVO {
private String categoryName;
private List<String> tags;
private List<Tag> tags;
private Long userId;
@@ -6,7 +6,7 @@ import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("article")
@TableName("blog_article")
public class Article {
@TableId(type = IdType.AUTO)
@@ -6,7 +6,7 @@ import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("article_like")
@TableName("blog_article_like")
public class ArticleLike {
@TableId(type = IdType.AUTO)
@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
@Data
@TableName("article_tag")
@TableName("blog_article_tag")
public class ArticleTag {
@TableId(type = IdType.AUTO)
@@ -6,7 +6,7 @@ import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("category")
@TableName("blog_category")
public class Category {
@TableId(type = IdType.AUTO)
@@ -6,7 +6,7 @@ import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("tag")
@TableName("blog_tag")
public class Tag {
@TableId(type = IdType.AUTO)
@@ -280,11 +280,11 @@ public class ArticleServiceImpl implements ArticleService {
User author = userMapper.selectById(article.getUserId());
Category category = article.getCategoryId() != null ? categoryMapper.selectById(article.getCategoryId()) : null;
List<String> tags = articleTagMapper.selectList(
List<Tag> tags = articleTagMapper.selectList(
new LambdaQueryWrapper<ArticleTag>().eq(ArticleTag::getArticleId, article.getId())
).stream().map(at -> {
Tag tag = tagMapper.selectById(at.getTagId());
return tag != null ? tag.getName() : null;
return tag;
}).filter(t -> t != null).toList();
return ArticleVO.builder()
@@ -16,7 +16,7 @@ import java.util.List;
@Tag(name = "评论接口")
@RestController
@RequestMapping("/api/comment")
@RequestMapping("/api/app/comment")
@RequiredArgsConstructor
public class CommentController {
@@ -74,7 +74,7 @@ class AdminCommentController {
}
@Operation(summary = "审核评论")
@PostMapping("/{id}/audit")
@PutMapping("/{id}/audit")
public ApiResult<Void> auditComment(
@PathVariable Long id,
@RequestParam Integer status) {
@@ -6,7 +6,7 @@ import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("comment")
@TableName("blog_comment")
public class Comment {
@TableId(type = IdType.AUTO)
@@ -18,6 +18,7 @@ public class Comment {
private Long parentId;
@TableField("reply_to_user_id")
private Long replyToId;
private String content;
@@ -6,7 +6,7 @@ import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("comment_like")
@TableName("blog_comment_like")
public class CommentLike {
@TableId(type = IdType.AUTO)
@@ -13,16 +13,19 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.UUID;
@Tag(name = "认证接口")
@RestController
@RequestMapping("/api/auth")
@RequestMapping("/api/public")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@Operation(summary = "用户登录")
@PostMapping("/login")
@PostMapping("/user/login")
@RateLimit(key = "login", maxCount = 5, duration = 60)
public ApiResult<LoginVO> login(@Valid @RequestBody LoginDTO dto) {
LoginVO vo = authService.login(dto, WebUtils.getClientIp(), WebUtils.getUserAgent());
@@ -38,25 +41,26 @@ public class AuthController {
}
@Operation(summary = "用户注册")
@PostMapping("/register")
@PostMapping("/user/register")
@RateLimit(key = "register", maxCount = 3, duration = 3600)
public ApiResult<Void> register(@Valid @RequestBody RegisterDTO dto) {
authService.register(dto);
return ApiResult.success();
}
@Operation(summary = "获取验证码")
@GetMapping("/captcha")
public ApiResult<Map<String, String>> captcha() {
return ApiResult.success(Map.of(
"captchaKey", UUID.randomUUID().toString(),
"captchaImage", ""
));
}
@Operation(summary = "刷新Token")
@PostMapping("/refresh")
public ApiResult<LoginVO> refreshToken(@RequestHeader("Refresh-Token") String refreshToken) {
@PostMapping("/refresh-token")
public ApiResult<LoginVO> refreshToken(@RequestParam String refreshToken) {
LoginVO vo = authService.refreshToken(refreshToken);
return ApiResult.success(vo);
}
@Operation(summary = "退出登录")
@PostMapping("/logout")
public ApiResult<Void> logout(@RequestHeader("Authorization") String authHeader) {
String token = authHeader.replace("Bearer ", "");
authService.logout(token);
return ApiResult.success();
}
}
@@ -3,6 +3,7 @@ package fun.nojava.module.system.controller;
import fun.nojava.common.model.ApiResult;
import fun.nojava.common.model.PageResult;
import fun.nojava.module.system.dto.UserInfoVO;
import fun.nojava.module.system.service.AuthService;
import fun.nojava.module.system.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -12,14 +13,15 @@ import org.springframework.web.bind.annotation.*;
@Tag(name = "用户接口")
@RestController
@RequestMapping("/api/user")
@RequestMapping("/api/app/user")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final AuthService authService;
@Operation(summary = "获取当前用户信息")
@GetMapping("/info")
@GetMapping("/me")
public ApiResult<UserInfoVO> getCurrentUser(@AuthenticationPrincipal Long userId) {
return ApiResult.success(userService.getUserInfo(userId));
}
@@ -29,6 +31,28 @@ public class UserController {
public ApiResult<UserInfoVO> getUserInfo(@PathVariable Long id) {
return ApiResult.success(userService.getUserInfo(id));
}
@Operation(summary = "关注用户")
@PostMapping("/{id}/follow")
public ApiResult<Void> followUser(@AuthenticationPrincipal Long userId, @PathVariable Long id) {
userService.follow(userId, id);
return ApiResult.success();
}
@Operation(summary = "取消关注")
@DeleteMapping("/{id}/follow")
public ApiResult<Void> unfollowUser(@AuthenticationPrincipal Long userId, @PathVariable Long id) {
userService.unfollow(userId, id);
return ApiResult.success();
}
@Operation(summary = "退出登录")
@PostMapping("/logout")
public ApiResult<Void> logout(@RequestHeader("Authorization") String authHeader) {
String token = authHeader.replace("Bearer ", "");
authService.logout(token);
return ApiResult.success();
}
}
@Tag(name = "管理员-用户管理")
@@ -49,14 +73,14 @@ class AdminUserController {
}
@Operation(summary = "重置密码")
@PostMapping("/{id}/reset-password")
@PutMapping("/{id}/reset-password")
public ApiResult<Void> resetPassword(@PathVariable Long id, @RequestParam String newPassword) {
userService.resetPassword(id, newPassword);
return ApiResult.success();
}
@Operation(summary = "更新状态")
@PostMapping("/{id}/status")
@PutMapping("/{id}/status")
public ApiResult<Void> updateStatus(@PathVariable Long id, @RequestParam int status) {
userService.updateUserStatus(id, status);
return ApiResult.success();
@@ -1,6 +1,5 @@
package fun.nojava.module.system.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@@ -8,8 +7,7 @@ import lombok.Data;
@Data
public class LoginDTO {
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
@NotBlank(message = "账号不能为空")
private String email;
@NotBlank(message = "密码不能为空")
@@ -6,7 +6,7 @@ import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("login_log")
@TableName("sys_login_log")
public class LoginLog {
@TableId(type = IdType.AUTO)
@@ -14,14 +14,17 @@ public class LoginLog {
private Long userId;
@TableField("username")
private String email;
private Integer loginType;
private String ipAddress;
@TableField(exist = false)
private String userAgent;
@TableField("result")
private Integer status;
private String failReason;
@@ -6,7 +6,7 @@ import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("user")
@TableName("sys_user")
public class User {
@TableId(type = IdType.AUTO)
@@ -14,6 +14,10 @@ public class User {
private String email;
private String username;
private String phone;
private String password;
private String nickname;
@@ -22,6 +26,7 @@ public class User {
private String bio;
@TableField("user_type")
private Integer type;
private Integer status;
@@ -0,0 +1,21 @@
package fun.nojava.module.system.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("sys_user_follow")
public class UserFollow {
@TableId(type = IdType.AUTO)
private Long id;
private Long followerId;
private Long followeeId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
}
@@ -6,7 +6,7 @@ import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("user_session")
@TableName("sys_user_session")
public class UserSession {
@TableId(type = IdType.AUTO)
@@ -14,12 +14,15 @@ public class UserSession {
private Long userId;
@TableField("token")
private String refreshToken;
@TableField("user_agent")
private String deviceInfo;
private String ipAddress;
@TableField("expire_time")
private LocalDateTime expiresAt;
@TableField(fill = FieldFill.INSERT)
@@ -0,0 +1,9 @@
package fun.nojava.module.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import fun.nojava.module.system.entity.UserFollow;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserFollowMapper extends BaseMapper<UserFollow> {
}
@@ -39,7 +39,12 @@ public class AuthServiceImpl implements AuthService {
@Transactional
public LoginVO login(LoginDTO dto, String ipAddress, String userAgent) {
User user = userMapper.selectOne(
new LambdaQueryWrapper<User>().eq(User::getEmail, dto.getEmail())
new LambdaQueryWrapper<User>()
.eq(User::getEmail, dto.getEmail())
.or()
.eq(User::getUsername, dto.getEmail())
.or()
.eq(User::getPhone, dto.getEmail())
);
if (user == null) {
@@ -7,6 +7,8 @@ import fun.nojava.common.exception.ErrorCode;
import fun.nojava.common.model.PageResult;
import fun.nojava.module.system.dto.UserInfoVO;
import fun.nojava.module.system.entity.User;
import fun.nojava.module.system.entity.UserFollow;
import fun.nojava.module.system.mapper.UserFollowMapper;
import fun.nojava.module.system.mapper.UserMapper;
import fun.nojava.module.system.service.UserService;
import lombok.RequiredArgsConstructor;
@@ -20,6 +22,7 @@ import java.util.List;
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
private final UserFollowMapper userFollowMapper;
private final PasswordEncoder passwordEncoder;
@Override
@@ -93,14 +96,30 @@ public class UserServiceImpl implements UserService {
@Override
public boolean isFollowing(Long followerId, Long followeeId) {
return false;
return userFollowMapper.selectCount(
new LambdaQueryWrapper<UserFollow>()
.eq(UserFollow::getFollowerId, followerId)
.eq(UserFollow::getFolloweeId, followeeId)
) > 0;
}
@Override
public void follow(Long followerId, Long followeeId) {
if (followerId == null || followeeId == null || followerId.equals(followeeId) || isFollowing(followerId, followeeId)) {
return;
}
UserFollow follow = new UserFollow();
follow.setFollowerId(followerId);
follow.setFolloweeId(followeeId);
userFollowMapper.insert(follow);
}
@Override
public void unfollow(Long followerId, Long followeeId) {
userFollowMapper.delete(
new LambdaQueryWrapper<UserFollow>()
.eq(UserFollow::getFollowerId, followerId)
.eq(UserFollow::getFolloweeId, followeeId)
);
}
}