feat: 完成商品分类管理功能,优化前后端配置与接口

1.  调整环境配置,优化 Vite 代理与 Axios 请求路径
2.  完善 Sa-Token 配置,新增 BusinessException 重载构造
3.  重构后端 Mapper 调用,替换 selectBatchIds 为 selectByIds
4.  重构登录与用户信息流程,移除验证码与 Mock 数据
5.  新增商品分类管理页面,支持树形增删改查与状态管理
6.  优化 Redis 序列化配置,升级 Jackson 序列化器
7.  完善类型定义与 API 封装,适配后端最新接口契约
This commit is contained in:
sparksfly 2026-06-02 23:46:19 +08:00
parent 7c483840b6
commit 8b70a3f49a
24 changed files with 8321 additions and 196 deletions

190
CLAUDE.md Normal file
View File

@ -0,0 +1,190 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概览
零食商城(毕业设计)— 前后端分离的电商平台,**SpringBoot 4 + Vue 3** 单体多模块仓库:
| 目录 | 角色 | 技术栈 |
|------|------|--------|
| `server-snack/` | 后端 API | Spring Boot 4.0.6、Java 21、MyBatis-Plus 3.5、Sa-Token 1.45、Redis、MySQL 8、Knife4j 4.6 |
| `admin-snack/` | 管理后台前端 | Vue 3.5 + Vite 6 + TypeScript 5 + Element Plus 2.9 + Pinia 2 + ECharts 5 |
| `db/` | 数据库脚本与设计文档 | `schema.sql`(建库 + 21 张表 DDL、`seed.sql`(演示数据)、`BUSINESS_DESIGN.md` |
| `功能设计文档.md` | 完整功能与接口设计(仓库根) | v1.2,覆盖用户端/管理端/客服/公告/优惠券/抢购 6 大模块 |
> 用户端 `web-snack` 模块当前未在本仓库中(设计文档中描述),当前实际前端仅有 `admin-snack` 管理端。
## 常用命令
### 后端(`server-snack/`
```bash
# 构建(首次会下载依赖,可能需要较长时间)
mvn clean package
# 跳过测试运行
mvn spring-boot:run
# 单独跑测试
mvn test
mvn test -Dtest=类名#方法名 # 单个测试方法
# 开发工具热重启
mvn spring-boot:run -Dspring-boot.run.fork=true
```
- 启动类:`com.snack.server.ServerSnackApplication`
- 监听端口:**8080**
- 接口文档Knife4j`http://localhost:8080/doc.html`
- MySQL 默认连接:`jdbc:mysql://localhost:3306/snack_mall`(账号 `root` / `123456`,见 `application.yml`
- Redis 默认:`localhost:6379`
- **本地上传文件** 存储到 `server-snack/uploads/`,通过 `/uploads/**` 暴露为静态资源
### 数据库(`db/`
```bash
mysql -u root -p < schema.sql # 建库 + 21 张表
mysql -u root -p < seed.sql # 演示数据管理员 admin/123456user001~user005/123456
```
### 管理端(`admin-snack/`
```bash
# 要求 Node ≥ 20
npm install
npm run dev # 开发服务器http://localhost:5173已配置 /api 和 /uploads 代理到 8080
npm run build # 类型检查 + 生产构建(默认 mode
npm run build:test # 测试环境构建
npm run build:prod # 生产环境构建
npm run type-check # vue-tsc 类型检查
npm run lint # ESLint --fix
npm run format # Prettier 格式化
```
> 仓库内 `pnpm-lock.yaml` 已存在,但 `package.json` 脚本未区分包管理器;如本机用 pnpm命令相同`pnpm dev` 等)。
> 项目内默认账号 `admin` / `123456`(管理端登录页 + 数据库 seed 同步)。
## 架构与约定
### 后端分层
每个业务模块位于 `server-snack/src/main/java/com/snack/server/module/<name>/`,统一包结构:
```
module/<name>/
├── controller/ # @RestController,按"用户端 / 管理端"分文件(如 ProductPublicController / ProductAdminController
├── service/ # 业务接口
│ └── impl/ # 业务实现
├── mapper/ # MyBatis-Plus BaseMapper
├── entity/ # 数据库表实体(含 createTime / updateTime会被 MetaObjectHandler 自动填充)
├── dto/req # 入参(带 jakarta.validation 注解)
├── vo/ # 出参(视图对象)
├── enums/ # 模块内枚举
└── constant/ # 模块内常量
```
跨模块共享放在 `com.snack.server.{common,config,constant,exception,handler,utils,enums,websocket}`
### 关键技术点
1. **认证 — Sa-Token注意不是 JWT**
- 用户端与管理端走**两套登录体系**`LoginType.USER` / `LoginType.ADMIN`),通过 `SaRouter.match` 区分
- Token 通过 `Authorization` 请求头传递(前端 `Bearer ${token}`
- 拦截器在 `config/SaTokenConfig.java` 集中维护,按 URL 模式做登录校验
- 业务异常401/403`GlobalExceptionHandler` 统一转 `Result<Void>`
2. **统一响应 — `Result<T>`**
- 结构:`{ code, message, data }`,封装在 `common/Result.java`
- 业务码集中在 `common/ResultCode.java`200/400/401/403/404/500 + 1xxx 业务码)
- **前端 axios 拦截器在 `code === 200` 时直接返回 `data`**`utils/request.ts`)— 这是已解包约定,调用方拿到的就是业务数据
3. **MyBatis-Plus 约定**
- 逻辑删除字段:`deleted`0 未删 / 1 已删),全局生效
- 主键自增:`id-type: auto`
- 实体类继承 `com.baomidou.mybatisplus.annotation.*`,自动填充 `createTime` / `updateTime``MybatisPlusMetaObjectHandler`
- 复杂 SQL 走 XML`src/main/resources/com/snack/server/module/<name>/mapper/*.xml`
- **建表 DDL 与 `entity` 字段必须保持一致**,新增字段要同步两边
4. **Redis 键命名**`constant/RedisKey.java`
- 抢购库存:`seckill:stock:{activityId}:{productId}`
- 用户抢购记录(限购):`seckill:user:{userId}:{activityId}:{productId}`
- 客服在线用户集合:`chat:online:users`
- 会话未读数:`chat:unread:{sessionId}`
- 验证码:`captcha:{uuid}`
5. **WebSocket 客服模块**`websocket/` 当前为占位 `gitkeep``BUSINESS_DESIGN.md` 有详细方案)
- Spring WebSocket + STOMP握手时验证 JWT
- 消息全部落库 `chat_message`,离线消息不丢
- 用户端用 `stomp.js`;管理端会话页在 `views/chat/`
6. **防超卖**(抢购核心)
- Redis `DECR` 原子扣减库存 → 失败直接返回"已抢完"
- 写入异步队列 → 消费端落 MySQL详见 `功能设计文档.md` 3.6.2
7. **接口规范**
- 用户端路径:`/api/<资源>`(例:`/api/cart`、`/api/orders/{id}/pay`
- 管理端路径:`/api/admin/**`(登录、验证码、文件上传除外)
- RESTful列表 `GET`、创建 `POST`、详情 `GET {id}`、删除 `DELETE {id}`
### 前端约定(`admin-snack/`
1. **路径别名**`@` → `src/`Vite + tsconfig 双侧配置)
2. **自动导入**`unplugin-auto-import` + `unplugin-vue-components`
- 自动导入 Vue / Vue Router / Pinia API、Element Plus 组件
- 类型声明由插件生成到 `src/types/auto-imports.d.ts``src/types/components.d.ts`
- 业务文件中**不要**手动 `import { ref, computed } from 'vue'` — 插件已处理
3. **状态管理 — Pinia + 持久化**
- `stores/modules/user.ts` 持久化 key 为 `snack-admin-user`
- 用 Composition API 写法(`defineStore('user', () => { ... })`),不是 Options API
4. **请求层**`utils/request.ts`
- axios 实例baseURL 取 `VITE_API_BASE_URL``.env.development` 默认 `http://localhost:8080`
- **响应拦截器已解包**`code === 200` 时直接 `return data`,调用方拿到的就是业务数据
- 401 自动清空登录态并跳 `/login`
- 所有 API 文件(`api/*.ts`)用 `request<T>(config)` 二次封装
5. **路由**`router/`
- `index.ts`基础路由login、404、仪表盘占位
- `async-routes.ts`:业务路由清单(与 `index.ts` 同步维护,权限动态挂载用)
- 路由守卫在 `src/permission.ts`,白名单 `['/login', '/404']`
- 默认页签布局:`layouts/BasicLayout.vue`(侧栏 + 顶栏 + TabsNav
6. **样式**
- SCSS 全局变量由 `vite.config.ts``additionalData` 自动注入 `@use "@/styles/variables.scss" as *;`
- 业务组件中**直接使用变量**即可,不要重新 import
- 设计令牌:主色 `#1E40AF` / `#3B82F6`,圆角 6/10px背景 `#F8FAFC`(详见 `admin-snack/README.md`
7. **Mock**
- `src/mock/index.ts` 启动时拦截 API 返回本地假数据(基于 `mockjs` + `axios-mock-adapter`
- **联调后端时注释掉 `main.ts` 里的 `import './mock'` 即可关闭**
8. **设计系统**Minimalism · Data-Dense Dashboard状态色 success `#10B981` / warning `#F59E0B` / danger `#EF4444`,圆角与阴影三档(`admin-snack/README.md` 有完整表)
### 全局约定
- **不要 commit 真实密钥**`.env.*.local`、`server-snack/application-local.yml`、`*.pem/*.key/*.crt` 已被 `.gitignore` 排除
- **Java 版本**21`<java.version>21</java.version>`
- **Node 版本**:≥ 20
- **包管理器**:管理端 `pnpm-lock.yaml` 已存在
- **Maven 仓库**:内置了华为云、阿里云镜像加速
- **不需要写** README、通用开发规范、显而易见的"添加注释"等指令
## 关键文档
| 文档 | 路径 | 何时读 |
|------|------|--------|
| 功能设计总览 | `功能设计文档.md` | 任何新模块开发前 |
| 业务核心设计WebSocket + 防超卖) | `db/BUSINESS_DESIGN.md` | 客服/抢购相关开发前 |
| 数据库表结构 | `db/schema.sql` + `db/README.md` | 新增/修改实体前 |
| 管理端技术栈与命令 | `admin-snack/README.md` | 前端环境问题排查 |
## 调试与排错
- **后端启动失败**:先检查 MySQL/Redis 是否启动、端口是否被占用、knife4j 文档能否打开
- **前端 401 风暴**:检查 `VITE_API_BASE_URL`、浏览器是否带了 `Authorization` 头、Sa-Token 拦截器路径配置
- **抢购超卖**:先看 Redis 中 `seckill:stock:*` 键值,再用 `BUSINESS_DESIGN.md` 对照流程
- **类型错误**:运行 `npm run type-check`(管理端),不要绕过 `vue-tsc` 直接 build
- **本地上传文件访问不到**:确认 `WebMvcConfig.addResourceHandlers``/uploads/**``file:./uploads/` 配置,并查看 `snack.upload.domain` 是否正确

View File

@ -1,4 +1,6 @@
# 开发环境
VITE_APP_TITLE=零食商城管理后台
VITE_API_BASE_URL=http://localhost:8080
# 留空 → axios 使用相对路径,请求走 Vite 代理vite.config.ts 的 server.proxy
# 这样浏览器看不到跨域,由 Vite 在 Node 端转发到后端
VITE_API_BASE_URL=
VITE_APP_ENV=development

View File

@ -1,3 +1,4 @@
VITE_APP_TITLE=零食商城管理后台
VITE_API_BASE_URL=http://localhost:8080
# 留空 → 走 Vite 代理(开发态),生产部署时应改为真实后端地址
VITE_API_BASE_URL=
VITE_APP_ENV=test

7368
admin-snack/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -74,10 +74,10 @@ importers:
version: 0.2.3
'@typescript-eslint/eslint-plugin':
specifier: ^8.18.0
version: 8.60.1(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
version: 8.60.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/parser':
specifier: ^8.18.0
version: 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
version: 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.4(vite@6.4.3(@types/node@22.19.19)(jiti@2.7.0)(sass@1.100.0)(tsx@4.22.4))(vue@3.5.35(typescript@5.9.3))
@ -957,63 +957,63 @@ packages:
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@typescript-eslint/eslint-plugin@8.60.1':
resolution: {integrity: sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==}
'@typescript-eslint/eslint-plugin@8.60.0':
resolution: {integrity: sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.60.1
'@typescript-eslint/parser': ^8.60.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/parser@8.60.1':
resolution: {integrity: sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==}
'@typescript-eslint/parser@8.60.0':
resolution: {integrity: sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/project-service@8.60.1':
resolution: {integrity: sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==}
'@typescript-eslint/project-service@8.60.0':
resolution: {integrity: sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/scope-manager@8.60.1':
resolution: {integrity: sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==}
'@typescript-eslint/scope-manager@8.60.0':
resolution: {integrity: sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.60.1':
resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==}
'@typescript-eslint/tsconfig-utils@8.60.0':
resolution: {integrity: sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/type-utils@8.60.1':
resolution: {integrity: sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==}
'@typescript-eslint/type-utils@8.60.0':
resolution: {integrity: sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/types@8.60.1':
resolution: {integrity: sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==}
'@typescript-eslint/types@8.60.0':
resolution: {integrity: sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.60.1':
resolution: {integrity: sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==}
'@typescript-eslint/typescript-estree@8.60.0':
resolution: {integrity: sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/utils@8.60.1':
resolution: {integrity: sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==}
'@typescript-eslint/utils@8.60.0':
resolution: {integrity: sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/visitor-keys@8.60.1':
resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==}
'@typescript-eslint/visitor-keys@8.60.0':
resolution: {integrity: sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@unocss/astro@0.65.4':
@ -2113,8 +2113,8 @@ packages:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'}
typescript-eslint@8.60.1:
resolution: {integrity: sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==}
typescript-eslint@8.60.0:
resolution: {integrity: sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@ -2980,14 +2980,14 @@ snapshots:
'@types/web-bluetooth@0.0.21': {}
'@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.60.1
'@typescript-eslint/type-utils': 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/utils': 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.60.1
'@typescript-eslint/parser': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.60.0
'@typescript-eslint/type-utils': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/utils': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.60.0
eslint: 9.39.4(jiti@2.7.0)
ignore: 7.0.5
natural-compare: 1.4.0
@ -2996,41 +2996,41 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.60.1
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.60.1
'@typescript-eslint/scope-manager': 8.60.0
'@typescript-eslint/types': 8.60.0
'@typescript-eslint/typescript-estree': 8.60.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.60.0
debug: 4.4.3
eslint: 9.39.4(jiti@2.7.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.60.1(typescript@5.9.3)':
'@typescript-eslint/project-service@8.60.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/tsconfig-utils': 8.60.0(typescript@5.9.3)
'@typescript-eslint/types': 8.60.0
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.60.1':
'@typescript-eslint/scope-manager@8.60.0':
dependencies:
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/visitor-keys': 8.60.1
'@typescript-eslint/types': 8.60.0
'@typescript-eslint/visitor-keys': 8.60.0
'@typescript-eslint/tsconfig-utils@8.60.1(typescript@5.9.3)':
'@typescript-eslint/tsconfig-utils@8.60.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/type-utils@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/types': 8.60.0
'@typescript-eslint/typescript-estree': 8.60.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.39.4(jiti@2.7.0)
ts-api-utils: 2.5.0(typescript@5.9.3)
@ -3038,14 +3038,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.60.1': {}
'@typescript-eslint/types@8.60.0': {}
'@typescript-eslint/typescript-estree@8.60.1(typescript@5.9.3)':
'@typescript-eslint/typescript-estree@8.60.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.60.1(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/visitor-keys': 8.60.1
'@typescript-eslint/project-service': 8.60.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.60.0(typescript@5.9.3)
'@typescript-eslint/types': 8.60.0
'@typescript-eslint/visitor-keys': 8.60.0
debug: 4.4.3
minimatch: 10.2.5
semver: 7.8.1
@ -3055,20 +3055,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/utils@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
'@typescript-eslint/scope-manager': 8.60.1
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.60.0
'@typescript-eslint/types': 8.60.0
'@typescript-eslint/typescript-estree': 8.60.0(typescript@5.9.3)
eslint: 9.39.4(jiti@2.7.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.60.1':
'@typescript-eslint/visitor-keys@8.60.0':
dependencies:
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/types': 8.60.0
eslint-visitor-keys: 5.0.1
'@unocss/astro@0.65.4(rollup@4.61.0)(vite@6.4.3(@types/node@22.19.19)(jiti@2.7.0)(sass@1.100.0)(tsx@4.22.4))(vue@3.5.35(typescript@5.9.3))':
@ -3319,11 +3319,11 @@ snapshots:
'@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@9.33.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/utils': 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/utils': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
eslint: 9.39.4(jiti@2.7.0)
eslint-plugin-vue: 9.33.0(eslint@9.39.4(jiti@2.7.0))
fast-glob: 3.3.3
typescript-eslint: 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
typescript-eslint: 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.7.0))
optionalDependencies:
typescript: 5.9.3
@ -4322,12 +4322,12 @@ snapshots:
type-fest@0.20.2: {}
typescript-eslint@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3):
typescript-eslint@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/parser': 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/eslint-plugin': 8.60.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/parser': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.60.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
eslint: 9.39.4(jiti@2.7.0)
typescript: 5.9.3
transitivePeerDependencies:

View File

@ -0,0 +1,5 @@
allowBuilds:
'@parcel/watcher': false
core-js-pure: false
esbuild: false
vue-demi: false

View File

@ -1,7 +1,13 @@
import { request } from '@/utils/request'
import type { LoginParams, LoginResult, AdminInfo } from '@/types/auth'
/** 登录 */
/**
*
* POST /api/admin/login
*
* SaTokenConfig/api/admin/login/api/admin/captcha
* LoginVO token / tokenType / expiresIn / adminInfo
*/
export function loginApi(params: LoginParams) {
return request<LoginResult>({
url: '/api/admin/login',
@ -10,7 +16,10 @@ export function loginApi(params: LoginParams) {
})
}
/** 登出 */
/**
*
* POST /api/admin/logout
*/
export function logoutApi() {
return request<void>({
url: '/api/admin/logout',
@ -18,18 +27,13 @@ export function logoutApi() {
})
}
/** 获取当前管理员信息 */
/**
*
* GET /api/admin/info
*/
export function getAdminInfoApi() {
return request<AdminInfo>({
url: '/api/admin/info',
method: 'GET'
})
}
/** 获取图形验证码 */
export function getCaptchaApi() {
return request<{ key: string; image: string }>({
url: '/api/admin/captcha',
method: 'GET'
})
}

View File

@ -1,14 +1,9 @@
import { request } from '@/utils/request'
import type { PageParams, PageResult } from '@/types/api'
export interface CategoryItem {
id: number
name: string
parentId: number
level: number
sort: number
icon: string
}
// ============================================================
// 商品管理
// ============================================================
export interface ProductItem {
id: number
@ -22,13 +17,6 @@ export interface ProductItem {
createTime: string
}
export function getCategoryListApi() {
return request<CategoryItem[]>({
url: '/api/admin/category/list',
method: 'GET'
})
}
export function getProductPageApi(params: PageParams) {
return request<PageResult<ProductItem>>({
url: '/api/admin/product/page',
@ -44,3 +32,120 @@ export function updateProductStatusApi(id: number, status: 0 | 1) {
data: { status }
})
}
// ============================================================
// 商品分类(管理端)
//
// 后端契约com.snack.server.module.category.controller.CategoryAdminController
// GET /api/admin/category/tree?status= → Result<List<CategoryTreeVO>>
// GET /api/admin/category/children?parentId= → Result<List<Category>>
// GET /api/admin/category/options → Result<List<CategoryOptionVO>>
// GET /api/admin/category/{id} → Result<Category>
// POST /api/admin/category → Result<Long> body: CategorySaveReq
// PUT /api/admin/category → Result<Void> body: CategorySaveReq
// DELETE /api/admin/category/{id} → Result<Void>
// PUT /api/admin/category/{id}/status/{status} → Result<Void>
// PUT /api/admin/category/{id}/sort/{sort} → Result<Void>
// ============================================================
/** 树节点(与后端 CategoryTreeVO 字段一一对应) */
export interface CategoryTreeNode {
id: number
name: string
parentId: number
level: 1 | 2 | 3
sort: number
icon: string
status: 0 | 1
createTime: string
children?: CategoryTreeNode[]
}
/** 下拉选项(用于选择父分类) */
export interface CategoryOption {
id: number
name: string
level: number
parentId: number
}
/** 详情 / 提交请求共用 */
export interface CategorySavePayload {
id?: number
name: string
parentId: number
level: 1 | 2 | 3
sort: number
icon?: string
status: 0 | 1
}
/** 兼容旧名views/product/category.vue 之前用过的) */
export type CategoryItem = CategoryTreeNode
/** 获取分类树status 不传 = 全部) */
export function getCategoryTreeApi(status?: 0 | 1) {
return request<CategoryTreeNode[]>({
url: '/api/admin/category/tree',
method: 'GET',
params: status === undefined ? {} : { status }
})
}
/** 获取启用状态的下拉选项(用于选父分类) */
export function getCategoryOptionsApi() {
return request<CategoryOption[]>({
url: '/api/admin/category/options',
method: 'GET'
})
}
/** 获取分类详情 */
export function getCategoryDetailApi(id: number) {
return request<CategorySavePayload>({
url: `/api/admin/category/${id}`,
method: 'GET'
})
}
/** 创建分类 */
export function createCategoryApi(payload: CategorySavePayload) {
return request<number>({
url: '/api/admin/category',
method: 'POST',
data: payload
})
}
/** 更新分类 */
export function updateCategoryApi(payload: CategorySavePayload) {
return request<void>({
url: '/api/admin/category',
method: 'PUT',
data: payload
})
}
/** 删除分类 */
export function deleteCategoryApi(id: number) {
return request<void>({
url: `/api/admin/category/${id}`,
method: 'DELETE'
})
}
/** 启用 / 禁用 */
export function updateCategoryStatusApi(id: number, status: 0 | 1) {
return request<void>({
url: `/api/admin/category/${id}/status/${status}`,
method: 'PUT'
})
}
/** 调整排序 */
export function updateCategorySortApi(id: number, sort: number) {
return request<void>({
url: `/api/admin/category/${id}/sort/${sort}`,
method: 'PUT'
})
}

View File

@ -23,7 +23,7 @@ import './assets/styles/main.scss'
import './permission'
// Mock 拦截(开发阶段用本地假数据,启动后端后可注释此行)
import './mock'
// import './mock'
const app = createApp(App)

View File

@ -19,33 +19,13 @@ const mock = new MockAdapter(service, { delayResponse: 300 })
*/
const ok = (data: unknown) => [200, { code: 200, message: '操作成功', data }]
/**
*
*/
mock.onPost('/api/admin/login').reply((config) => {
const body = JSON.parse(config.data || '{}')
if (body.username === 'admin' && body.password === '123456') {
return ok({
token: 'mock-token-' + Date.now()
})
}
return [200, { code: 1004, message: '用户名或密码错误', data: null }]
})
/** 登出 */
mock.onPost('/api/admin/logout').reply(() => ok(null))
/** 当前管理员信息 */
mock.onGet('/api/admin/info').reply(() =>
ok({
id: 1,
username: 'admin',
nickname: '超级管理员',
avatar: '',
role: 'super_admin',
permissions: ['*']
})
)
// ============================================================
// 鉴权相关接口login / logout / info由真实后端处理
// Mock 不拦截,让请求走 vite proxy → server-snack
// ============================================================
mock.onPost('/api/admin/login').passThrough()
mock.onPost('/api/admin/logout').passThrough()
mock.onGet('/api/admin/info').passThrough()
/** 仪表盘 */
mock.onGet('/api/admin/dashboard/stats').reply(() => ok(mockDashboardStats))
@ -83,8 +63,34 @@ function paginate<T>(list: T[], current = 1, size = 10) {
return { records, total, size, current, pages }
}
/** 商品分类 */
mock.onGet('/api/admin/category/list').reply(() => ok(mockCategories))
/** 商品分类(树形) */
mock.onGet('/api/admin/category/tree').reply(() => {
// 把扁平列表按 parentId 装配成树
const byId = new Map<number, any>()
mockCategories.forEach((c: any) => byId.set(c.id, { ...c, children: [] }))
const roots: any[] = []
byId.forEach((node) => {
if (node.parentId === 0 || !byId.has(node.parentId)) {
roots.push(node)
} else {
byId.get(node.parentId).children.push(node)
}
})
return ok(roots)
})
mock.onGet('/api/admin/category/options').reply(() =>
ok(mockCategories.map((c: any) => ({ id: c.id, name: c.name, level: c.level, parentId: c.parentId })))
)
mock.onGet(/\/api\/admin\/category\/\d+$/).reply((config) => {
const id = Number(config.url!.split('/').pop())
const found = (mockCategories as any[]).find((c) => c.id === id)
return found ? ok(found) : [200, { code: 404, message: '分类不存在', data: null }]
})
mock.onPost('/api/admin/category').reply(() => ok(Date.now()))
mock.onPut('/api/admin/category').reply(() => ok(null))
mock.onDelete(/\/api\/admin\/category\/\d+$/).reply(() => ok(null))
mock.onPut(/\/api\/admin\/category\/\d+\/status\/\d+/).reply(() => ok(null))
mock.onPut(/\/api\/admin\/category\/\d+\/sort\/\d+/).reply(() => ok(null))
/** 商品分页 */
mock.onGet('/api/admin/product/page').reply((config) => {

View File

@ -1,10 +1,14 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { loginApi, logoutApi, getAdminInfoApi } from '@/api/auth'
import type { LoginParams, AdminInfo } from '@/types/auth'
import type { LoginParams, LoginResult, AdminInfo } from '@/types/auth'
/**
* / Store
* Store
*
* - token / adminInfo pinia-plugin-persistedstate localStoragekey: snack-admin-user
* -
* - 401 request + /login
*/
export const useUserStore = defineStore(
'user',
@ -14,31 +18,43 @@ export const useUserStore = defineStore(
const isLoggedIn = computed(() => !!token.value)
const username = computed(() => adminInfo.value?.username ?? '')
const nickname = computed(() => adminInfo.value?.nickname ?? '')
const avatar = computed(() => adminInfo.value?.avatar ?? '')
const role = computed(() => adminInfo.value?.role ?? '')
async function login(params: LoginParams) {
// request 拦截器已解包出业务 data这里直接拿到 { token }
/**
*
* LoginVO adminInfo /info
*/
async function login(params: LoginParams): Promise<LoginResult> {
const data = await loginApi(params)
token.value = data.token
if (data.adminInfo) {
adminInfo.value = data.adminInfo
}
return data
}
async function fetchAdminInfo() {
// request 拦截器已解包出业务 data这里直接拿到 AdminInfo
/**
* / token adminInfo
*/
async function fetchAdminInfo(): Promise<AdminInfo> {
const data = await getAdminInfoApi()
adminInfo.value = data
return data
}
async function logout() {
async function logout(): Promise<void> {
try {
await logoutApi()
} catch {
/* 即便后端登出失败,前端也要清掉本地登录态 */
} finally {
clearAuth()
}
}
function clearAuth() {
function clearAuth(): void {
token.value = ''
adminInfo.value = null
}
@ -48,7 +64,9 @@ export const useUserStore = defineStore(
adminInfo,
isLoggedIn,
username,
nickname,
avatar,
role,
login,
logout,
fetchAdminInfo,

View File

@ -1,28 +1,41 @@
/**
*
*
* com.snack.server.module.admin.dto.req.AdminLoginReq
* POST /api/admin/login
*/
export interface LoginParams {
username: string
password: string
captchaKey?: string
captchaCode?: string
/** 记住我(可选,后端会延长 token 有效期) */
rememberMe?: boolean
}
/**
*
* LoginVO
* request data LoginVO
*/
export interface LoginResult {
token: string
tokenType: string
expiresIn: number
adminInfo: AdminInfo
}
/**
*
* AdminVO
* GET /api/admin/info
*/
export interface AdminInfo {
id: number
username: string
nickname: string
avatar: string
email: string
phone: string
role: string
permissions: string[]
status: number
lastLoginTime: string
lastLoginIp: string
createTime: string
}

View File

@ -15,6 +15,7 @@ declare module 'vue' {
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
@ -36,10 +37,12 @@ declare module 'vue' {
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']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']

View File

@ -19,8 +19,16 @@ export interface ApiResponse<T = unknown> {
data: T
}
/**
* baseURL
* - /VITE_API_BASE_URL baseURL = '' /api/...
* Vite server.proxy Node ****
* - VITE_API_BASE_URL https://api.example.com→ 跨域由后端 CORS 处理
*/
const baseURL = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/+$/, '')
const service: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
baseURL,
timeout: 15000,
withCredentials: false
})

View File

@ -1,4 +1,14 @@
<script setup lang="ts">
/**
* 管理员登录页
*
* 流程
* 1. 校验表单 userStore.login()请求 POST /api/admin/login
* 2. 后端返回 LoginVOtoken + adminInfostore 自动写入
* 3. 跳到 redirect 参数指定的页面默认 /
*
* Token 失效由 request.ts 拦截器统一处理401 清登录态 + 跳本页
*/
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
@ -16,17 +26,18 @@ const loading = ref(false)
const form = reactive<LoginParams>({
username: 'admin',
password: '123456',
captchaKey: '',
captchaCode: ''
rememberMe: false
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '用户名长度 2-50', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 位', trigger: 'blur' }
],
captchaCode: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
{ min: 6, max: 50, message: '密码长度 6-50', trigger: 'blur' }
]
}
async function handleLogin() {
@ -35,23 +46,16 @@ async function handleLogin() {
if (!valid) return
loading.value = true
try {
// 1.
await userStore.login({
username: form.username,
password: form.password
password: form.password,
rememberMe: form.rememberMe
})
// 2.
try {
await userStore.fetchAdminInfo()
} catch {
/* 拉取失败也允许继续 */
}
ElMessage.success('登录成功')
// 3.
const redirect = (route.query.redirect as string) || '/'
await router.push(redirect)
} catch (e) {
/* 拦截器已提示错误 */
} catch {
/* 拦截器已提示错误,组件内不再 toast */
} finally {
loading.value = false
}
@ -116,6 +120,11 @@ async function handleLogin() {
clearable
/>
</el-form-item>
<el-form-item>
<div class="form-extra">
<el-checkbox v-model="form.rememberMe">记住我</el-checkbox>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@ -277,6 +286,13 @@ async function handleLogin() {
}
}
.form-extra {
width: 100%;
display: flex;
justify-content: flex-start;
margin-bottom: 4px;
}
.submit-btn {
width: 100%;
height: 44px;

View File

@ -1,32 +1,246 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
/**
* 商品分类管理管理端
*
* 后端契约 /api/admin/category/** src/api/product.ts 统一封装
* 数据结构树形最多三级通过 el-table :tree-props="{ children: 'children' }" 渲染
*/
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCategoryListApi } from '@/api/product'
import type { CategoryItem } from '@/api/product'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
getCategoryTreeApi,
createCategoryApi,
updateCategoryApi,
deleteCategoryApi,
updateCategoryStatusApi,
updateCategorySortApi
} from '@/api/product'
import type { CategoryTreeNode, CategorySavePayload } from '@/api/product'
const list = ref<CategoryItem[]>([])
const tree = ref<CategoryTreeNode[]>([])
const loading = ref(false)
const searchKeyword = ref('')
/** 过滤后的树(前端按 name 模糊过滤,保留结构) */
const filteredTree = computed<CategoryTreeNode[]>(() => {
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) return tree.value
const filter = (nodes: CategoryTreeNode[]): CategoryTreeNode[] => {
const out: CategoryTreeNode[] = []
for (const n of nodes) {
const matched = n.name.toLowerCase().includes(kw)
const children = n.children ? filter(n.children) : []
if (matched || children.length) {
out.push({ ...n, children: children.length ? children : n.children })
}
}
return out
}
return filter(tree.value)
})
async function fetchData() {
loading.value = true
try {
const res: any = await getCategoryListApi()
list.value = res
tree.value = await getCategoryTreeApi()
} finally {
loading.value = false
}
}
async function handleAdd() {
ElMessage.info('新增分类功能演示')
// ============================================================
// / /
// ============================================================
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const dialogTitle = computed(() => (dialogMode.value === 'edit' ? '编辑分类' : '新增分类'))
const formRef = ref<FormInstance>()
const submitting = ref(false)
const parentOptions = ref<{ id: number; name: string; level: number; parentId: number }[]>([])
interface FormState {
id?: number
name: string
parentId: number
level: 1 | 2 | 3
sort: number
icon: string
status: 0 | 1
}
async function handleEdit(row: CategoryItem) {
ElMessage.info(`编辑:${row.name}`)
const form = reactive<FormState>({
name: '',
parentId: 0,
level: 1,
sort: 0,
icon: '',
status: 1
})
const rules: FormRules<FormState> = {
name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ max: 50, message: '名称最长 50 字', trigger: 'blur' }
],
level: [{ required: true, message: '请选择层级', trigger: 'change' }],
sort: [{ required: true, message: '请输入排序号', trigger: 'blur' }]
}
async function handleDelete(row: CategoryItem) {
await ElMessageBox.confirm(`确定要删除分类【${row.name}】吗?`, '提示', { type: 'warning' })
ElMessage.success('删除成功')
/**
* 父分类 ID 推断层级
* parentId = 0 一级分类
* parent.level = 1 二级分类
* parent.level = 2 三级分类
* parent.level = 3 不允许再创建子分类
*/
function deriveLevelByParent(parentId: number): 1 | 2 | 3 | null {
if (parentId === 0) return 1
const findIn = (nodes: CategoryTreeNode[]): CategoryTreeNode | null => {
for (const n of nodes) {
if (n.id === parentId) return n
const child = n.children ? findIn(n.children) : null
if (child) return child
}
return null
}
const parent = findIn(tree.value)
if (!parent) return 1
if (parent.level === 1) return 2
if (parent.level === 2) return 3
return null //
}
async function openCreateDialog(parentId = 0) {
const level = deriveLevelByParent(parentId)
if (level === null) {
ElMessage.warning('三级分类下不允许再创建子分类')
return
}
dialogMode.value = 'create'
formRef.value?.resetFields()
Object.assign(form, {
id: undefined,
name: '',
parentId,
level,
sort: 0,
icon: '',
status: 1
})
await loadParentOptions(level)
dialogVisible.value = true
}
async function openEditDialog(row: CategoryTreeNode) {
dialogMode.value = 'edit'
await nextTick()
formRef.value?.resetFields()
Object.assign(form, {
id: row.id,
name: row.name,
parentId: row.parentId ?? 0,
level: row.level,
sort: row.sort ?? 0,
icon: row.icon ?? '',
status: row.status
})
await loadParentOptions(row.level)
dialogVisible.value = true
}
async function loadParentOptions(level: number) {
// level-1
// level=1
// level=2 level=1
// level=3 level=2
const all = await getCategoryTreeApi()
const flat: { id: number; name: string; level: number; parentId: number }[] = []
const walk = (nodes: CategoryTreeNode[]) => {
for (const n of nodes) {
flat.push({ id: n.id, name: n.name, level: n.level, parentId: n.parentId ?? 0 })
if (n.children) walk(n.children)
}
}
walk(all)
parentOptions.value = flat.filter((c) => c.level === level - 1)
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
const payload: CategorySavePayload = {
id: form.id,
name: form.name,
parentId: form.parentId,
level: form.level,
sort: form.sort,
icon: form.icon,
status: form.status
}
if (dialogMode.value === 'create') {
await createCategoryApi(payload)
ElMessage.success('新增成功')
} else {
await updateCategoryApi(payload)
ElMessage.success('更新成功')
}
dialogVisible.value = false
await fetchData()
} catch {
/* 拦截器已提示 */
} finally {
submitting.value = false
}
})
}
// ============================================================
// /
// ============================================================
async function handleStatusChange(row: CategoryTreeNode, val: 0 | 1) {
try {
await updateCategoryStatusApi(row.id, val)
ElMessage.success(val === 1 ? '已启用' : '已禁用')
await fetchData()
} catch {
//
row.status = val === 1 ? 0 : 1
}
}
// ============================================================
// blur
// ============================================================
async function handleSortBlur(row: CategoryTreeNode) {
if (row.sort == null) return
try {
await updateCategorySortApi(row.id, row.sort)
ElMessage.success('排序已更新')
} catch {
/* 拦截器已提示 */
}
}
// ============================================================
//
// ============================================================
async function handleDelete(row: CategoryTreeNode) {
await ElMessageBox.confirm(
`确定要删除分类【${row.name}】吗?${row.children?.length ? '该分类下还有子分类,将一并删除。' : ''}`,
'提示',
{ type: 'warning' }
)
try {
await deleteCategoryApi(row.id)
ElMessage.success('删除成功')
await fetchData()
} catch {
/* 拦截器已提示 */
}
}
onMounted(fetchData)
@ -37,34 +251,183 @@ onMounted(fetchData)
<div class="page-header">
<div>
<h2>商品分类</h2>
<p class="page-desc">树形结构管理商品分类支持多级嵌套</p>
<p class="page-desc">树形结构管理商品分类最多支持三级嵌套</p>
</div>
<el-button type="primary" :icon="Plus">新增分类</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDialog(0)">新增分类</el-button>
</div>
<el-card shadow="never" v-loading="loading">
<el-table :data="list" row-key="id" :tree-props="{ children: 'children' }" default-expand-all>
<el-table-column prop="name" label="分类名称" min-width="240" />
<el-table-column label="层级" width="120" align="center">
<div class="toolbar">
<el-input
v-model="searchKeyword"
placeholder="搜索分类名称"
clearable
style="width: 240px"
/>
<el-button @click="fetchData">刷新</el-button>
</div>
<el-table
:data="filteredTree"
row-key="id"
:tree-props="{ children: 'children' }"
default-expand-all
:key="searchKeyword"
border
stripe
>
<el-table-column prop="name" label="分类名称" min-width="240">
<template #default="{ row }">
<el-tag :type="row.level === 1 ? 'primary' : 'info'" size="small">
{{ row.level === 1 ? '一级分类' : '二级分类' }}
<span :class="{ 'is-disabled': row.status === 0 }">{{ row.name }}</span>
<el-tag v-if="row.status === 0" type="info" size="small" style="margin-left: 8px">
已禁用
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="100" align="center" />
<el-table-column label="操作" width="200" align="center">
<el-table-column label="层级" width="120" align="center">
<template #default="{ row }">
<el-button link type="primary" :icon="Edit" size="small" @click="handleEdit(row)">
<el-tag :type="row.level === 1 ? 'primary' : row.level === 2 ? 'success' : 'warning'" size="small">
{{ ['一级', '二级', '三级'][row.level - 1] }}分类
</el-tag>
</template>
</el-table-column>
<el-table-column label="排序" width="140" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.sort"
:min="0"
:max="9999"
size="small"
controls-position="right"
style="width: 100px"
@blur="handleSortBlur(row)"
@change="(_v: number) => handleSortBlur(row)"
/>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.status === 1"
:active-value="true"
:inactive-value="false"
@change="(v: boolean | number | string) => handleStatusChange(row, v ? 1 : 0)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="280" align="center" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
size="small"
:icon="Plus"
:disabled="row.level >= 3"
@click="openCreateDialog(row.id)"
>
新增子分类
</el-button>
<el-button link type="primary" size="small" :icon="Edit" @click="openEditDialog(row)">
编辑
</el-button>
<el-button link type="primary" :icon="Plus" size="small">新增子分类</el-button>
<el-button link type="danger" :icon="Delete" size="small" @click="handleDelete(row)">
<el-button link type="danger" size="small" :icon="Delete" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新增 / 编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="520px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="90px"
label-position="right"
>
<el-form-item label="分类名称" prop="name">
<el-input v-model="form.name" placeholder="如:坚果炒货" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="层级" prop="level">
<el-radio-group v-model="form.level" :disabled="dialogMode === 'edit'">
<el-radio :value="1">一级</el-radio>
<el-radio :value="2">二级</el-radio>
<el-radio :value="3">三级</el-radio>
</el-radio-group>
<div class="form-tip">层级在创建后不可修改</div>
</el-form-item>
<el-form-item label="父分类" prop="parentId">
<el-select
v-model="form.parentId"
placeholder="选择父分类"
:disabled="form.level === 1"
style="width: 100%"
>
<el-option
v-for="opt in parentOptions"
:key="opt.id"
:label="opt.name"
:value="opt.id"
/>
</el-select>
<div v-if="form.level === 1" class="form-tip">一级分类无父级</div>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" />
<div class="form-tip">数字越小排序越靠前</div>
</el-form-item>
<el-form-item label="图标" prop="icon">
<el-input v-model="form.icon" placeholder="图标 URL可选" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 16px;
align-items: center;
}
.is-disabled {
color: $color-text-secondary;
text-decoration: line-through;
}
.form-tip {
font-size: 12px;
color: $color-text-secondary;
margin-top: 4px;
line-height: 1.4;
}
</style>

View File

@ -18,12 +18,15 @@ export default defineConfig(({ mode }) => {
vueJsx(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()],
// 不让 resolver 自动注入组件 CSSmain.ts 已统一 import 'element-plus/dist/index.css'
// 避免 unplugin-vue-components 在某些版本下生成 element-plus/es/.../style/css无扩展名路径报错
resolvers: [ElementPlusResolver({ importStyle: false })],
dts: 'src/types/auto-imports.d.ts',
eslintrc: { enabled: true }
}),
Components({
resolvers: [ElementPlusResolver()],
// 同上:组件 JS 按需引入,但样式由 main.ts 全量引入
resolvers: [ElementPlusResolver({ importStyle: false })],
dts: 'src/types/components.d.ts'
}),
UnoCSS()
@ -48,14 +51,23 @@ export default defineConfig(({ mode }) => {
open: true,
cors: true,
proxy: {
// /api/** 全部代理到后端
// - 前端 axios baseURL = '' 时请求路径 = '/api/admin/login' → 浏览器同源,无 CORS
// - Vite 在 Node 层把请求转给 VITE_API_BASE_URL默认 http://localhost:8080
'/api': {
target: env.VITE_API_BASE_URL || 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api')
changeOrigin: true
},
// 上传文件访问
'/uploads': {
target: env.VITE_API_BASE_URL || 'http://localhost:8080',
changeOrigin: true
},
// WebSocket 代理(后续客服模块用,握手阶段会带 /ws 前缀)
'/ws': {
target: env.VITE_API_BASE_URL || 'http://localhost:8080',
changeOrigin: true,
ws: true
}
}
},

View File

@ -4,11 +4,14 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis 配置 解决默认 JDK 序列化导致的可读性问题
*
* Spring Data Redis 4.x 推荐使用 GenericJacksonJsonRedisSerializer基于 Jackson 3
* GenericJackson2JsonRedisSerializer基于 Jackson 2已标记为废弃
*/
@Configuration
public class RedisConfig {
@ -19,7 +22,8 @@ public class RedisConfig {
template.setConnectionFactory(factory);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
// create() 需要一个 Consumer 用于自定义 JsonMapper.Builder这里不自定义传空 lambda
GenericJacksonJsonRedisSerializer jsonSerializer = GenericJacksonJsonRedisSerializer.create(builder -> {});
// key 使用 String 序列化
template.setKeySerializer(stringSerializer);

View File

@ -21,6 +21,11 @@ public class BusinessException extends RuntimeException {
this.code = resultCode.getCode();
}
public BusinessException(ResultCode resultCode, String message) {
super(message);
this.code = resultCode.getCode();
}
public BusinessException(Integer code, String message) {
super(message);
this.code = code;

View File

@ -138,7 +138,7 @@ public class AdminServiceImpl implements AdminService {
@Transactional(rollbackFor = Exception.class)
public Long createAdmin(AdminSaveReq req) {
// 1. 用户名唯一性校验
Long count = adminMapper.selectCount(
long count = adminMapper.selectCount(
new LambdaQueryWrapper<Admin>().eq(Admin::getUsername, req.getUsername())
);
if (count > 0) {
@ -172,7 +172,7 @@ public class AdminServiceImpl implements AdminService {
}
// 用户名查重排除自己
Long count = adminMapper.selectCount(
long count = adminMapper.selectCount(
new LambdaQueryWrapper<Admin>()
.eq(Admin::getUsername, req.getUsername())
.ne(Admin::getId, req.getId())
@ -192,7 +192,7 @@ public class AdminServiceImpl implements AdminService {
@Transactional(rollbackFor = Exception.class)
public void deleteAdmin(Long id) {
// 不允许删除自己
if (StpUtil.getLoginIdAsLong().equals(id)) {
if (StpUtil.getLoginIdAsLong() == id) {
throw new BusinessException(1000, "不能删除自己");
}
// 不允许删除超级管理员
@ -255,7 +255,7 @@ public class AdminServiceImpl implements AdminService {
if (id == null || status == null) {
throw new BusinessException(1000, "参数错误");
}
if (StpUtil.getLoginIdAsLong().equals(id)) {
if (StpUtil.getLoginIdAsLong() == id) {
throw new BusinessException(1000, "不能禁用自己");
}
Admin admin = adminMapper.selectById(id);

View File

@ -77,9 +77,9 @@ public class CartServiceImpl implements CartService {
Set<Long> productIds = carts.stream().map(Cart::getProductId).collect(Collectors.toSet());
Set<Long> skuIds = carts.stream().map(Cart::getSkuId).collect(Collectors.toSet());
Map<Long, Product> productMap = productMapper.selectBatchIds(productIds).stream()
Map<Long, Product> productMap = productMapper.selectByIds(productIds).stream()
.collect(Collectors.toMap(Product::getId, p -> p));
Map<Long, ProductSku> skuMap = productSkuMapper.selectBatchIds(skuIds).stream()
Map<Long, ProductSku> skuMap = productSkuMapper.selectByIds(skuIds).stream()
.collect(Collectors.toMap(ProductSku::getId, s -> s));
// 2. VO + 统计

View File

@ -81,14 +81,14 @@ public class OrderServiceImpl implements OrderService {
List<Long> skuIds = req.getItems().stream()
.map(OrderSubmitReq.OrderItemReq::getSkuId)
.toList();
List<ProductSku> skus = productSkuMapper.selectBatchIds(skuIds);
List<ProductSku> skus = productSkuMapper.selectByIds(skuIds);
Map<Long, ProductSku> skuMap = skus.stream()
.collect(Collectors.toMap(ProductSku::getId, s -> s));
if (skuMap.size() != skuIds.size()) {
throw new BusinessException(1000, "部分 SKU 不存在");
}
Set<Long> productIds = skus.stream().map(ProductSku::getProductId).collect(Collectors.toSet());
Map<Long, Product> productMap = productMapper.selectBatchIds(productIds).stream()
Map<Long, Product> productMap = productMapper.selectByIds(productIds).stream()
.collect(Collectors.toMap(Product::getId, p -> p));
// 3. 计算金额 + 构造 OrderItem 列表

View File

@ -26,6 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
@ -116,7 +117,7 @@ public class ProductServiceImpl implements ProductService {
.map(Product::getCategoryId).filter(Objects::nonNull).collect(Collectors.toSet());
Map<Long, String> categoryNameMap = new HashMap<>();
if (!categoryIds.isEmpty()) {
categoryMapper.selectBatchIds(categoryIds)
categoryMapper.selectByIds(categoryIds)
.forEach(c -> categoryNameMap.put(c.getId(), c.getName()));
}

View File

@ -62,6 +62,7 @@ sa-token:
token-style: uuid
# 是否输出操作日志
is-log: false
token-prefix: Bearer
# Knife4j 配置
knife4j: