From ea60deaa8cfac6dd4c0fc1f49dfe829d86336ff4 Mon Sep 17 00:00:00 2001 From: lingyunxsh Date: Sun, 18 May 2025 19:52:17 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=E5=9B=9B=E9=98=B6=E6=AE=B5=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LogBook.md | 33 + charging_web_app/package-lock.json | 1024 ++++++++++++++++- charging_web_app/package.json | 1 + .../admin/activation-codes/page.tsx | 382 ++++++ .../(authenticated)/admin/dashboard/page.tsx | 40 +- .../app/(authenticated)/dashboard/page.tsx | 83 +- .../app/(authenticated)/redeem-code/page.tsx | 107 ++ .../src/components/RedeemCodeForm.tsx | 109 ++ charging_web_app/src/contexts/AuthContext.tsx | 1 + charging_web_app/src/types/api.ts | 10 + .../controller/ActivationCodeController.java | 161 +++ .../project/mapper/ActivationCodeMapper.java | 12 + .../ActivationCodeQueryRequest.java | 70 ++ .../activationcode/GenerateCodesRequest.java | 36 + .../dto/activationcode/RedeemCodeRequest.java | 19 + .../project/model/dto/common/IdRequest.java | 13 + .../project/model/entity/ActivationCode.java | 80 ++ .../project/model/vo/ActivationCodeVO.java | 76 ++ .../service/ActivationCodeService.java | 58 + .../impl/ActivationCodeServiceImpl.java | 215 ++++ .../project/service/impl/UserServiceImpl.java | 48 +- .../service/impl/UserServiceImpl.class | Bin 18321 -> 18903 bytes 22 files changed, 2513 insertions(+), 65 deletions(-) create mode 100644 LogBook.md create mode 100644 charging_web_app/src/app/(authenticated)/admin/activation-codes/page.tsx create mode 100644 charging_web_app/src/app/(authenticated)/redeem-code/page.tsx create mode 100644 charging_web_app/src/components/RedeemCodeForm.tsx create mode 100644 charging_web_app/src/types/api.ts create mode 100644 springboot-init-main/src/main/java/com/yupi/project/controller/ActivationCodeController.java create mode 100644 springboot-init-main/src/main/java/com/yupi/project/mapper/ActivationCodeMapper.java create mode 100644 springboot-init-main/src/main/java/com/yupi/project/model/dto/activationcode/ActivationCodeQueryRequest.java create mode 100644 springboot-init-main/src/main/java/com/yupi/project/model/dto/activationcode/GenerateCodesRequest.java create mode 100644 springboot-init-main/src/main/java/com/yupi/project/model/dto/activationcode/RedeemCodeRequest.java create mode 100644 springboot-init-main/src/main/java/com/yupi/project/model/dto/common/IdRequest.java create mode 100644 springboot-init-main/src/main/java/com/yupi/project/model/entity/ActivationCode.java create mode 100644 springboot-init-main/src/main/java/com/yupi/project/model/vo/ActivationCodeVO.java create mode 100644 springboot-init-main/src/main/java/com/yupi/project/service/ActivationCodeService.java create mode 100644 springboot-init-main/src/main/java/com/yupi/project/service/impl/ActivationCodeServiceImpl.java diff --git a/LogBook.md b/LogBook.md new file mode 100644 index 0000000..853804d --- /dev/null +++ b/LogBook.md @@ -0,0 +1,33 @@ +## 2024-07-29 (用户激活码功能 - 后端主体) + +- **核心业务逻辑实现 (Service & Impl)**: + - 创建 `ActivationCodeService.java` 接口,定义了激活码生成 (`generateCodes`)、兑换 (`redeemCode`) 和查询条件构建 (`getQueryWrapper`) 的方法。 + - 创建 `ActivationCodeServiceImpl.java` 实现类: + - `generateCodes`: 实现批量生成激活码的逻辑,使用 UUID 生成唯一码,支持自定义数量、面值、过期时间和批次号,并进行批量保存。 + - `redeemCode`: 实现用户兑换激活码的逻辑,包含参数校验、激活码状态(是否存在、已用、过期)校验,调用 `UserService.increaseBalance()` 增加用户余额,并更新激活码状态。整个过程使用 `@Transactional` 保证事务原子性。 + - `getQueryWrapper`: 根据 `ActivationCodeQueryRequest` 中的各种条件(如激活码、使用状态、批次号、用户ID、面值范围、过期时间范围、创建时间范围)构建 MyBatis Plus 查询条件,并处理排序(默认按创建时间降序)。 + +- **数据传输对象 (DTO) & 视图对象 (VO)**: + - 在 `com.yupi.project.model.dto.activationcode` 包下创建了以下 DTO: + - `RedeemCodeRequest.java`: 用户兑换激活码请求 (包含 `code`)。 + - `GenerateCodesRequest.java`: 管理员生成激活码请求 (包含 `count`, `value`, `expireTime`, `batchId`)。 + - `ActivationCodeQueryRequest.java`: 管理员查询激活码请求,继承自 `PageRequest`,包含多种筛选条件。 + - 在 `com.yupi.project.model.vo` 包下创建了 `ActivationCodeVO.java`: + - 包含激活码详细信息,并为已使用的激活码增加了 `userName` 字段(用于显示使用者用户名),日期时间字段使用 `@JsonFormat` 格式化。 + +- **API 控制器 (Controller)**: + - 创建 `ActivationCodeController.java`,定义了以下 RESTful API 接口: + - `POST /api/activation-code/redeem`: 用户兑换激活码接口。需要用户登录。调用 `activationCodeService.redeemCode`。 + - `POST /api/activation-code/admin/generate`: 管理员批量生成激活码接口。使用 `@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)` 进行权限控制。调用 `activationCodeService.generateCodes`。 + - `POST /api/activation-code/admin/list/page`: 管理员分页查询激活码接口。使用 `@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)` 进行权限控制。调用 `activationCodeService.page` 和 `getQueryWrapper`,并实现 `Page` 到 `Page` 的转换逻辑,包括填充 `userName`。 + +- **依赖注入与注解**: + - 正确使用了 `@Service`, `@Resource`, `@RestController`, `@RequestMapping`, `@PostMapping`, `@RequestBody`, `@AuthCheck`, `@Transactional`, `@Slf4j` 等注解。 + +- **项目结构**: 相关类已放置在规范的包路径下。 + +- **待办与后续**: + - 编写 API 文档 (Swagger/OpenAPI)。 + - 编写单元测试和集成测试。 + - 前端页面对接和开发。 + - 根据实际测试反馈进一步完善错误处理和日志。 \ No newline at end of file diff --git a/charging_web_app/package-lock.json b/charging_web_app/package-lock.json index 4f4eba1..97dc433 100644 --- a/charging_web_app/package-lock.json +++ b/charging_web_app/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@headlessui/react": "^2.2.3", + "antd": "^5.25.1", "axios": "^1.9.0", "next": "15.3.2", "react": "^19.0.0", @@ -54,6 +55,112 @@ "node": ">=6.0.0" } }, + "node_modules/@ant-design/colors": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-7.2.0.tgz", + "integrity": "sha512-bjTObSnZ9C/O8MB/B4OUtd/q9COomuJAR2SYfhxLyHvCKn4EKwCN3e+fWGMo7H5InAyV0wL17jdE9ALrdOW/6A==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.23.0", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-1.23.0.tgz", + "integrity": "sha512-7GAg9bD/iC9ikWatU9ym+P9ugJhi/WbsTWzcKN6T4gU0aehsprtke1UAaaSxxkjjmkJb3llet/rbUSLPgwlY4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.27.1.tgz", + "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "1.4.3", "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.4.3.tgz", @@ -87,6 +194,18 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -1034,6 +1153,155 @@ "node": ">=12.4.0" } }, + "node_modules/@rc-component/async-validator": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-5.0.4.tgz", + "integrity": "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", + "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@rc-component/qrcode/-/qrcode-1.0.0.tgz", + "integrity": "sha512-L+rZ4HXP2sJ1gHMGHjsg9jlYBX/SLN2D6OxP9Zn3qgtpMWtO2vUfxVFwiogHpAIqs54FnALxraUy/BCO1yRIgg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmmirror.com/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.2.6", + "resolved": "https://registry.npmmirror.com/@rc-component/trigger/-/trigger-2.2.6.tgz", + "integrity": "sha512-/9zuTnWwhQ3S3WT1T8BubuFTT46kvnXgaERR9f4BTKyn61/wpf/BvbImzYBubzJibU707FxwbKszLlHjcLiv1Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/@react-aria/focus": { "version": "3.20.2", "resolved": "https://registry.npmmirror.com/@react-aria/focus/-/focus-3.20.2.tgz", @@ -2072,6 +2340,71 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/antd": { + "version": "5.25.1", + "resolved": "https://registry.npmmirror.com/antd/-/antd-5.25.1.tgz", + "integrity": "sha512-4KC7KuPCjr0z3Vuw9DsF+ceqJaPLbuUI3lOX1sY8ix25ceamp+P8yxOmk3Y2JHCD2ZAhq+5IQ/DTJRN2adWYKQ==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.0", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.0.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.2.6", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.2.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.0", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.7", + "rc-slider": "~11.1.8", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.50.4", + "rc-tabs": "~15.6.1", + "rc-textarea": "~1.10.0", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.9.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", @@ -2496,6 +2829,12 @@ "node": ">=18" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz", @@ -2568,6 +2907,12 @@ "node": ">= 0.8" } }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", @@ -2618,6 +2963,15 @@ "node": ">=6.6.0" } }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.5.tgz", @@ -2651,7 +3005,6 @@ "version": "3.1.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2715,6 +3068,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.0.tgz", @@ -4667,6 +5026,15 @@ "dev": true, "license": "MIT" }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, "node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/json5/-/json5-1.0.2.tgz", @@ -5763,6 +6131,618 @@ "node": ">= 0.8" } }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmmirror.com/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmmirror.com/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmmirror.com/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmmirror.com/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/rc-drawer/-/rc-drawer-7.2.0.tgz", + "integrity": "sha512-9lOQ7kBekEJRdEpScHvtmEtXnAsy+NGDXiRWc2ZVC7QXAazNVbeT4EraQKYwCME8BJLa8Bxqxvs5swwyOepRwg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/rc-field-form/-/rc-field-form-2.7.0.tgz", + "integrity": "sha512-hgKsCay2taxzVnBPZl+1n4ZondsV78G++XVsMIJCAoioMjlMQR9YwAp7JZDIECzIu2Z66R+f4SFIRrO2DjDNAA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmmirror.com/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmmirror.com/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmmirror.com/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmmirror.com/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmmirror.com/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmmirror.com/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rc-overflow/-/rc-overflow-1.4.1.tgz", + "integrity": "sha512-3MoPQQPV1uKyOMVNd6SZfONi+f3st0r8PksexIdBTeIYbMX0Jr+k7pHEDvsXtR4BpCv90/Pv2MovVNhktKrwvw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmmirror.com/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmmirror.com/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/rc-segmented/-/rc-segmented-2.7.0.tgz", + "integrity": "sha512-liijAjXz+KnTRVnxxXG2sYDGd6iLL7VpGGdR8gwoxAXy2KglviKCxLWZdjKYJzYzGSUwKDSTdYk8brj54Bn5BA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmmirror.com/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.8", + "resolved": "https://registry.npmmirror.com/rc-slider/-/rc-slider-11.1.8.tgz", + "integrity": "sha512-2gg/72YFSpKP+Ja5AjC5DPL1YnV8DEITDQrcc1eASrUYjl0esptaBVJBh5nLTXCCp15eD8EuGjwezVGSHhs9tQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.50.5", + "resolved": "https://registry.npmmirror.com/rc-table/-/rc-table-7.50.5.tgz", + "integrity": "sha512-FDZu8aolhSYd3v9KOc3lZOVAU77wmRRu44R0Wfb8Oj1dXRUsloFaXMSl6f7yuWZUxArJTli7k8TEOX2mvhDl4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.6.1", + "resolved": "https://registry.npmmirror.com/rc-tabs/-/rc-tabs-15.6.1.tgz", + "integrity": "sha512-/HzDV1VqOsUWyuC0c6AkxVYFjvx9+rFPKZ32ejxX0Uc7QCzcEjTA9/xMgv4HemPKwzBNX8KhGVbbumDjnj92aA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/rc-textarea/-/rc-textarea-1.10.0.tgz", + "integrity": "sha512-ai9IkanNuyBS4x6sOL8qu/Ld40e6cEs6pgk93R+XLYg0mDSjNBGey6/ZpDs5+gNLD7urQ14po3V6Ck2dJLt9SA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmmirror.com/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmmirror.com/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmmirror.com/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.9.0", + "resolved": "https://registry.npmmirror.com/rc-upload/-/rc-upload-4.9.0.tgz", + "integrity": "sha512-pAzlPnyiFn1GCtEybEG2m9nXNzQyWXqWV2xFYCmDxjN9HzyjS5Pz2F+pbNdYw8mMJsixLEKLG0wVy9vOGxJMJA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmmirror.com/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/rc-virtual-list": { + "version": "3.18.6", + "resolved": "https://registry.npmmirror.com/rc-virtual-list/-/rc-virtual-list-3.18.6.tgz", + "integrity": "sha512-TQ5SsutL3McvWmmxqQtMIbfeoE3dGjJrRSfKekgby7WQMpPIFvv4ghytp5Z0s3D8Nik9i9YNOCqHBfk86AwgAA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmmirror.com/react/-/react-19.1.0.tgz", @@ -5844,6 +6824,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.10.tgz", @@ -6026,6 +7012,15 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, "node_modules/semver": { "version": "7.7.1", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.1.tgz", @@ -6318,6 +7313,12 @@ "node": ">=10.0.0" } }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6477,6 +7478,12 @@ } } }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", @@ -6544,6 +7551,15 @@ "node": ">=18" } }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, "node_modules/tinyglobby": { "version": "0.2.13", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.13.tgz", @@ -6602,6 +7618,12 @@ "node": ">=8.0" } }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/charging_web_app/package.json b/charging_web_app/package.json index 28f0fef..0a58d32 100644 --- a/charging_web_app/package.json +++ b/charging_web_app/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@headlessui/react": "^2.2.3", + "antd": "^5.25.1", "axios": "^1.9.0", "next": "15.3.2", "react": "^19.0.0", diff --git a/charging_web_app/src/app/(authenticated)/admin/activation-codes/page.tsx b/charging_web_app/src/app/(authenticated)/admin/activation-codes/page.tsx new file mode 100644 index 0000000..256abbf --- /dev/null +++ b/charging_web_app/src/app/(authenticated)/admin/activation-codes/page.tsx @@ -0,0 +1,382 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { useAuth } from '@/contexts/AuthContext'; +import { useRouter } from 'next/navigation'; +import { + Button, Card, Col, DatePicker, Form, Input, InputNumber, message, + Row, Select, Space, Table, Tabs, Typography, Modal +} from 'antd'; +import { api } from '@/services/api'; +import { BaseResponse } from '@/types/api'; +import LoadingSpinner from '@/components/LoadingSpinner'; + +const { Title, Text } = Typography; +const { TabPane } = Tabs; +const { RangePicker } = DatePicker; + +// --- TypeScript Interfaces --- +interface ActivationCodeVO { + id: number; + code: string; + value: number; + isUsed: number; + userId?: number | null; + userName?: string | null; + useTime?: string | null; + expireTime?: string | null; + batchId?: string | null; + createTime: string; + updateTime: string; +} + +interface GenerateCodesRequest { + count: number; + value: number; + expireTime?: string | null; // ISO string or null + batchId?: string | null; +} + +interface ActivationCodeQueryFormData extends ActivationCodeQueryRequest { + expireTimeRange?: [any, any]; // Using 'any' for now, ideally Dayjs or Moment tuple + createTimeRange?: [any, any]; +} + +interface ActivationCodeQueryRequest { + current?: number; + pageSize?: number; + code?: string; + isUsed?: number; + batchId?: string; + userId?: number; + valueMin?: number; + valueMax?: number; + expireTimeStart?: string; + expireTimeEnd?: string; + createTimeStart?: string; + createTimeEnd?: string; + sortField?: string; + sortOrder?: string; +} + +interface Page { + records: T[]; + total: number; + current: number; + size: number; +} + +const AdminActivationCodesPage = () => { + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); + const router = useRouter(); + + const [generateForm] = Form.useForm(); + const [queryForm] = Form.useForm(); + + const [generatedCodes, setGeneratedCodes] = useState([]); + const [isGenerating, setIsGenerating] = useState(false); + + const [activationCodes, setActivationCodes] = useState([]); + const [totalCodes, setTotalCodes] = useState(0); + const [codesLoading, setCodesLoading] = useState(false); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10, + }); + + // 添加删除Modal的状态 + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const [currentCodeId, setCurrentCodeId] = useState(null); + + // 权限检查和重定向 + useEffect(() => { + if (!authLoading) { + if (!isAuthenticated) { + router.replace('/login'); + } else if (user?.role !== 'admin') { + message.error('您没有权限访问此页面。'); + router.replace('/dashboard'); // 或者其他非管理员默认页 + } + } + }, [authLoading, isAuthenticated, user, router]); + + // --- Generate Codes Logic --- + const handleGenerateCodes = async (values: GenerateCodesRequest) => { + setIsGenerating(true); + setGeneratedCodes([]); + try { + const requestPayload = { + ...values, + expireTime: values.expireTime ? (values.expireTime as any).toISOString() : null, + }; + const response = await api.post>('/activation-code/admin/generate', requestPayload); + if (response.data.code === 0 && response.data.data) { + setGeneratedCodes(response.data.data); + message.success(`成功生成 ${response.data.data.length} 个激活码!`); + generateForm.resetFields(); + fetchActivationCodes({ current: 1 }); + } else { + message.error(response.data.message || '生成激活码失败'); + } + } catch (error: any) { + message.error(error.response?.data?.message || error.message || '生成激活码时发生错误'); + } + setIsGenerating(false); + }; + + // --- List Codes Logic --- + const fetchActivationCodes = useCallback(async (params: Partial = {}) => { + setCodesLoading(true); + try { + const formValues = queryForm.getFieldsValue(); + const queryParams: ActivationCodeQueryRequest = { + current: pagination.current, + pageSize: pagination.pageSize, + ...formValues, + ...params, + }; + + if (formValues.expireTimeRange && formValues.expireTimeRange.length === 2) { + queryParams.expireTimeStart = (formValues.expireTimeRange[0] as any).toISOString(); + queryParams.expireTimeEnd = (formValues.expireTimeRange[1] as any).toISOString(); + } + delete (queryParams as ActivationCodeQueryFormData).expireTimeRange; + + if (formValues.createTimeRange && formValues.createTimeRange.length === 2) { + queryParams.createTimeStart = (formValues.createTimeRange[0] as any).toISOString(); + queryParams.createTimeEnd = (formValues.createTimeRange[1] as any).toISOString(); + } + delete (queryParams as ActivationCodeQueryFormData).createTimeRange; + + const response = await api.post>>('/activation-code/admin/list/page', queryParams); + if (response.data.code === 0 && response.data.data) { + setActivationCodes(response.data.data.records || []); + setTotalCodes(response.data.data.total || 0); + } else { + message.error(response.data.message || '获取激活码列表失败'); + setActivationCodes([]); + setTotalCodes(0); + } + } catch (error: any) { + message.error(error.response?.data?.message || error.message || '获取激活码列表时发生错误'); + setActivationCodes([]); + setTotalCodes(0); + } + setCodesLoading(false); + }, [pagination, queryForm]); + + useEffect(() => { + if (isAuthenticated && user?.role === 'admin') { + fetchActivationCodes(); + } + }, [fetchActivationCodes, isAuthenticated, user]); + + const handleTableChange = (newPagination: any, filters: any, sorter: any) => { + const sortParams: Partial = {}; + if (sorter.field && sorter.order) { + sortParams.sortField = sorter.field as string; + sortParams.sortOrder = sorter.order; + } + const newPager = { + current: newPagination.current, + pageSize: newPagination.pageSize, + }; + setPagination(newPager); + fetchActivationCodes({ + ...newPager, + ...sortParams + }); + }; + + const onQueryFinish = (values: ActivationCodeQueryFormData) => { + const newPager = {...pagination, current: 1}; + setPagination(newPager); + fetchActivationCodes({current: 1}); + } + + const handleDeleteCode = (codeId: number) => { + console.log('handleDeleteCode called with ID:', codeId); + setCurrentCodeId(codeId); + setIsDeleteModalVisible(true); + console.log('Delete modal state set to visible'); + }; + + // 处理删除确认 + const handleDeleteConfirm = () => { + if (currentCodeId) { + console.log('Delete confirmed for ID:', currentCodeId); + + // 实际执行删除的代码 + api.post>( + `/activation-code/admin/delete`, + { id: currentCodeId } + ).then(response => { + if (response.data.code === 0 && response.data.data === true) { + message.success('激活码删除成功!'); + fetchActivationCodes({ current: pagination.current }); // 刷新当前页 + } else { + message.error(response.data.message || '删除失败'); + } + }).catch(error => { + message.error(error.message || '删除激活码时发生网络错误'); + }); + } + setIsDeleteModalVisible(false); + }; + + // 处理取消 + const handleDeleteCancel = () => { + console.log('Delete cancelled'); + setIsDeleteModalVisible(false); + }; + + // --- Columns for Activation Codes Table --- + const columns: any[] = [ + { title: 'ID', dataIndex: 'id', key: 'id', sorter: true }, + { title: '激活码', dataIndex: 'code', key: 'code' }, + { title: '面值', dataIndex: 'value', key: 'value', sorter: true, render: (val: number) => `¥${val.toFixed(2)}` }, + { + title: '状态', dataIndex: 'isUsed', key: 'isUsed', sorter: true, + render: (isUsed: number) => isUsed === 1 ? 已使用 : 未使用, + filters: [ + { text: '未使用', value: 0 }, + { text: '已使用', value: 1 }, + ], + }, + { title: '使用者ID', dataIndex: 'userId', key: 'userId', sorter: true }, + { title: '使用者', dataIndex: 'userName', key: 'userName' }, + { title: '使用时间', dataIndex: 'useTime', key: 'useTime', sorter: true, render: (time: string) => time ? new Date(time).toLocaleString() : '-' }, + { title: '过期时间', dataIndex: 'expireTime', key: 'expireTime', sorter: true, render: (time: string) => time ? new Date(time).toLocaleString() : '永不' }, + { title: '批次号', dataIndex: 'batchId', key: 'batchId' }, + { title: '创建时间', dataIndex: 'createTime', key: 'createTime', sorter: true, defaultSortOrder: 'descend', render: (time: string) => new Date(time).toLocaleString() }, + { + title: '操作', + key: 'action', + render: (text: any, record: ActivationCodeVO) => ( + + + + ), + }, + ]; + + if (authLoading) return ; + if (!isAuthenticated || user?.role !== 'admin') return null; // 或显示权限不足的组件 + + return ( +
+ 激活码管理中心 + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ {generatedCodes.length > 0 && ( + + + {generatedCodes.map((code, index) => ( + {code} + ))} + + 共生成 {generatedCodes.length} 个。请妥善保管。 + + )} +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + {/* 删除确认对话框 */} + +

