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:
parent
7c483840b6
commit
8b70a3f49a
|
|
@ -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/123456,user001~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` 是否正确
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
VITE_APP_TITLE=零食商城管理后台
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
# 留空 → 走 Vite 代理(开发态),生产部署时应改为真实后端地址
|
||||
VITE_API_BASE_URL=
|
||||
VITE_APP_ENV=test
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
allowBuilds:
|
||||
'@parcel/watcher': false
|
||||
core-js-pure: false
|
||||
esbuild: false
|
||||
vue-demi: false
|
||||
|
|
@ -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'
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import './assets/styles/main.scss'
|
|||
import './permission'
|
||||
|
||||
// Mock 拦截(开发阶段用本地假数据,启动后端后可注释此行)
|
||||
import './mock'
|
||||
// import './mock'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 持久化到 localStorage(key: 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* 管理员登录页
|
||||
*
|
||||
* 流程:
|
||||
* 1. 校验表单 → 调 userStore.login()(请求 POST /api/admin/login)
|
||||
* 2. 后端返回 LoginVO(token + adminInfo),store 自动写入
|
||||
* 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;
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
/**
|
||||
* 父分类 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>
|
||||
|
|
|
|||
|
|
@ -18,12 +18,15 @@ export default defineConfig(({ mode }) => {
|
|||
vueJsx(),
|
||||
AutoImport({
|
||||
imports: ['vue', 'vue-router', 'pinia'],
|
||||
resolvers: [ElementPlusResolver()],
|
||||
// 不让 resolver 自动注入组件 CSS:main.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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 + 统计
|
||||
|
|
|
|||
|
|
@ -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 列表
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ sa-token:
|
|||
token-style: uuid
|
||||
# 是否输出操作日志
|
||||
is-log: false
|
||||
token-prefix: Bearer
|
||||
|
||||
# Knife4j 配置
|
||||
knife4j:
|
||||
|
|
|
|||
Loading…
Reference in New Issue