您确定要删除这个激活码 (ID: {currentCodeId}) 吗?此操作不可恢复。

+
+ + ); +}; + +export default AdminActivationCodesPage; \ No newline at end of file diff --git a/charging_web_app/src/app/(authenticated)/admin/dashboard/page.tsx b/charging_web_app/src/app/(authenticated)/admin/dashboard/page.tsx index d905cdd..cabd5c4 100644 --- a/charging_web_app/src/app/(authenticated)/admin/dashboard/page.tsx +++ b/charging_web_app/src/app/(authenticated)/admin/dashboard/page.tsx @@ -5,8 +5,9 @@ import { useAuth } from '@/contexts/AuthContext'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import LoadingSpinner from '@/components/LoadingSpinner'; -import { FiUsers, FiCpu, FiBarChart2, FiGrid, FiUserCheck, FiList, FiTerminal, FiSettings } from 'react-icons/fi'; -import { api } from '@/utils/axios'; +import { FiUsers, FiCpu, FiBarChart2, FiGrid, FiUserCheck, FiList, FiTerminal, FiSettings, FiGift } from 'react-icons/fi'; +import { api } from '@/services/api'; +import { BaseResponse } from '@/types/api'; import { AxiosError, AxiosResponse } from 'axios'; // 匹配后端 AdminDashboardStatsVO @@ -28,13 +29,6 @@ interface AdminStats { // availableParkingSpots: number; } -// 建议定义一个通用的 BaseResponse 接口 -interface BackendBaseResponse { - data: T; - message: string; - code?: number; -} - const AdminDashboardPage = () => { const { user, isLoading, isAuthenticated } = useAuth(); const router = useRouter(); @@ -56,23 +50,22 @@ const AdminDashboardPage = () => { if (isAuthenticated && user?.role === 'admin') { setStatsLoading(true); setStatsError(null); - api.get>('/admin/stats/summary') // 使用 BackendBaseResponse - .then((response: AxiosResponse>) => { - if (response.data && response.data.data) { - // 后端返回的 totalRevenue 对应前端接口的 totalRevenueToday - // 后端返回的 activeSessions 对应前端接口的 totalChargingSessionsToday - // 但为了保持一致性,我们最好让前端AdminStats接口的字段名与后端VO的字段名一致 - // 这里暂时直接使用后端返回的字段名,前端 JSX 部分也需要对应调整 + api.get>('/admin/stats/summary') + .then((response: AxiosResponse>) => { + if (response.data && response.data.code === 0 && response.data.data) { setAdminStats(response.data.data); } else { - console.error("Failed to fetch admin stats or data is null/malformed:", response); - setStatsError("加载统计数据失败: 数据为空或格式错误。"); + console.error("Failed to fetch admin stats or data is null/malformed:", response.data?.message); + setStatsError(response.data?.message || "加载统计数据失败: 数据为空或格式错误。"); setAdminStats(null); } }) - .catch((error: AxiosError) => { + .catch((error: AxiosError>) => { console.error("Failed to fetch admin stats:", error); - if (error.response) { + const backendMessage = error.response?.data?.message; + if (backendMessage) { + setStatsError(`加载统计数据失败: ${backendMessage}`); + } else if (error.response) { setStatsError(`加载统计数据失败: ${error.response.status} ${error.response.statusText}`); } else if (error.request) { setStatsError("加载统计数据失败: 未收到服务器响应。"); @@ -200,7 +193,6 @@ const AdminDashboardPage = () => { description="查看所有充电会话记录、详情" href="/admin/sessions" icon={} - disabled={true} /> { href="/admin/user-management" icon={} /> + } + /> (undefined); @@ -34,6 +37,7 @@ export default function DashboardPage() { const [dataLoading, setDataLoading] = useState(true); const [error, setError] = useState(null); const [isStopping, setIsStopping] = useState(false); + const [isRedeemModalVisible, setIsRedeemModalVisible] = useState(false); // Modal状态 useEffect(() => { if (!authLoading && !isAuthenticated) { @@ -51,44 +55,30 @@ export default function DashboardPage() { setDataLoading(true); setError(null); try { - // 使用 Promise.allSettled 来处理部分请求失败的情况,如果需要 const [sessionResult, statsResult] = await Promise.all([ api.get>('/session/my/active'), api.get>('/user/stats/mine') ]); - if (sessionResult.data && sessionResult.data.code === 0) { // 假设 code 0 代表成功 - console.log("Active session response:", sessionResult.data); - setActiveSession(sessionResult.data.data); // data 可以是 null + if (sessionResult.data && sessionResult.data.code === 0) { + setActiveSession(sessionResult.data.data); } else { - // setActiveSession(null); // 或者根据后端错误信息设置 - console.warn("Failed to fetch active session or no active session:", sessionResult.data?.message); - setActiveSession(null); // 如果没有活动会话,后端data.data可能为null,是正常情况 + setActiveSession(null); } if (statsResult.data && statsResult.data.code === 0 && statsResult.data.data) { - console.log("User stats response:", statsResult.data.data); - // 检查后端返回的是monthlyCharges还是monthlySessions,并进行适当转换 const statsData = statsResult.data.data; - if ('monthlyCharges' in statsData && statsData.monthlyCharges !== undefined) { - // 如果后端返回的是monthlyCharges字段,映射到monthlySessions - setUserStats({ - monthlySessions: statsData.monthlyCharges as number, - monthlySpending: statsData.monthlySpending - }); - } else { - // 否则假定数据结构已匹配接口 - setUserStats(statsData); - } + setUserStats({ + monthlySessions: (statsData as any).monthlyCharges !== undefined ? (statsData as any).monthlyCharges : statsData.monthlySessions, + monthlySpending: statsData.monthlySpending + }); } else { setUserStats(null); - console.error("Failed to fetch user stats:", statsResult.data?.message); - // setError(prev => prev ? prev + '\n' + (statsResult.data?.message || '获取用户统计失败') : (statsResult.data?.message || '获取用户统计失败')); } } catch (err: any) { console.error("Error fetching dashboard data:", err); - const errorMessage = err.response?.data?.message || err.message || '获取仪表盘数据失败,请稍后再试。'; + const errorMessage = err.response?.data?.message || err.message || '获取仪表盘数据失败'; setError(prevError => prevError ? `${prevError}\n${errorMessage}` : errorMessage); setActiveSession(null); setUserStats(null); @@ -100,15 +90,14 @@ export default function DashboardPage() { if (isAuthenticated && user?.role === 'user') { fetchData(); } else if (!authLoading && !isAuthenticated) { - // Handle cases where user is not authenticated and not loading - setDataLoading(false); // Stop loading if not fetching + setDataLoading(false); } }, [fetchData, isAuthenticated, user, authLoading]); // 直接从仪表盘停止充电 const handleStopCharging = async () => { if (!activeSession || !activeSession.id) { - setError("无法停止充电:无效的会话信息。"); + message.error("无法停止充电:无效的会话信息。"); // 使用 antd message return; } setIsStopping(true); @@ -116,14 +105,17 @@ export default function DashboardPage() { try { const response = await api.post>(`/session/stop`, { sessionId: activeSession.id }); if (response.data && response.data.code === 0) { - // 停止成功后重新获取数据 fetchData(); + message.success('已成功发送停止充电请求。'); } else { setError(response.data?.message || "停止充电请求失败"); + message.error(response.data?.message || "停止充电请求失败"); } } catch (err: any) { console.error("Error stopping charging session:", err); - setError(err.response?.data?.message || err.message || "停止充电时发生错误"); + const msg = err.response?.data?.message || err.message || "停止充电时发生错误"; + setError(msg); + message.error(msg); } finally { setIsStopping(false); } @@ -168,19 +160,32 @@ export default function DashboardPage() { return '--'; }; + const handleRedeemSuccess = () => { + setIsRedeemModalVisible(false); + checkAuth(); // 刷新用户数据,尤其是余额 + // fetchData(); // 也可以选择刷新整个仪表盘数据,如果余额不通过checkAuth更新 + message.success('激活码已成功兑换,余额已更新!'); + }; + return (

用户中心

- {/* Logout button is in global layout, no need here if using that strategy */} +

欢迎, {user.username}!

用户ID: {user.id}

角色: {user.role === 'user' ? '普通用户' : user.role}

-

账户余额: ¥{user.balance?.toFixed(2) ?? 'N/A'}

+

账户余额: ¥{user.balance?.toFixed(2) ?? 'N/A'}

{error && ( @@ -286,6 +291,20 @@ export default function DashboardPage() {
+ + setIsRedeemModalVisible(false)} + footer={null} // RedeemCodeForm 内部有自己的提交和取消逻辑 + destroyOnClose // 关闭时销毁内部组件状态 + > + setIsRedeemModalVisible(false)} // Modal 的取消按钮也调用此 handler + /> + + ); } \ No newline at end of file diff --git a/charging_web_app/src/app/(authenticated)/redeem-code/page.tsx b/charging_web_app/src/app/(authenticated)/redeem-code/page.tsx new file mode 100644 index 0000000..c5db6f6 --- /dev/null +++ b/charging_web_app/src/app/(authenticated)/redeem-code/page.tsx @@ -0,0 +1,107 @@ +import React, { useState, ChangeEvent, FormEvent } from 'react'; +import { Input, Button, Card, Typography, message } from 'antd'; +import { useAuth } from '@/contexts/AuthContext'; +import { api as apiService } from '@/services/api'; + +// 暂时在此处定义 BaseResponse,因为 @/services/api.ts 未导出它 +// 理想情况下,它应该在全局类型定义文件中或与api实例一起导出 +export interface BaseResponse { + code: number; + data: T; + message?: string; +} + +const { Title } = Typography; + +const RedeemActivationCodePage = () => { + const [code, setCode] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const { isAuthenticated, user } = useAuth(); + + const handleInputChange = (e: ChangeEvent) => { + setCode(e.target.value.trim()); + setError(null); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!isAuthenticated) { + message.error('请先登录后再兑换激活码。'); + return; + } + + if (!code) { + setError('请输入激活码。'); + message.error('请输入激活码。'); + return; + } + setIsLoading(true); + setError(null); + + try { + const response = await apiService.post>('/api/activation-code/redeem', + { code } + ); + + if (response.data && response.data.code === 0 && response.data.data === true) { + message.success('激活码兑换成功!您的余额已更新。'); + setCode(''); + } else { + const errorMessage = response.data?.message || '兑换失败,请稍后再试。'; + setError(errorMessage); + message.error(errorMessage); + } + } catch (err: any) { + console.error('Redeem code error:', err); + const errorMessage = err.response?.data?.message || err.message || '兑换过程中发生错误,请检查网络或联系管理员。'; + setError(errorMessage); + message.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + 兑换激活码 + +
+ + {error && ( + + {error} + + )} + + {!isAuthenticated && ( + + 请先登录以兑换激活码。 + + )} + +
+
+ ); +}; + +export default RedeemActivationCodePage; \ No newline at end of file diff --git a/charging_web_app/src/components/RedeemCodeForm.tsx b/charging_web_app/src/components/RedeemCodeForm.tsx new file mode 100644 index 0000000..c125ac6 --- /dev/null +++ b/charging_web_app/src/components/RedeemCodeForm.tsx @@ -0,0 +1,109 @@ +import React, { useState, ChangeEvent, FormEvent } from 'react'; +import { Input, Button, Typography, message } from 'antd'; +import { useAuth } from '@/contexts/AuthContext'; +import { api as apiService } from '@/services/api'; +import { BaseResponse } from '@/types/api'; // 假设 BaseResponse 移到了这里 + +interface RedeemCodeFormProps { + onSuccess?: () => void; // 兑换成功后的回调 + onCancel?: () => void; // 可选的取消/关闭回调 +} + +const RedeemCodeForm: React.FC = ({ onSuccess, onCancel }) => { + const [code, setCode] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const { isAuthenticated, checkAuth } = useAuth(); // checkAuth 用于成功后刷新用户信息 + + const handleInputChange = (e: ChangeEvent) => { + setCode(e.target.value.trim()); + setError(null); + }; + + const handleSubmit = async (e?: FormEvent) => { + if (e) e.preventDefault(); + + if (!isAuthenticated) { + message.error('请先登录后再兑换激活码。'); + return; + } + if (!code) { + setError('请输入激活码。'); + message.error('请输入激活码。'); + return; + } + setIsLoading(true); + setError(null); + + try { + const response = await apiService.post>('/activation-code/redeem', + { code } + ); + + console.log('Backend response for redeem:', response.data); + + if (response.data && response.data.code === 0 && response.data.data === true) { + message.success('激活码兑换成功!您的余额已更新。'); + setCode(''); + await checkAuth(); // 刷新用户信息,特别是余额 + if (onSuccess) onSuccess(); + } else { + const errorMessage = response.data?.message || '兑换失败,请稍后再试。'; + setError(errorMessage); + message.error(errorMessage); + } + } catch (err: any) { + console.error('Redeem code error:', err); + const errorMessage = err.response?.data?.message || err.message || '兑换过程中发生错误。'; + setError(errorMessage); + message.error(errorMessage); + } + setIsLoading(false); + }; + + return ( +
+ + {error && ( + + {error} + + )} + + {onCancel && ( + + )} + {!isAuthenticated && ( + + 请先登录以兑换激活码。 + + )} + + ); +}; + +export default RedeemCodeForm; \ No newline at end of file diff --git a/charging_web_app/src/contexts/AuthContext.tsx b/charging_web_app/src/contexts/AuthContext.tsx index b526f9d..66bdf47 100644 --- a/charging_web_app/src/contexts/AuthContext.tsx +++ b/charging_web_app/src/contexts/AuthContext.tsx @@ -44,6 +44,7 @@ export const AuthProvider: React.FC = ({ children }) => { try { const response = await api.get('/user/current'); if (response.data.code === 0 && response.data.data) { + console.log('Data from /user/current for setUser:', JSON.stringify(response.data.data)); setUser(response.data.data); console.log('User authenticated:', response.data.data.username, 'Role:', response.data.data.role); } else { diff --git a/charging_web_app/src/types/api.ts b/charging_web_app/src/types/api.ts new file mode 100644 index 0000000..ee231d8 --- /dev/null +++ b/charging_web_app/src/types/api.ts @@ -0,0 +1,10 @@ +/** + * 通用的后端响应体结构 + */ +export interface BaseResponse { + code: number; // 响应状态码,0表示成功 + data: T; // 泛型数据,可以是任何类型 + message?: string; // 可选的消息说明 +} + +// 你可以在这里添加其他通用的API相关类型定义 \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/controller/ActivationCodeController.java b/springboot-init-main/src/main/java/com/yupi/project/controller/ActivationCodeController.java new file mode 100644 index 0000000..002c049 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/controller/ActivationCodeController.java @@ -0,0 +1,161 @@ +package com.yupi.project.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.yupi.project.annotation.AuthCheck; +import com.yupi.project.common.BaseResponse; +import com.yupi.project.common.DeleteRequest; +import com.yupi.project.common.ErrorCode; +import com.yupi.project.common.ResultUtils; +import com.yupi.project.constant.UserConstant; +import com.yupi.project.exception.BusinessException; +import com.yupi.project.exception.ThrowUtils; +import com.yupi.project.model.dto.activationcode.ActivationCodeQueryRequest; +import com.yupi.project.model.dto.activationcode.GenerateCodesRequest; +import com.yupi.project.model.dto.activationcode.RedeemCodeRequest; +import com.yupi.project.model.dto.common.IdRequest; +import com.yupi.project.model.entity.ActivationCode; +import com.yupi.project.model.entity.User; +import com.yupi.project.model.vo.ActivationCodeVO; +import com.yupi.project.service.ActivationCodeService; +import com.yupi.project.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/activation-code") +@Slf4j +public class ActivationCodeController { + + @Resource + private ActivationCodeService activationCodeService; + + @Resource + private UserService userService; + + // region 用户操作 + + /** + * 用户兑换激活码 + * + * @param redeemCodeRequest 激活码请求 + * @param request HTTP请求 + * @return 是否成功 + */ + @PostMapping("/redeem") + public BaseResponse redeemActivationCode(@RequestBody RedeemCodeRequest redeemCodeRequest, HttpServletRequest request) { + if (redeemCodeRequest == null || redeemCodeRequest.getCode() == null || redeemCodeRequest.getCode().trim().isEmpty()) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "激活码不能为空"); + } + User loginUser = userService.getCurrentUser(request); + if (loginUser == null) { + throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "用户未登录或登录已失效"); + } + boolean result = activationCodeService.redeemCode(loginUser.getId(), redeemCodeRequest.getCode()); + return ResultUtils.success(result); + } + + // endregion + + // region 管理员操作 + + /** + * 管理员批量生成激活码 + * + * @param generateCodesRequest 生成请求 + * @return 生成的激活码列表 (只返回code,避免信息过多,或根据需要返回VO) + */ + @PostMapping("/admin/generate") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse> generateActivationCodes(@RequestBody GenerateCodesRequest generateCodesRequest) { + if (generateCodesRequest == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + if (generateCodesRequest.getCount() == null || generateCodesRequest.getCount() <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "生成数量必须大于0"); + } + if (generateCodesRequest.getValue() == null || generateCodesRequest.getValue().signum() <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "面值必须大于0"); + } + + try { + List codes = activationCodeService.generateCodes( + generateCodesRequest.getCount(), + generateCodesRequest.getValue(), + generateCodesRequest.getExpireTime(), + generateCodesRequest.getBatchId() + ); + List voList = codes.stream() + .map(code -> { + ActivationCodeVO vo = new ActivationCodeVO(); + BeanUtils.copyProperties(code, vo); + return vo; + }) + .collect(Collectors.toList()); + return ResultUtils.success(voList); + } catch (Exception e) { + log.error("Error generating activation codes", e); + throw new BusinessException(ErrorCode.SYSTEM_ERROR, e.getMessage()); + } + } + + /** + * 管理员分页查询激活码 + * + * @param activationCodeQueryRequest 查询条件 + * @return 分页的激活码视图对象 + */ + @PostMapping("/admin/list/page") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse> listActivationCodesByPage(@RequestBody ActivationCodeQueryRequest activationCodeQueryRequest) { + if (activationCodeQueryRequest == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + long current = activationCodeQueryRequest.getCurrent(); + long size = activationCodeQueryRequest.getPageSize(); + ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR); + + Page activationCodePage = activationCodeService.page(new Page<>(current, size), + activationCodeService.getQueryWrapper(activationCodeQueryRequest)); + + Page activationCodeVOPage = new Page<>(activationCodePage.getCurrent(), activationCodePage.getSize(), activationCodePage.getTotal()); + List activationCodeVOList = activationCodePage.getRecords().stream().map(activationCode -> { + ActivationCodeVO activationCodeVO = new ActivationCodeVO(); + BeanUtils.copyProperties(activationCode, activationCodeVO); + if (activationCode.getIsUsed() == 1 && activationCode.getUserId() != null) { + User user = userService.getById(activationCode.getUserId()); + if (user != null) { + activationCodeVO.setUserName(user.getUsername()); + } + } + return activationCodeVO; + }).collect(Collectors.toList()); + activationCodeVOPage.setRecords(activationCodeVOList); + + return ResultUtils.success(activationCodeVOPage); + } + + /** + * 管理员删除激活码 + * + * @param idRequest 包含激活码ID的请求体 + * @param request HTTP请求 (用于权限校验等) + * @return 是否成功 + */ + @PostMapping("/admin/delete") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse deleteActivationCode(@RequestBody IdRequest idRequest, HttpServletRequest request) { + if (idRequest == null || idRequest.getId() == null || idRequest.getId() <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "激活码ID不能为空"); + } + boolean result = activationCodeService.deleteCode(idRequest.getId()); + return ResultUtils.success(result); + } + + // endregion +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/mapper/ActivationCodeMapper.java b/springboot-init-main/src/main/java/com/yupi/project/mapper/ActivationCodeMapper.java new file mode 100644 index 0000000..c2bd343 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/mapper/ActivationCodeMapper.java @@ -0,0 +1,12 @@ +package com.yupi.project.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.yupi.project.model.entity.ActivationCode; + +/** +* @description 针对表【activation_code(激活码表)】的数据库操作Mapper +* @createDate 2024-08-04 10:00:00 +* @Entity com.yupi.project.model.entity.ActivationCode +*/ +public interface ActivationCodeMapper extends BaseMapper { +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/dto/activationcode/ActivationCodeQueryRequest.java b/springboot-init-main/src/main/java/com/yupi/project/model/dto/activationcode/ActivationCodeQueryRequest.java new file mode 100644 index 0000000..6b29cc1 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/dto/activationcode/ActivationCodeQueryRequest.java @@ -0,0 +1,70 @@ +package com.yupi.project.model.dto.activationcode; + +import com.yupi.project.common.PageRequest; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 激活码查询请求 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class ActivationCodeQueryRequest extends PageRequest implements Serializable { + + /** + * 激活码 (模糊匹配) + */ + private String code; + + /** + * 是否已使用 (0 - 未使用, 1 - 已使用) + */ + private Integer isUsed; + + /** + * 批次号 + */ + private String batchId; + + /** + * 使用者用户ID + */ + private Long userId; + + /** + * 面值下限 (用于范围查询) + */ + private BigDecimal valueMin; + + /** + * 面值上限 (用于范围查询) + */ + private BigDecimal valueMax; + + /** + * 过期时间开始 (用于范围查询) + */ + private LocalDateTime expireTimeStart; + + /** + * 过期时间结束 (用于范围查询) + */ + private LocalDateTime expireTimeEnd; + + /** + * 创建时间开始 (用于范围查询) + */ + private LocalDateTime createTimeStart; + + /** + * 创建时间结束 (用于范围查询) + */ + private LocalDateTime createTimeEnd; + + + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/dto/activationcode/GenerateCodesRequest.java b/springboot-init-main/src/main/java/com/yupi/project/model/dto/activationcode/GenerateCodesRequest.java new file mode 100644 index 0000000..982bae7 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/dto/activationcode/GenerateCodesRequest.java @@ -0,0 +1,36 @@ +package com.yupi.project.model.dto.activationcode; + +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 管理员生成激活码请求 + */ +@Data +public class GenerateCodesRequest implements Serializable { + + /** + * 生成数量 + */ + private Integer count; + + /** + * 激活码面值 + */ + private BigDecimal value; + + /** + * 过期时间 (可选, null 表示永不过期) + */ + private LocalDateTime expireTime; + + /** + * 批次号 (可选, 用于追踪) + */ + private String batchId; + + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/dto/activationcode/RedeemCodeRequest.java b/springboot-init-main/src/main/java/com/yupi/project/model/dto/activationcode/RedeemCodeRequest.java new file mode 100644 index 0000000..d286e4e --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/dto/activationcode/RedeemCodeRequest.java @@ -0,0 +1,19 @@ +package com.yupi.project.model.dto.activationcode; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 用户兑换激活码请求 + */ +@Data +public class RedeemCodeRequest implements Serializable { + + /** + * 激活码 + */ + private String code; + + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/dto/common/IdRequest.java b/springboot-init-main/src/main/java/com/yupi/project/model/dto/common/IdRequest.java new file mode 100644 index 0000000..9916db1 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/dto/common/IdRequest.java @@ -0,0 +1,13 @@ +package com.yupi.project.model.dto.common; + +import lombok.Data; +import java.io.Serializable; + +/** + * 通用ID请求DTO + */ +@Data +public class IdRequest implements Serializable { + private Long id; + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/entity/ActivationCode.java b/springboot-init-main/src/main/java/com/yupi/project/model/entity/ActivationCode.java new file mode 100644 index 0000000..986b165 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/entity/ActivationCode.java @@ -0,0 +1,80 @@ +package com.yupi.project.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 激活码表实体类 + */ +@TableName(value = "activation_code") +@Data +public class ActivationCode implements Serializable { + + /** + * 主键ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 激活码字符串,确保全局唯一 + */ + private String code; + + /** + * 激活码对应的面值(金额) + */ + private BigDecimal value; + + /** + * 是否已使用:0-未使用,1-已使用 + */ + private Integer isUsed; + + /** + * 使用者用户ID (如果已使用) + */ + private Long userId; + + /** + * 激活码使用时间 (如果已使用) + */ + private LocalDateTime useTime; + + /** + * 激活码过期时间 (NULL表示永不过期) + */ + private LocalDateTime expireTime; + + /** + * 生成批次号,方便管理员追踪管理 + */ + private String batchId; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + + /** + * 逻辑删除标志:0-未删除,1-已删除 + */ + @TableLogic + private Integer isDelete; + + @TableField(exist = false) + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/vo/ActivationCodeVO.java b/springboot-init-main/src/main/java/com/yupi/project/model/vo/ActivationCodeVO.java new file mode 100644 index 0000000..2aacf26 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/vo/ActivationCodeVO.java @@ -0,0 +1,76 @@ +package com.yupi.project.model.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 激活码视图对象 + */ +@Data +public class ActivationCodeVO implements Serializable { + + /** + * 主键 + */ + private Long id; + + /** + * 激活码 + */ + private String code; + + /** + * 面值 + */ + private BigDecimal value; + + /** + * 是否已使用 (0 - 未使用, 1 - 已使用) + */ + private Integer isUsed; + + /** + * 使用者用户ID (如果已使用) + */ + private Long userId; + + /** + * 使用者用户名 (如果已使用, 需要额外查询填充) + */ + private String userName; + + /** + * 使用时间 (如果已使用) + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime useTime; + + /** + * 过期时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime expireTime; + + /** + * 批次号 + */ + private String batchId; + + /** + * 创建时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; + + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/service/ActivationCodeService.java b/springboot-init-main/src/main/java/com/yupi/project/service/ActivationCodeService.java new file mode 100644 index 0000000..b101975 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/service/ActivationCodeService.java @@ -0,0 +1,58 @@ +package com.yupi.project.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.yupi.project.model.entity.ActivationCode; +import com.yupi.project.model.dto.activationcode.ActivationCodeQueryRequest; +// TODO: Import Page, ActivationCodeVO, ActivationCodeQueryRequest when created + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 激活码服务接口 + */ +public interface ActivationCodeService extends IService { + + /** + * (管理员) 批量生成激活码。 + * + * @param count 生成数量 + * @param value 单个激活码的面值 + * @param expireTime 过期时间 (null 表示永不过期) + * @param batchId 批次ID (可由调用者传入或在Service中生成) + * @return 生成的激活码列表 + * @throws Exception 如果生成过程中发生错误 + */ + List generateCodes(int count, BigDecimal value, LocalDateTime expireTime, String batchId) throws Exception; + + /** + * (用户) 兑换激活码。 + * + * @param currentUserId 当前登录用户的ID + * @param code 激活码字符串 + * @return true 如果兑换成功,并增加了用户余额 + * @throws com.yupi.project.exception.BusinessException 如果激活码无效、已使用、已过期或兑换失败 + */ + boolean redeemCode(Long currentUserId, String code); + + /** + * 构建查询条件 + * + * @param activationCodeQueryRequest 查询请求 + * @return QueryWrapper + */ + QueryWrapper getQueryWrapper(ActivationCodeQueryRequest activationCodeQueryRequest); + + /** + * 根据ID删除激活码 (逻辑删除) + * + * @param codeId 激活码ID + * @return 是否删除成功 + */ + boolean deleteCode(Long codeId); + + // TODO: Add method for listing codes (admin) + // Page listCodes(ActivationCodeQueryRequest queryRequest); +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/service/impl/ActivationCodeServiceImpl.java b/springboot-init-main/src/main/java/com/yupi/project/service/impl/ActivationCodeServiceImpl.java new file mode 100644 index 0000000..6beda45 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/service/impl/ActivationCodeServiceImpl.java @@ -0,0 +1,215 @@ +package com.yupi.project.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.yupi.project.common.ErrorCode; +import com.yupi.project.exception.BusinessException; +import com.yupi.project.mapper.ActivationCodeMapper; +import com.yupi.project.model.dto.activationcode.ActivationCodeQueryRequest; +import com.yupi.project.model.entity.ActivationCode; +import com.yupi.project.service.ActivationCodeService; +import com.yupi.project.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@Slf4j +public class ActivationCodeServiceImpl extends ServiceImpl implements ActivationCodeService { + + @Resource + private UserService userService; + + // 通常不直接注入Mapper,而是通过继承的ServiceImpl的方法操作,但如果需要复杂查询可以注入 + // @Resource + // private ActivationCodeMapper activationCodeMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public List generateCodes(int count, BigDecimal value, LocalDateTime expireTime, String batchId) throws Exception { + if (count <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "生成数量必须大于0"); + } + if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "激活码面值必须大于0"); + } + + List generatedCodes = new ArrayList<>(); + String effectiveBatchId = (batchId == null || batchId.isEmpty()) ? UUID.randomUUID().toString() : batchId; + + for (int i = 0; i < count; i++) { + ActivationCode activationCode = new ActivationCode(); + // 生成唯一code的简单策略,实际项目中可能需要更复杂的防碰撞机制或预生成库 + String uniqueCode = UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(); + // TODO: 考虑code冲突的可能性,虽然UUID冲突概率极低,但严格来说需要循环检查或数据库唯一约束处理 + // 在高并发下,先生成再批量插入,并处理唯一约束异常可能是更好的方式 + + activationCode.setCode(uniqueCode); + activationCode.setValue(value); + activationCode.setExpireTime(expireTime); + activationCode.setBatchId(effectiveBatchId); + activationCode.setIsUsed(0); // 0 for not used + activationCode.setIsDelete(0); // 0 for not deleted + // createTime and updateTime will be handled by DB default or MyBatis Plus fill strategy + generatedCodes.add(activationCode); + } + + boolean saveBatchResult = this.saveBatch(generatedCodes); + if (!saveBatchResult) { + log.error("Failed to batch save activation codes for batchId: {}", effectiveBatchId); + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "批量生成激活码失败"); + } + log.info("Successfully generated {} activation codes with value {}, batchId: {}.", count, value, effectiveBatchId); + return generatedCodes; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean redeemCode(Long currentUserId, String code) { + if (currentUserId == null || currentUserId <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "无效的用户ID"); + } + if (code == null || code.trim().isEmpty()) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "激活码不能为空"); + } + + // 1. 查询激活码 + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("code", code.trim()); + // is_delete = 0 会由 @TableLogic 自动处理 + ActivationCode activationCode = this.getOne(queryWrapper); + + if (activationCode == null) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "无效的激活码"); + } + + // 2. 检查是否已使用 + if (activationCode.getIsUsed() == 1) { + throw new BusinessException(ErrorCode.OPERATION_ERROR, "此激活码已被使用"); + } + + // 3. 检查是否过期 + if (activationCode.getExpireTime() != null && LocalDateTime.now().isAfter(activationCode.getExpireTime())) { + throw new BusinessException(ErrorCode.OPERATION_ERROR, "此激活码已过期"); + } + + // 4. 增加用户余额 + boolean increaseSuccess = userService.increaseBalance(currentUserId, activationCode.getValue()); + if (!increaseSuccess) { + // increaseBalance 内部应该会抛出异常如果失败(如用户不存在),或者返回false + log.error("Failed to increase balance for user {} while redeeming code {}", currentUserId, code); + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "余额充值失败,请稍后再试"); + } + + // 5. 更新激活码状态 + activationCode.setIsUsed(1); + activationCode.setUserId(currentUserId); + activationCode.setUseTime(LocalDateTime.now()); + // updateTime 会自动更新 + + boolean updateResult = this.updateById(activationCode); + if (!updateResult) { + log.error("Failed to update activation code {} status after redeeming by user {}. Potential inconsistency!", code, currentUserId); + // 由于余额已增加,但激活码状态更新失败,这是一个危险状态。事务应回滚。 + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "更新激活码状态失败,请联系客服"); + } + + log.info("User {} successfully redeemed activation code {}. Value: {}", currentUserId, code, activationCode.getValue()); + return true; + } + + @Override + public QueryWrapper getQueryWrapper(ActivationCodeQueryRequest activationCodeQueryRequest) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + if (activationCodeQueryRequest == null) { + return queryWrapper; + } + + String code = activationCodeQueryRequest.getCode(); + Integer isUsed = activationCodeQueryRequest.getIsUsed(); + String batchId = activationCodeQueryRequest.getBatchId(); + Long userId = activationCodeQueryRequest.getUserId(); + BigDecimal valueMin = activationCodeQueryRequest.getValueMin(); + BigDecimal valueMax = activationCodeQueryRequest.getValueMax(); + LocalDateTime expireTimeStart = activationCodeQueryRequest.getExpireTimeStart(); + LocalDateTime expireTimeEnd = activationCodeQueryRequest.getExpireTimeEnd(); + LocalDateTime createTimeStart = activationCodeQueryRequest.getCreateTimeStart(); + LocalDateTime createTimeEnd = activationCodeQueryRequest.getCreateTimeEnd(); + String sortField = activationCodeQueryRequest.getSortField(); + String sortOrder = activationCodeQueryRequest.getSortOrder(); + + // 拼接查询条件 + if (StringUtils.isNotBlank(code)) { + queryWrapper.like("code", code); + } + if (isUsed != null) { + queryWrapper.eq("isUsed", isUsed); + } + if (StringUtils.isNotBlank(batchId)) { + queryWrapper.eq("batchId", batchId); + } + if (userId != null && userId > 0) { + queryWrapper.eq("userId", userId); + } + if (valueMin != null) { + queryWrapper.ge("value", valueMin); + } + if (valueMax != null) { + queryWrapper.le("value", valueMax); + } + if (expireTimeStart != null) { + queryWrapper.ge("expireTime", expireTimeStart); + } + if (expireTimeEnd != null) { + queryWrapper.le("expireTime", expireTimeEnd); + } + if (createTimeStart != null) { + queryWrapper.ge("createTime", createTimeStart); + } + if (createTimeEnd != null) { + queryWrapper.le("createTime", createTimeEnd); + } + // 默认按创建时间降序 + if (StringUtils.isNotBlank(sortField)) { + queryWrapper.orderBy(true, "ascend".equalsIgnoreCase(sortOrder), sortField); + } else { + queryWrapper.orderByDesc("create_time"); + } + + return queryWrapper; + } + + @Override + @Transactional + public boolean deleteCode(Long codeId) { + if (codeId == null || codeId <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "无效的激活码ID"); + } + // 检查激活码是否存在,这步可选,因为removeById如果找不到也不会报错,但返回false + ActivationCode existingCode = this.getById(codeId); + if (existingCode == null || existingCode.getIsDelete() == 1) { // 已经是逻辑删除状态 + // 可以选择抛出未找到错误,或者认为已经是"已删除"状态,返回true + // throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "激活码不存在或已被删除"); + log.warn("Attempted to delete a non-existent or already deleted activation code with ID: {}", codeId); + return true; // 或者 false,取决于业务定义,这里认为已经是目标状态所以返回true + } + + // 使用MyBatis Plus的逻辑删除 + boolean result = this.removeById(codeId); + if (!result) { + // 这种情况理论上不应该发生,除非并发删除了或者 getById 和 removeById 之间状态变了 + log.error("Failed to logically delete activation code with ID: {}. removeById returned false.", codeId); + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "删除激活码失败"); + } + log.info("Activation code with ID: {} logically deleted.", codeId); + return true; + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/service/impl/UserServiceImpl.java b/springboot-init-main/src/main/java/com/yupi/project/service/impl/UserServiceImpl.java index ac35d39..7f4f436 100644 --- a/springboot-init-main/src/main/java/com/yupi/project/service/impl/UserServiceImpl.java +++ b/springboot-init-main/src/main/java/com/yupi/project/service/impl/UserServiceImpl.java @@ -140,25 +140,51 @@ public class UserServiceImpl extends ServiceImpl public User getCurrentUser(HttpServletRequest request) { // 优先从 SecurityContextHolder 获取认证信息 Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication != null && authentication.isAuthenticated() && !(authentication.getPrincipal() instanceof String && authentication.getPrincipal().equals("anonymousUser"))) { + if (authentication != null && authentication.isAuthenticated() && !(authentication.getPrincipal() instanceof String && "anonymousUser".equals(authentication.getPrincipal()))) { Object principal = authentication.getPrincipal(); if (principal instanceof User) { - return (User) principal; // principal 已经是 safetyUser + User userFromContext = (User) principal; + // 根据 Context 中的用户ID,从数据库重新获取最新的用户信息 + User latestUser = this.getById(userFromContext.getId()); + if (latestUser != null && latestUser.getIsDeleted() == 0) { // 确保用户未被删除 + // 返回脱敏后的最新用户信息 + return getSafetyUser(latestUser); + } else { + // 如果根据ID查不到用户了(例如被删除了),则认为未登录或异常 + // 清除可能无效的认证信息 + SecurityContextHolder.clearContext(); + throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "用户状态异常或已失效,请重新登录"); + } } else if (principal instanceof org.springframework.security.core.userdetails.User) { - // 如果 principal 是 Spring Security 的 User (不太可能在这里,因为我们设置的是 safetyUser) - // 需要转换或重新查询 - // For now, assume it's our User object based on login logic + // 如果是Spring Security的User对象,通常包含username + org.springframework.security.core.userdetails.User springUser = (org.springframework.security.core.userdetails.User) principal; + User userByUsername = this.getOne(new QueryWrapper().eq("username", springUser.getUsername())); + if (userByUsername != null && userByUsername.getIsDeleted() == 0) { + return getSafetyUser(userByUsername); + } else { + SecurityContextHolder.clearContext(); + throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "用户状态异常或已失效,请重新登录"); + } } } - - // 如果 SecurityContextHolder 中没有,尝试从 session (旧逻辑,作为后备或移除) + + // 如果 SecurityContextHolder 中没有有效信息,尝试从 session (旧逻辑,可作为后备) + // 注意:如果完全依赖 Spring Security,这部分可以考虑移除或调整 Object userObj = request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE); if (userObj instanceof User) { - // 最好在这里也验证一下数据库中的用户状态,或者确保session中的信息足够可信 - return (User) userObj; + User userFromSession = (User) userObj; + User latestUser = this.getById(userFromSession.getId()); + if (latestUser != null && latestUser.getIsDeleted() == 0) { + // 如果session中的用户有效,也最好将其信息同步到SecurityContext以保持一致性 (可选) + // recreateAuthenticationInSecurityContext(latestUser, request); // 辅助方法,如果需要 + return getSafetyUser(latestUser); + } + // Session 中的用户信息无效 + request.getSession().removeAttribute(UserConstant.USER_LOGIN_STATE); + throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "会话已过期或无效,请重新登录"); } - - throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR); + + throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "用户未登录"); } @Override diff --git a/springboot-init-main/target/classes/com/yupi/project/service/impl/UserServiceImpl.class b/springboot-init-main/target/classes/com/yupi/project/service/impl/UserServiceImpl.class index 618a55bb6344567c1f08a4c2643f7734d3b2f912..8ce5d9eff045834d1ccb29b4f8b57ecead993f34 100644 GIT binary patch delta 7501 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 Le6mAIWaqyDkK)Oy delta 6867 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