From 5dd16d980920e53e9c99ddfa05709158d68e3890 Mon Sep 17 00:00:00 2001 From: Hongying Li Date: Sun, 12 Apr 2026 18:28:37 +0800 Subject: [PATCH] =?UTF-8?q?1.0.1=20=E5=88=9D=E5=A7=8B=E5=8C=96=E9=93=9C?= =?UTF-8?q?=E5=A3=B6=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=B9=B6=E9=85=8D=E7=BD=AE=E5=AE=B9=E5=99=A8=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 23 + AGENTS.md | 40 + backend/.gitattributes | 2 + backend/.gitignore | 33 + backend/.mvn/wrapper/maven-wrapper.properties | 3 + backend/AGENTS.md | 33 + backend/Dockerfile | 23 + backend/mvnw | 295 +++ backend/mvnw.cmd | 189 ++ backend/pom.xml | 83 + .../com/teapot/system/BackendApplication.java | 13 + .../teapot/system/auth/AuthController.java | 37 + .../com/teapot/system/auth/AuthService.java | 78 + .../teapot/system/auth/AuthenticatedUser.java | 40 + .../system/auth/CurrentUserResponse.java | 40 + .../teapot/system/auth/HealthController.java | 22 + .../system/auth/JwtAuthenticationFilter.java | 57 + .../teapot/system/auth/JwtTokenProvider.java | 74 + .../com/teapot/system/auth/LoginRequest.java | 28 + .../com/teapot/system/auth/LoginResponse.java | 26 + .../teapot/system/auth/PermissionCodes.java | 29 + .../system/catalog/CatalogRepository.java | 435 ++++ .../teapot/system/catalog/CatalogService.java | 237 +++ .../system/catalog/CategoryController.java | 33 + .../catalog/CategoryProductNodeResponse.java | 26 + .../catalog/CategoryTreeNodeResponse.java | 28 + .../system/catalog/CreateProductRequest.java | 19 + .../system/catalog/ProductAssetContent.java | 44 + .../system/catalog/ProductAssetResponse.java | 56 + .../system/catalog/ProductController.java | 110 + .../system/catalog/ProductDetailResponse.java | 80 + .../catalog/ProductSummaryResponse.java | 87 + .../ReorderProductImageAssetsRequest.java | 19 + .../system/catalog/UpdateProductRequest.java | 88 + .../teapot/system/common/ApiException.java | 8 + .../com/teapot/system/common/ApiResponse.java | 38 + .../system/common/GlobalExceptionHandler.java | 33 + .../config/BootstrapDataInitializer.java | 131 ++ .../teapot/system/config/SecurityConfig.java | 63 + .../system/order/CompleteOrderRequest.java | 17 + .../system/order/CreateOrderRequest.java | 30 + .../teapot/system/order/OrderController.java | 82 + .../system/order/OrderDetailResponse.java | 101 + .../teapot/system/order/OrderItemRequest.java | 30 + .../system/order/OrderItemResponse.java | 46 + .../system/order/OrderLabelDownload.java | 32 + .../order/OrderLabelTemplateContent.java | 32 + .../system/order/OrderProductSnapshot.java | 46 + .../teapot/system/order/OrderRecordData.java | 26 + .../teapot/system/order/OrderRepository.java | 251 +++ .../com/teapot/system/order/OrderService.java | 439 ++++ .../system/order/OrderSummaryResponse.java | 71 + .../system/order/UpdateOrderRequest.java | 30 + .../statistics/StatisticsController.java | 33 + .../statistics/StatisticsRepository.java | 56 + .../statistics/StatisticsRowResponse.java | 34 + .../system/statistics/StatisticsService.java | 48 + .../statistics/StatisticsSummaryResponse.java | 60 + .../teapot/system/user/CreateUserRequest.java | 52 + .../user/UpdateUserPermissionsRequest.java | 18 + .../system/user/UpdateUserStatusRequest.java | 17 + .../com/teapot/system/user/UserAccount.java | 44 + .../system/user/UserAccountRepository.java | 115 + .../teapot/system/user/UserController.java | 56 + .../system/user/UserListItemResponse.java | 46 + .../com/teapot/system/user/UserService.java | 98 + .../src/main/resources/application.properties | 12 + backend/src/main/resources/db/schema.sql | 102 + .../system/BackendApplicationTests.java | 18 + .../CatalogServiceIntegrationTests.java | 87 + .../order/OrderServiceIntegrationTests.java | 153 ++ docker-compose.yml | 28 + frontend/.gitignore | 24 + frontend/AGENTS.md | 30 + frontend/Dockerfile | 18 + frontend/README.md | 5 + frontend/components.d.ts | 39 + frontend/index.html | 13 + frontend/nginx.conf | 29 + frontend/package-lock.json | 1891 +++++++++++++++++ frontend/package.json | 27 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.vue | 3 + frontend/src/assets/brand-mark.svg | 25 + frontend/src/assets/hero.png | Bin 0 -> 44919 bytes frontend/src/assets/vite.svg | 1 + frontend/src/assets/vue.svg | 1 + frontend/src/components/HelloWorld.vue | 93 + frontend/src/components/OrderEditorDialog.vue | 321 +++ frontend/src/layouts/AppLayout.vue | 156 ++ frontend/src/main.ts | 9 + frontend/src/router/index.ts | 111 + frontend/src/services/access.ts | 58 + frontend/src/services/api.ts | 86 + frontend/src/services/asset.ts | 45 + frontend/src/services/auth.ts | 13 + frontend/src/services/catalog.ts | 65 + frontend/src/services/mock-data.ts | 142 ++ frontend/src/services/order.ts | 41 + frontend/src/services/statistics.ts | 26 + frontend/src/services/user.ts | 32 + frontend/src/stores/auth.ts | 95 + frontend/src/stores/catalog.ts | 50 + frontend/src/stores/index.ts | 3 + frontend/src/stores/orders.ts | 81 + frontend/src/style.css | 1447 +++++++++++++ frontend/src/types/asset.ts | 18 + frontend/src/types/auth.ts | 45 + frontend/src/types/catalog.ts | 56 + frontend/src/types/order.ts | 37 + frontend/src/types/statistics.ts | 18 + frontend/src/views/auth/LoginView.vue | 64 + frontend/src/views/order/OrderDetailView.vue | 246 +++ frontend/src/views/order/OrdersView.vue | 321 +++ .../src/views/product/ProductCenterView.vue | 579 +++++ .../src/views/product/ProductDetailView.vue | 781 +++++++ .../src/views/statistics/StatisticsView.vue | 112 + frontend/src/views/system/UsersView.vue | 357 ++++ frontend/tsconfig.app.json | 14 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 24 + frontend/vite.config.ts | 41 + sql/V001__init.sql | 102 + ui-preview.html | 1252 +++++++++++ 前端页面UI方案.md | 392 ++++ 商品详情与订单页面设计文档.md | 394 ++++ 详细实施任务拆分.md | 88 + 铜壶管理系统开发任务计划书.md | 199 ++ 129 files changed, 15165 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 backend/.gitattributes create mode 100644 backend/.gitignore create mode 100644 backend/.mvn/wrapper/maven-wrapper.properties create mode 100644 backend/AGENTS.md create mode 100644 backend/Dockerfile create mode 100644 backend/mvnw create mode 100644 backend/mvnw.cmd create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/com/teapot/system/BackendApplication.java create mode 100644 backend/src/main/java/com/teapot/system/auth/AuthController.java create mode 100644 backend/src/main/java/com/teapot/system/auth/AuthService.java create mode 100644 backend/src/main/java/com/teapot/system/auth/AuthenticatedUser.java create mode 100644 backend/src/main/java/com/teapot/system/auth/CurrentUserResponse.java create mode 100644 backend/src/main/java/com/teapot/system/auth/HealthController.java create mode 100644 backend/src/main/java/com/teapot/system/auth/JwtAuthenticationFilter.java create mode 100644 backend/src/main/java/com/teapot/system/auth/JwtTokenProvider.java create mode 100644 backend/src/main/java/com/teapot/system/auth/LoginRequest.java create mode 100644 backend/src/main/java/com/teapot/system/auth/LoginResponse.java create mode 100644 backend/src/main/java/com/teapot/system/auth/PermissionCodes.java create mode 100644 backend/src/main/java/com/teapot/system/catalog/CatalogRepository.java create mode 100644 backend/src/main/java/com/teapot/system/catalog/CatalogService.java create mode 100644 backend/src/main/java/com/teapot/system/catalog/CategoryController.java create mode 100644 backend/src/main/java/com/teapot/system/catalog/CategoryProductNodeResponse.java create mode 100644 backend/src/main/java/com/teapot/system/catalog/CategoryTreeNodeResponse.java create mode 100644 backend/src/main/java/com/teapot/system/catalog/CreateProductRequest.java create mode 100644 backend/src/main/java/com/teapot/system/catalog/ProductAssetContent.java create mode 100644 backend/src/main/java/com/teapot/system/catalog/ProductAssetResponse.java create mode 100644 backend/src/main/java/com/teapot/system/catalog/ProductController.java create mode 100644 backend/src/main/java/com/teapot/system/catalog/ProductDetailResponse.java create mode 100644 backend/src/main/java/com/teapot/system/catalog/ProductSummaryResponse.java create mode 100644 backend/src/main/java/com/teapot/system/catalog/ReorderProductImageAssetsRequest.java create mode 100644 backend/src/main/java/com/teapot/system/catalog/UpdateProductRequest.java create mode 100644 backend/src/main/java/com/teapot/system/common/ApiException.java create mode 100644 backend/src/main/java/com/teapot/system/common/ApiResponse.java create mode 100644 backend/src/main/java/com/teapot/system/common/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/teapot/system/config/BootstrapDataInitializer.java create mode 100644 backend/src/main/java/com/teapot/system/config/SecurityConfig.java create mode 100644 backend/src/main/java/com/teapot/system/order/CompleteOrderRequest.java create mode 100644 backend/src/main/java/com/teapot/system/order/CreateOrderRequest.java create mode 100644 backend/src/main/java/com/teapot/system/order/OrderController.java create mode 100644 backend/src/main/java/com/teapot/system/order/OrderDetailResponse.java create mode 100644 backend/src/main/java/com/teapot/system/order/OrderItemRequest.java create mode 100644 backend/src/main/java/com/teapot/system/order/OrderItemResponse.java create mode 100644 backend/src/main/java/com/teapot/system/order/OrderLabelDownload.java create mode 100644 backend/src/main/java/com/teapot/system/order/OrderLabelTemplateContent.java create mode 100644 backend/src/main/java/com/teapot/system/order/OrderProductSnapshot.java create mode 100644 backend/src/main/java/com/teapot/system/order/OrderRecordData.java create mode 100644 backend/src/main/java/com/teapot/system/order/OrderRepository.java create mode 100644 backend/src/main/java/com/teapot/system/order/OrderService.java create mode 100644 backend/src/main/java/com/teapot/system/order/OrderSummaryResponse.java create mode 100644 backend/src/main/java/com/teapot/system/order/UpdateOrderRequest.java create mode 100644 backend/src/main/java/com/teapot/system/statistics/StatisticsController.java create mode 100644 backend/src/main/java/com/teapot/system/statistics/StatisticsRepository.java create mode 100644 backend/src/main/java/com/teapot/system/statistics/StatisticsRowResponse.java create mode 100644 backend/src/main/java/com/teapot/system/statistics/StatisticsService.java create mode 100644 backend/src/main/java/com/teapot/system/statistics/StatisticsSummaryResponse.java create mode 100644 backend/src/main/java/com/teapot/system/user/CreateUserRequest.java create mode 100644 backend/src/main/java/com/teapot/system/user/UpdateUserPermissionsRequest.java create mode 100644 backend/src/main/java/com/teapot/system/user/UpdateUserStatusRequest.java create mode 100644 backend/src/main/java/com/teapot/system/user/UserAccount.java create mode 100644 backend/src/main/java/com/teapot/system/user/UserAccountRepository.java create mode 100644 backend/src/main/java/com/teapot/system/user/UserController.java create mode 100644 backend/src/main/java/com/teapot/system/user/UserListItemResponse.java create mode 100644 backend/src/main/java/com/teapot/system/user/UserService.java create mode 100644 backend/src/main/resources/application.properties create mode 100644 backend/src/main/resources/db/schema.sql create mode 100644 backend/src/test/java/com/teapot/system/BackendApplicationTests.java create mode 100644 backend/src/test/java/com/teapot/system/catalog/CatalogServiceIntegrationTests.java create mode 100644 backend/src/test/java/com/teapot/system/order/OrderServiceIntegrationTests.java create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/AGENTS.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/components.d.ts create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/assets/brand-mark.svg create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/assets/vue.svg create mode 100644 frontend/src/components/HelloWorld.vue create mode 100644 frontend/src/components/OrderEditorDialog.vue create mode 100644 frontend/src/layouts/AppLayout.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/services/access.ts create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/services/asset.ts create mode 100644 frontend/src/services/auth.ts create mode 100644 frontend/src/services/catalog.ts create mode 100644 frontend/src/services/mock-data.ts create mode 100644 frontend/src/services/order.ts create mode 100644 frontend/src/services/statistics.ts create mode 100644 frontend/src/services/user.ts create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/stores/catalog.ts create mode 100644 frontend/src/stores/index.ts create mode 100644 frontend/src/stores/orders.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/src/types/asset.ts create mode 100644 frontend/src/types/auth.ts create mode 100644 frontend/src/types/catalog.ts create mode 100644 frontend/src/types/order.ts create mode 100644 frontend/src/types/statistics.ts create mode 100644 frontend/src/views/auth/LoginView.vue create mode 100644 frontend/src/views/order/OrderDetailView.vue create mode 100644 frontend/src/views/order/OrdersView.vue create mode 100644 frontend/src/views/product/ProductCenterView.vue create mode 100644 frontend/src/views/product/ProductDetailView.vue create mode 100644 frontend/src/views/statistics/StatisticsView.vue create mode 100644 frontend/src/views/system/UsersView.vue create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 sql/V001__init.sql create mode 100644 ui-preview.html create mode 100644 前端页面UI方案.md create mode 100644 商品详情与订单页面设计文档.md create mode 100644 详细实施任务拆分.md create mode 100644 铜壶管理系统开发任务计划书.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40fafcd --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# IDE / OS +.idea/ +.vscode/ +*.suo +*.ntvs +*.njsproj +*.sln +.DS_Store +Thumbs.db +desktop.ini + +# Build and Env +.env.local + +# Database / Storage (Docker Compose mounted path) +data/ +*.db +*.sqlite +*.sqlite3 + +# Logs +logs/ +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..97a1c23 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# Project Guidelines + +## Scope +- This repository uses a root AGENTS.md as the workspace-wide instruction file. Do not add a parallel .github/copilot-instructions.md unless this file is being intentionally replaced. +- The workspace is organized around four top-level areas: frontend, backend, sql, and root-level business/design documents. +- Project context and business rules are documented in [商品详情与订单页面设计文档.md](商品详情与订单页面设计文档.md), [前端页面UI方案.md](前端页面UI方案.md), [铜壶管理系统开发任务计划书.md](铜壶管理系统开发任务计划书.md), and [详细实施任务拆分.md](详细实施任务拆分.md). Update those docs when core behavior changes. + +## Architecture +- frontend: Vue 3 + Vite + TypeScript + Vue Router + Pinia + Element Plus. +- backend: Spring Boot 2.7 + Java 11 + Spring Security + JWT + JDBC + SQLite. +- data: SQLite is the only database in this project. Product images and label templates must remain in SQLite BLOB fields in the file_asset table; do not move them to local disk paths or object storage unless requirements change. +- product detail uses a dedicated route, not a modal. The left-side category tree is part of the core workflow and should remain available during detail navigation. +- permissions are enforced in two places: frontend visibility and backend API authorization. Do not rely on frontend hiding alone. +- order processing is a closed loop: create, edit pending orders, complete with express number, deduct stock, and download labels generated by the backend from order quantities. + +## Build And Test +- Frontend install: cd frontend && npm install +- Frontend dev: cd frontend && npm run dev +- Frontend build: cd frontend && npm run build +- Frontend local API target is configured through [frontend/.env.local](frontend/.env.local). Local development currently points VITE_API_BASE_URL to http://localhost:8081. +- Backend test: cd backend && .\mvnw.cmd test +- Backend run: cd backend && .\mvnw.cmd spring-boot:run "-Dspring-boot.run.arguments=--server.port=8081" +- Run backend commands from the backend directory. Do not assume mvn is installed globally; use the Maven Wrapper in backend. +- Prefer port 8081 for local backend runs because 8080 is frequently occupied in this environment. +- After frontend changes, run the frontend build. After backend changes, run backend tests. + +## Conventions +- Preserve the existing split between frontend service files, Pinia stores, view components, and backend controller/service/repository packages. +- Keep Vue route components lazily loaded in [frontend/src/router/index.ts](frontend/src/router/index.ts). +- Keep Element Plus on-demand loading configured in [frontend/vite.config.ts](frontend/vite.config.ts). Do not reintroduce global Element Plus registration or full library CSS import in [frontend/src/main.ts](frontend/src/main.ts). +- Frontend HTTP calls should continue to go through the service layer under frontend/src/services rather than ad hoc fetch logic inside views. +- Backend persistence should stay SQLite-friendly and simple; prefer focused JDBC repository methods over adding heavyweight persistence abstractions. +- The project is delivered as source code, SQL scripts, and deployment notes. Do not add Git-flow-specific assumptions to docs or workflow guidance. + +## Business Rules +- The system supports two user types: administrator and normal user. Normal-user permissions are assigned by an administrator. +- Pending orders can be edited or deleted. Completed orders are read-only. +- Label downloads are generated by the backend based on current order items and quantities. +- If a category does not require a detail page, do not force it into the standard product-detail workflow. +- Keep stock validation and permission checks consistent across frontend and backend when changing order or catalog behavior. \ No newline at end of file diff --git a/backend/.gitattributes b/backend/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/backend/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..667aaef --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/backend/.mvn/wrapper/maven-wrapper.properties b/backend/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..c595b00 --- /dev/null +++ b/backend/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000..7fa1fe2 --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,33 @@ +# Backend Guidelines + +## Scope +- Applies to files under backend/. +- Inherit the root [AGENTS.md](../AGENTS.md) and only override backend-specific expectations here. + +## Stack +- Spring Boot 2.7 + Java 11 + Spring Security + JWT + JDBC + SQLite. +- Runtime configuration lives in [backend/src/main/resources/application.properties](src/main/resources/application.properties). +- The primary business database is the SQLite file referenced by application.properties. Keep product images and label templates in file_asset BLOB fields. + +## Build And Test +- Run commands from backend/ and use the Maven Wrapper instead of a global Maven install. +- Run tests from backend/: .\mvnw.cmd test +- Start the backend locally from backend/: .\mvnw.cmd spring-boot:run "-Dspring-boot.run.arguments=--server.port=8081" +- Prefer port 8081 for local runs because 8080 is commonly occupied in this environment. +- Run backend tests after any change under backend/. + +## Conventions +- Preserve the current package split under [backend/src/main/java/com/teapot/system](src/main/java/com/teapot/system): auth, catalog, order, statistics, user, config, and common. +- Keep the controller/service/repository layering intact. New persistence logic should generally live in focused JDBC repositories, not in controllers or ad hoc utility classes. +- Keep JSON APIs wrapped in the common response model, while file downloads may continue using ResponseEntity where appropriate. +- Keep security enforced through Spring Security and method-level authorization. Do not weaken backend permission checks because the frontend already hides an action. +- Keep persistence SQLite-friendly: avoid introducing heavyweight ORM abstractions or storage patterns that assume another database engine. +- Asset list endpoints should return metadata only; binary file retrieval remains a dedicated download path. +- Tests must stay isolated from the real business database and should continue using target/ or other temporary SQLite files. + +## Backend Business Rules +- Pending orders can be edited or deleted; completed orders are read-only. +- Completing an order requires express number handling and must keep stock deduction consistent with order item quantities. +- Label downloads are generated by the backend from current order items and quantities, using the latest label template data stored in SQLite. +- Product image primary/cover behavior, stock validation, and permission checks must stay consistent with frontend expectations. +- Categories that do not require a detail page should not be forced into standard product-detail business logic. \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..7688e71 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,23 @@ +FROM maven:3.8.7-eclipse-temurin-11 AS builder +WORKDIR /build +COPY pom.xml . +# 预下载依赖,利用缓存层加速后续构建 +RUN mvn dependency:go-offline -B +COPY src ./src +# 打包并跳过测试 +RUN mvn package -DskipTests + +FROM eclipse-temurin:11-jre-alpine +WORKDIR /app +COPY --from=builder /build/target/*.jar app.jar + +# 暴露给前端的反向代理端口 +EXPOSE 8080 + +# 覆盖application.properties里的数据库地址,将其挂载到独立volume中 +ENV SPRING_DATASOURCE_URL=jdbc:sqlite:/app/data/teapot-system.db + +# 挂载数据卷以持久化SQLite和潜在的图片文件 +VOLUME /app/data + +ENTRYPOINT ["java", "-Dspring.datasource.url=${SPRING_DATASOURCE_URL}", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"] \ No newline at end of file diff --git a/backend/mvnw b/backend/mvnw new file mode 100644 index 0000000..bd8896b --- /dev/null +++ b/backend/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/backend/mvnw.cmd b/backend/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/backend/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..8eb7870 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,83 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.teapot + backend + 0.0.1-SNAPSHOT + teapot-system-backend + Teapot Management System Backend + + + 11 + 0.11.5 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.xerial + sqlite-jdbc + 3.45.3.0 + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/backend/src/main/java/com/teapot/system/BackendApplication.java b/backend/src/main/java/com/teapot/system/BackendApplication.java new file mode 100644 index 0000000..1fac2f6 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/BackendApplication.java @@ -0,0 +1,13 @@ +package com.teapot.system; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class BackendApplication { + + public static void main(String[] args) { + SpringApplication.run(BackendApplication.class, args); + } + +} diff --git a/backend/src/main/java/com/teapot/system/auth/AuthController.java b/backend/src/main/java/com/teapot/system/auth/AuthController.java new file mode 100644 index 0000000..f092a0a --- /dev/null +++ b/backend/src/main/java/com/teapot/system/auth/AuthController.java @@ -0,0 +1,37 @@ +package com.teapot.system.auth; + +import com.teapot.system.common.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.Map; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody LoginRequest request) { + return ApiResponse.success("登录成功", authService.login(request)); + } + + @PostMapping("/logout") + public ApiResponse> logout() { + return ApiResponse.success(Map.of("message", "退出成功")); + } + + @GetMapping("/me") + public ApiResponse currentUser() { + return ApiResponse.success(authService.getCurrentUser()); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/auth/AuthService.java b/backend/src/main/java/com/teapot/system/auth/AuthService.java new file mode 100644 index 0000000..b8b247a --- /dev/null +++ b/backend/src/main/java/com/teapot/system/auth/AuthService.java @@ -0,0 +1,78 @@ +package com.teapot.system.auth; + +import com.teapot.system.common.ApiException; +import com.teapot.system.user.UserAccount; +import com.teapot.system.user.UserAccountRepository; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class AuthService { + + private final UserAccountRepository userAccountRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + public AuthService( + UserAccountRepository userAccountRepository, + PasswordEncoder passwordEncoder, + JwtTokenProvider jwtTokenProvider) { + this.userAccountRepository = userAccountRepository; + this.passwordEncoder = passwordEncoder; + this.jwtTokenProvider = jwtTokenProvider; + } + + public LoginResponse login(LoginRequest request) { + UserAccount user = userAccountRepository.findByUsername(request.getUsername()) + .orElseThrow(() -> new ApiException("账号或密码错误")); + + if (!"ENABLED".equals(user.getStatus())) { + throw new ApiException("账号已停用,请联系管理员"); + } + + if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { + throw new ApiException("账号或密码错误"); + } + + List permissions = userAccountRepository.findPermissionsByUserId(user.getId()); + AuthenticatedUser authenticatedUser = new AuthenticatedUser( + user.getId(), + user.getUsername(), + user.getDisplayName(), + user.getUserType(), + permissions + ); + String token = jwtTokenProvider.generateToken(authenticatedUser); + userAccountRepository.updateLastLoginAt(user.getId()); + + return new LoginResponse( + token, + jwtTokenProvider.getExpireSeconds(), + toCurrentUserResponse(authenticatedUser) + ); + } + + public CurrentUserResponse getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof AuthenticatedUser)) { + throw new ApiException("未获取到登录用户信息"); + } + + AuthenticatedUser user = (AuthenticatedUser) authentication.getPrincipal(); + return toCurrentUserResponse(user); + } + + private CurrentUserResponse toCurrentUserResponse(AuthenticatedUser user) { + return new CurrentUserResponse( + user.getId(), + user.getUsername(), + user.getDisplayName(), + user.getUserType(), + user.getPermissions() + ); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/auth/AuthenticatedUser.java b/backend/src/main/java/com/teapot/system/auth/AuthenticatedUser.java new file mode 100644 index 0000000..1381e5d --- /dev/null +++ b/backend/src/main/java/com/teapot/system/auth/AuthenticatedUser.java @@ -0,0 +1,40 @@ +package com.teapot.system.auth; + +import java.util.List; + +public class AuthenticatedUser { + + private final Long id; + private final String username; + private final String displayName; + private final String userType; + private final List permissions; + + public AuthenticatedUser(Long id, String username, String displayName, String userType, List permissions) { + this.id = id; + this.username = username; + this.displayName = displayName; + this.userType = userType; + this.permissions = permissions; + } + + public Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public String getDisplayName() { + return displayName; + } + + public String getUserType() { + return userType; + } + + public List getPermissions() { + return permissions; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/auth/CurrentUserResponse.java b/backend/src/main/java/com/teapot/system/auth/CurrentUserResponse.java new file mode 100644 index 0000000..4ea52e5 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/auth/CurrentUserResponse.java @@ -0,0 +1,40 @@ +package com.teapot.system.auth; + +import java.util.List; + +public class CurrentUserResponse { + + private final Long id; + private final String username; + private final String displayName; + private final String userType; + private final List permissions; + + public CurrentUserResponse(Long id, String username, String displayName, String userType, List permissions) { + this.id = id; + this.username = username; + this.displayName = displayName; + this.userType = userType; + this.permissions = permissions; + } + + public Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public String getDisplayName() { + return displayName; + } + + public String getUserType() { + return userType; + } + + public List getPermissions() { + return permissions; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/auth/HealthController.java b/backend/src/main/java/com/teapot/system/auth/HealthController.java new file mode 100644 index 0000000..dd0abbb --- /dev/null +++ b/backend/src/main/java/com/teapot/system/auth/HealthController.java @@ -0,0 +1,22 @@ +package com.teapot.system.auth; + +import com.teapot.system.common.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.util.Map; + +@RestController +@RequestMapping("/api") +public class HealthController { + + @GetMapping("/health") + public ApiResponse> health() { + return ApiResponse.success(Map.of( + "status", "UP", + "time", LocalDateTime.now().toString() + )); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/auth/JwtAuthenticationFilter.java b/backend/src/main/java/com/teapot/system/auth/JwtAuthenticationFilter.java new file mode 100644 index 0000000..cafa0b2 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/auth/JwtAuthenticationFilter.java @@ -0,0 +1,57 @@ +package com.teapot.system.auth; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + + try { + AuthenticatedUser user = jwtTokenProvider.parseToken(token); + List authorities = new ArrayList<>(); + if ("ADMIN".equals(user.getUserType())) { + authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); + } + for (String permission : user.getPermissions()) { + authorities.add(new SimpleGrantedAuthority(permission)); + } + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + user, + null, + authorities + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception exception) { + SecurityContextHolder.clearContext(); + } + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/auth/JwtTokenProvider.java b/backend/src/main/java/com/teapot/system/auth/JwtTokenProvider.java new file mode 100644 index 0000000..eccb812 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/auth/JwtTokenProvider.java @@ -0,0 +1,74 @@ +package com.teapot.system.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.time.Instant; +import java.util.Date; +import java.util.List; + +@Component +public class JwtTokenProvider { + + private final String secret; + private final long expireHours; + private Key key; + + public JwtTokenProvider( + @Value("${app.security.jwt-secret}") String secret, + @Value("${app.security.jwt-expire-hours}") long expireHours) { + this.secret = secret; + this.expireHours = expireHours; + } + + @PostConstruct + public void init() { + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String generateToken(AuthenticatedUser user) { + Instant now = Instant.now(); + Instant expiresAt = now.plusSeconds(expireHours * 3600); + + return Jwts.builder() + .setSubject(user.getUsername()) + .claim("uid", user.getId()) + .claim("displayName", user.getDisplayName()) + .claim("userType", user.getUserType()) + .claim("permissions", user.getPermissions()) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(expiresAt)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public AuthenticatedUser parseToken(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + + List permissions = claims.get("permissions", List.class); + Long userId = claims.get("uid", Number.class).longValue(); + + return new AuthenticatedUser( + userId, + claims.getSubject(), + claims.get("displayName", String.class), + claims.get("userType", String.class), + permissions + ); + } + + public long getExpireSeconds() { + return expireHours * 3600; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/auth/LoginRequest.java b/backend/src/main/java/com/teapot/system/auth/LoginRequest.java new file mode 100644 index 0000000..5bdb017 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/auth/LoginRequest.java @@ -0,0 +1,28 @@ +package com.teapot.system.auth; + +import javax.validation.constraints.NotBlank; + +public class LoginRequest { + + @NotBlank(message = "账号不能为空") + private String username; + + @NotBlank(message = "密码不能为空") + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/auth/LoginResponse.java b/backend/src/main/java/com/teapot/system/auth/LoginResponse.java new file mode 100644 index 0000000..6d56338 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/auth/LoginResponse.java @@ -0,0 +1,26 @@ +package com.teapot.system.auth; + +public class LoginResponse { + + private final String token; + private final long expiresInSeconds; + private final CurrentUserResponse user; + + public LoginResponse(String token, long expiresInSeconds, CurrentUserResponse user) { + this.token = token; + this.expiresInSeconds = expiresInSeconds; + this.user = user; + } + + public String getToken() { + return token; + } + + public long getExpiresInSeconds() { + return expiresInSeconds; + } + + public CurrentUserResponse getUser() { + return user; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/auth/PermissionCodes.java b/backend/src/main/java/com/teapot/system/auth/PermissionCodes.java new file mode 100644 index 0000000..51e6aa9 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/auth/PermissionCodes.java @@ -0,0 +1,29 @@ +package com.teapot.system.auth; + +import java.util.Map; + +public final class PermissionCodes { + + public static final String PRODUCT_VIEW = "PRODUCT_VIEW"; + public static final String PRODUCT_EDIT = "PRODUCT_EDIT"; + public static final String ASSET_UPLOAD = "ASSET_UPLOAD"; + public static final String ORDER_VIEW = "ORDER_VIEW"; + public static final String ORDER_PROCESS = "ORDER_PROCESS"; + public static final String ORDER_COMPLETE = "ORDER_COMPLETE"; + public static final String STATISTICS_VIEW = "STATISTICS_VIEW"; + public static final String USER_MANAGE = "USER_MANAGE"; + + public static final Map NAME_MAP = Map.of( + PRODUCT_VIEW, "商品查看", + PRODUCT_EDIT, "商品编辑", + ASSET_UPLOAD, "附件上传", + ORDER_VIEW, "订单查看", + ORDER_PROCESS, "订单处理", + ORDER_COMPLETE, "订单完成", + STATISTICS_VIEW, "统计查看", + USER_MANAGE, "用户管理" + ); + + private PermissionCodes() { + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/catalog/CatalogRepository.java b/backend/src/main/java/com/teapot/system/catalog/CatalogRepository.java new file mode 100644 index 0000000..ac812f5 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/catalog/CatalogRepository.java @@ -0,0 +1,435 @@ +package com.teapot.system.catalog; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Repository +public class CatalogRepository { + + private static final String PRODUCT_BUSINESS_TYPE = "PRODUCT"; + + private final JdbcTemplate jdbcTemplate; + + public CatalogRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public long countCategories() { + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(1) FROM category", Integer.class); + return count == null ? 0L : count; + } + + public long countProducts() { + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(1) FROM product_item", Integer.class); + return count == null ? 0L : count; + } + + public boolean existsProductCategory(Long categoryId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM category WHERE id = ? AND category_type = 'PRODUCT'", + Integer.class, + categoryId + ); + return count != null && count > 0; + } + + public boolean existsProductSku(String sku, Long excludeProductId) { + Integer count; + if (excludeProductId == null) { + count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM product_item WHERE sku = ?", + Integer.class, + sku + ); + } else { + count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM product_item WHERE sku = ? AND id <> ?", + Integer.class, + sku, + excludeProductId + ); + } + return count != null && count > 0; + } + + public void insertCategory(long id, Long parentId, String name, int sortNo, boolean requiresDetailPage, String categoryType) { + jdbcTemplate.update( + "INSERT INTO category (id, parent_id, name, sort_no, requires_detail_page, category_type) VALUES (?, ?, ?, ?, ?, ?)", + id, + parentId, + name, + sortNo, + requiresDetailPage ? 1 : 0, + categoryType + ); + } + + public void insertProduct( + long id, + long categoryId, + String sku, + String name, + String modelName, + String status, + BigDecimal price, + int stockQuantity, + String remark) { + jdbcTemplate.update( + "INSERT INTO product_item (id, category_id, sku, name, model_name, status, wholesale_price, stock_quantity, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + id, + categoryId, + sku, + name, + modelName, + status, + price, + stockQuantity, + remark + ); + } + + public Long createProduct(CreateProductRequest request) { + return jdbcTemplate.execute((ConnectionCallback) connection -> { + try (PreparedStatement insertStatement = connection.prepareStatement( + "INSERT INTO product_item (category_id, sku, name, model_name, status, wholesale_price, stock_quantity, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + PreparedStatement identityStatement = connection.prepareStatement("SELECT last_insert_rowid()")) { + insertStatement.setLong(1, request.getCategoryId()); + insertStatement.setString(2, request.getSku()); + insertStatement.setString(3, request.getName()); + insertStatement.setString(4, request.getModel()); + insertStatement.setString(5, request.getStatus()); + insertStatement.setBigDecimal(6, request.getPrice()); + insertStatement.setInt(7, request.getStock()); + insertStatement.setString(8, request.getRemark()); + insertStatement.executeUpdate(); + + try (ResultSet identityResult = identityStatement.executeQuery()) { + if (identityResult.next()) { + return identityResult.getLong(1); + } + } + + return null; + } + }); + } + + public List findCategoryTree() { + List> categories = jdbcTemplate.queryForList( + "SELECT id, name FROM category WHERE category_type = 'PRODUCT' ORDER BY sort_no ASC, id ASC" + ); + + List results = new ArrayList<>(); + for (Map category : categories) { + Long categoryId = ((Number) category.get("id")).longValue(); + String categoryName = String.valueOf(category.get("name")); + List children = jdbcTemplate.query( + "SELECT id, name FROM product_item WHERE category_id = ? ORDER BY id ASC", + (resultSet, rowNum) -> new CategoryProductNodeResponse( + resultSet.getLong("id"), + resultSet.getString("name"), + resultSet.getLong("id") + ), + categoryId + ); + results.add(new CategoryTreeNodeResponse(categoryId, categoryName, children)); + } + + return results; + } + + public List findProductsByCategoryId(Long categoryId) { + return jdbcTemplate.query( + "SELECT id, category_id, model_name, name, sku, wholesale_price, stock_quantity, status, cover_asset_id, remark FROM product_item WHERE category_id = ? ORDER BY id ASC", + (resultSet, rowNum) -> new ProductSummaryResponse( + resultSet.getLong("id"), + resultSet.getLong("category_id"), + resultSet.getString("model_name"), + resultSet.getString("name"), + resultSet.getString("sku"), + readDecimal(resultSet, "wholesale_price"), + resultSet.getInt("stock_quantity"), + statusLabel(resultSet.getString("status")), + statusType(resultSet.getString("status")), + readNullableLong(resultSet, "cover_asset_id"), + resultSet.getString("remark") + ), + categoryId + ); + } + + public List findProductAssets(Long productId) { + return jdbcTemplate.query( + "SELECT id, file_role, file_name, mime_type, file_size, sort_no, is_primary, created_at FROM file_asset WHERE business_type = ? AND business_id = ? ORDER BY CASE WHEN file_role = 'IMAGE' THEN 0 ELSE 1 END ASC, sort_no ASC, id ASC", + (resultSet, rowNum) -> new ProductAssetResponse( + resultSet.getLong("id"), + resultSet.getString("file_role"), + resultSet.getString("file_name"), + resultSet.getString("mime_type"), + resultSet.getLong("file_size"), + resultSet.getInt("sort_no"), + resultSet.getInt("is_primary") == 1, + resultSet.getString("created_at") + ), + PRODUCT_BUSINESS_TYPE, + productId + ); + } + + public Optional findProductAsset(Long productId, Long assetId) { + List assets = jdbcTemplate.query( + "SELECT id, file_role, file_name, mime_type, file_size, sort_no, is_primary, created_at FROM file_asset WHERE id = ? AND business_type = ? AND business_id = ?", + (resultSet, rowNum) -> new ProductAssetResponse( + resultSet.getLong("id"), + resultSet.getString("file_role"), + resultSet.getString("file_name"), + resultSet.getString("mime_type"), + resultSet.getLong("file_size"), + resultSet.getInt("sort_no"), + resultSet.getInt("is_primary") == 1, + resultSet.getString("created_at") + ), + assetId, + PRODUCT_BUSINESS_TYPE, + productId + ); + + return assets.stream().findFirst(); + } + + public Optional findProductAssetContent(Long productId, Long assetId) { + List assets = jdbcTemplate.query( + "SELECT id, file_role, file_name, mime_type, file_size, content_blob FROM file_asset WHERE id = ? AND business_type = ? AND business_id = ?", + (resultSet, rowNum) -> new ProductAssetContent( + resultSet.getLong("id"), + resultSet.getString("file_role"), + resultSet.getString("file_name"), + resultSet.getString("mime_type"), + resultSet.getLong("file_size"), + resultSet.getBytes("content_blob") + ), + assetId, + PRODUCT_BUSINESS_TYPE, + productId + ); + + return assets.stream().findFirst(); + } + + public Optional findFirstImageAsset(Long productId) { + List assets = jdbcTemplate.query( + "SELECT id, file_role, file_name, mime_type, file_size, sort_no, is_primary, created_at FROM file_asset WHERE business_type = ? AND business_id = ? AND file_role = 'IMAGE' ORDER BY sort_no ASC, id ASC LIMIT 1", + (resultSet, rowNum) -> new ProductAssetResponse( + resultSet.getLong("id"), + resultSet.getString("file_role"), + resultSet.getString("file_name"), + resultSet.getString("mime_type"), + resultSet.getLong("file_size"), + resultSet.getInt("sort_no"), + resultSet.getInt("is_primary") == 1, + resultSet.getString("created_at") + ), + PRODUCT_BUSINESS_TYPE, + productId + ); + + return assets.stream().findFirst(); + } + + public ProductAssetResponse insertProductAsset(Long productId, String fileRole, String fileName, String mimeType, long fileSize, byte[] content, boolean isPrimary, String sha256) { + int sortNo = nextSortNo(productId, fileRole); + Long assetId = jdbcTemplate.execute((ConnectionCallback) connection -> { + try (PreparedStatement insertStatement = connection.prepareStatement( + "INSERT INTO file_asset (business_type, business_id, file_role, file_name, mime_type, file_size, content_blob, preview_blob, sort_no, is_primary, sha256) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + PreparedStatement identityStatement = connection.prepareStatement("SELECT last_insert_rowid()"); + ) { + insertStatement.setString(1, PRODUCT_BUSINESS_TYPE); + insertStatement.setLong(2, productId); + insertStatement.setString(3, fileRole); + insertStatement.setString(4, fileName); + insertStatement.setString(5, mimeType); + insertStatement.setLong(6, fileSize); + insertStatement.setBytes(7, content); + insertStatement.setBytes(8, null); + insertStatement.setInt(9, sortNo); + insertStatement.setInt(10, isPrimary ? 1 : 0); + insertStatement.setString(11, sha256); + insertStatement.executeUpdate(); + + try (ResultSet identityResult = identityStatement.executeQuery()) { + if (identityResult.next()) { + return identityResult.getLong(1); + } + } + + return null; + } + }); + + if (assetId == null) { + throw new IllegalStateException("附件保存后未返回编号"); + } + + if (assetId != null && "IMAGE".equals(fileRole)) { + Long currentCoverAssetId = findCoverAssetId(productId); + boolean shouldSetPrimary = isPrimary || currentCoverAssetId == null; + if (shouldSetPrimary) { + makeImageAssetPrimary(productId, assetId); + } + } + + return findProductAsset(productId, assetId).orElseThrow(() -> new IllegalStateException("附件保存后无法读取")); + } + + public void makeImageAssetPrimary(Long productId, Long assetId) { + resetPrimaryImageFlags(productId); + markAssetAsPrimary(assetId); + updateProductCoverAsset(productId, assetId); + } + + public void updateProductImageSortNo(Long productId, Long assetId, int sortNo) { + int updatedRows = jdbcTemplate.update( + "UPDATE file_asset SET sort_no = ? WHERE id = ? AND business_type = ? AND business_id = ? AND file_role = 'IMAGE'", + sortNo, + assetId, + PRODUCT_BUSINESS_TYPE, + productId + ); + + if (updatedRows != 1) { + throw new IllegalStateException("图片排序更新失败"); + } + } + + public void clearProductCoverAsset(Long productId) { + jdbcTemplate.update("UPDATE product_item SET cover_asset_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?", productId); + } + + public void deleteProductAsset(Long productId, Long assetId) { + jdbcTemplate.update( + "DELETE FROM file_asset WHERE id = ? AND business_type = ? AND business_id = ?", + assetId, + PRODUCT_BUSINESS_TYPE, + productId + ); + } + + public Optional findProductById(Long productId) { + List products = jdbcTemplate.query( + "SELECT id, category_id, model_name, name, sku, wholesale_price, stock_quantity, status, remark FROM product_item WHERE id = ?", + (resultSet, rowNum) -> new ProductDetailResponse( + resultSet.getLong("id"), + resultSet.getLong("category_id"), + resultSet.getString("model_name"), + resultSet.getString("name"), + resultSet.getString("sku"), + readDecimal(resultSet, "wholesale_price"), + resultSet.getInt("stock_quantity"), + resultSet.getString("status"), + statusType(resultSet.getString("status")), + resultSet.getString("remark") + ), + productId + ); + + return products.stream().findFirst(); + } + + public void updateProduct(Long productId, UpdateProductRequest request) { + jdbcTemplate.update( + "UPDATE product_item SET sku = ?, name = ?, model_name = ?, status = ?, wholesale_price = ?, stock_quantity = ?, remark = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + request.getSku(), + request.getName(), + request.getModel(), + request.getStatus(), + request.getPrice(), + request.getStock(), + request.getRemark(), + productId + ); + } + + private int nextSortNo(Long productId, String fileRole) { + Integer maxSortNo = jdbcTemplate.queryForObject( + "SELECT COALESCE(MAX(sort_no), 0) FROM file_asset WHERE business_type = ? AND business_id = ? AND file_role = ?", + Integer.class, + PRODUCT_BUSINESS_TYPE, + productId, + fileRole + ); + return (maxSortNo == null ? 0 : maxSortNo) + 1; + } + + private Long findCoverAssetId(Long productId) { + List results = jdbcTemplate.query( + "SELECT cover_asset_id FROM product_item WHERE id = ?", + (resultSet, rowNum) -> readNullableLong(resultSet, "cover_asset_id"), + productId + ); + return results.isEmpty() ? null : results.get(0); + } + + private void resetPrimaryImageFlags(Long productId) { + jdbcTemplate.update( + "UPDATE file_asset SET is_primary = 0 WHERE business_type = ? AND business_id = ? AND file_role = 'IMAGE'", + PRODUCT_BUSINESS_TYPE, + productId + ); + } + + private void markAssetAsPrimary(Long assetId) { + jdbcTemplate.update("UPDATE file_asset SET is_primary = 1 WHERE id = ?", assetId); + } + + private void updateProductCoverAsset(Long productId, Long assetId) { + jdbcTemplate.update("UPDATE product_item SET cover_asset_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", assetId, productId); + } + + private BigDecimal readDecimal(ResultSet resultSet, String column) throws SQLException { + return BigDecimal.valueOf(resultSet.getDouble(column)); + } + + private Long readNullableLong(ResultSet resultSet, String column) throws SQLException { + long value = resultSet.getLong(column); + return resultSet.wasNull() ? null : value; + } + + private String statusLabel(String status) { + switch (status) { + case "DISCONTINUED": + return "停产"; + case "HIGH_STOCK": + return "库存多"; + case "LOW_STOCK": + return "库存少"; + case "AVAILABLE": + default: + return "可售"; + } + } + + private String statusType(String status) { + switch (status) { + case "DISCONTINUED": + return "danger"; + case "HIGH_STOCK": + return "info"; + case "LOW_STOCK": + return "warning"; + case "AVAILABLE": + default: + return "success"; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/catalog/CatalogService.java b/backend/src/main/java/com/teapot/system/catalog/CatalogService.java new file mode 100644 index 0000000..c59b923 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/catalog/CatalogService.java @@ -0,0 +1,237 @@ +package com.teapot.system.catalog; + +import com.teapot.system.common.ApiException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class CatalogService { + + private static final List PRODUCT_STATUSES = List.of("AVAILABLE", "LOW_STOCK", "HIGH_STOCK", "DISCONTINUED"); + + private final CatalogRepository catalogRepository; + + public CatalogService(CatalogRepository catalogRepository) { + this.catalogRepository = catalogRepository; + } + + public List getCategoryTree() { + return catalogRepository.findCategoryTree(); + } + + public List getProductsByCategory(Long categoryId) { + return catalogRepository.findProductsByCategoryId(categoryId); + } + + public ProductDetailResponse getProduct(Long productId) { + return catalogRepository.findProductById(productId) + .orElseThrow(() -> new ApiException("商品不存在")); + } + + public ProductDetailResponse createProduct(CreateProductRequest request) { + if (!catalogRepository.existsProductCategory(request.getCategoryId())) { + throw new ApiException("所属类目不存在"); + } + + normalizeProductRequest(request); + if (catalogRepository.existsProductSku(request.getSku(), null)) { + throw new ApiException("SKU 已存在"); + } + + Long productId = catalogRepository.createProduct(request); + if (productId == null) { + throw new ApiException("商品创建失败"); + } + + return getProduct(productId); + } + + public ProductDetailResponse updateProduct(Long productId, UpdateProductRequest request) { + getProduct(productId); + normalizeProductRequest(request); + if (catalogRepository.existsProductSku(request.getSku(), productId)) { + throw new ApiException("SKU 已存在"); + } + catalogRepository.updateProduct(productId, request); + return getProduct(productId); + } + + public List getProductAssets(Long productId) { + getProduct(productId); + return catalogRepository.findProductAssets(productId); + } + + public ProductAssetResponse uploadProductAsset(Long productId, String fileRole, boolean isPrimary, MultipartFile file) { + getProduct(productId); + + String normalizedRole = normalizeFileRole(fileRole); + if (file == null || file.isEmpty()) { + throw new ApiException("上传文件不能为空"); + } + + String mimeType = file.getContentType() == null || file.getContentType().trim().isEmpty() + ? "application/octet-stream" + : file.getContentType().trim(); + + if ("IMAGE".equals(normalizedRole) && !mimeType.startsWith("image/")) { + throw new ApiException("图片附件只支持图片类型文件"); + } + + try { + byte[] content = file.getBytes(); + String fileName = file.getOriginalFilename() == null || file.getOriginalFilename().trim().isEmpty() + ? ("IMAGE".equals(normalizedRole) ? "image.bin" : "label.bin") + : file.getOriginalFilename().trim(); + return catalogRepository.insertProductAsset(productId, normalizedRole, fileName, mimeType, file.getSize(), content, isPrimary, calculateSha256(content)); + } catch (IOException exception) { + throw new ApiException("读取上传文件失败"); + } + } + + public ProductAssetContent downloadProductAsset(Long productId, Long assetId) { + getProduct(productId); + ProductAssetContent assetContent = catalogRepository.findProductAssetContent(productId, assetId) + .orElseThrow(() -> new ApiException("附件不存在")); + if (assetContent.getContent() == null || assetContent.getContent().length == 0) { + throw new ApiException("附件内容为空"); + } + return assetContent; + } + + public ProductAssetResponse setPrimaryProductAsset(Long productId, Long assetId) { + getProduct(productId); + ProductAssetResponse asset = catalogRepository.findProductAsset(productId, assetId) + .orElseThrow(() -> new ApiException("附件不存在")); + + if (!"IMAGE".equals(asset.getFileRole())) { + throw new ApiException("只有图片附件才允许设为主图"); + } + + catalogRepository.makeImageAssetPrimary(productId, assetId); + return catalogRepository.findProductAsset(productId, assetId) + .orElseThrow(() -> new ApiException("主图更新失败")); + } + + @Transactional + public List reorderProductImageAssets(Long productId, List assetIds) { + getProduct(productId); + + List currentImageAssets = catalogRepository.findProductAssets(productId).stream() + .filter(asset -> "IMAGE".equals(asset.getFileRole())) + .collect(Collectors.toList()); + + Set requestedAssetIds = new LinkedHashSet<>(assetIds); + Set currentAssetIds = currentImageAssets.stream() + .map(ProductAssetResponse::getId) + .collect(Collectors.toSet()); + + if (requestedAssetIds.size() != assetIds.size() || currentImageAssets.size() != assetIds.size() || !currentAssetIds.equals(requestedAssetIds)) { + throw new ApiException("图片顺序必须包含当前商品的全部图片且不能重复"); + } + + int sortNo = 1; + for (Long assetId : assetIds) { + catalogRepository.updateProductImageSortNo(productId, assetId, sortNo++); + } + + return catalogRepository.findProductAssets(productId); + } + + public void deleteProductAsset(Long productId, Long assetId) { + getProduct(productId); + ProductAssetResponse asset = catalogRepository.findProductAsset(productId, assetId) + .orElseThrow(() -> new ApiException("附件不存在")); + + catalogRepository.deleteProductAsset(productId, assetId); + + if ("IMAGE".equals(asset.getFileRole())) { + catalogRepository.findFirstImageAsset(productId) + .ifPresentOrElse( + image -> catalogRepository.makeImageAssetPrimary(productId, image.getId()), + () -> catalogRepository.clearProductCoverAsset(productId) + ); + } + } + + public long countCategories() { + return catalogRepository.countCategories(); + } + + public long countProducts() { + return catalogRepository.countProducts(); + } + + public void seedCategory(long id, Long parentId, String name, int sortNo, boolean requiresDetailPage, String categoryType) { + catalogRepository.insertCategory(id, parentId, name, sortNo, requiresDetailPage, categoryType); + } + + public void seedProduct(long id, long categoryId, String sku, String name, String modelName, String status, java.math.BigDecimal price, int stockQuantity, String remark) { + catalogRepository.insertProduct(id, categoryId, sku, name, modelName, status, price, stockQuantity, remark); + } + + private void normalizeProductRequest(UpdateProductRequest request) { + request.setModel(request.getModel().trim()); + request.setName(request.getName().trim()); + request.setSku(request.getSku().trim()); + request.setStatus(normalizeProductStatus(request.getStatus())); + request.setRemark(normalizeOptionalText(request.getRemark())); + } + + private String normalizeProductStatus(String status) { + if (status == null || status.trim().isEmpty()) { + throw new ApiException("商品状态不能为空"); + } + + String normalizedStatus = status.trim().toUpperCase(); + if (!PRODUCT_STATUSES.contains(normalizedStatus)) { + throw new ApiException("商品状态非法"); + } + + return normalizedStatus; + } + + private String normalizeOptionalText(String value) { + if (value == null) { + return null; + } + + String normalizedValue = value.trim(); + return normalizedValue.isEmpty() ? null : normalizedValue; + } + + private String normalizeFileRole(String fileRole) { + if (fileRole == null || fileRole.trim().isEmpty()) { + throw new ApiException("附件类型不能为空"); + } + + String normalizedRole = fileRole.trim().toUpperCase(); + if (!"IMAGE".equals(normalizedRole) && !"LABEL".equals(normalizedRole)) { + throw new ApiException("仅支持 IMAGE 或 LABEL 两种附件类型"); + } + + return normalizedRole; + } + + private String calculateSha256(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(content); + StringBuilder builder = new StringBuilder(); + for (byte item : hash) { + builder.append(String.format("%02x", item)); + } + return builder.toString(); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前环境不支持 SHA-256", exception); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/catalog/CategoryController.java b/backend/src/main/java/com/teapot/system/catalog/CategoryController.java new file mode 100644 index 0000000..65062fb --- /dev/null +++ b/backend/src/main/java/com/teapot/system/catalog/CategoryController.java @@ -0,0 +1,33 @@ +package com.teapot.system.catalog; + +import com.teapot.system.common.ApiResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/categories") +public class CategoryController { + + private final CatalogService catalogService; + + public CategoryController(CatalogService catalogService) { + this.catalogService = catalogService; + } + + @GetMapping("/tree") + @PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('PRODUCT_VIEW','PRODUCT_EDIT','ASSET_UPLOAD')") + public ApiResponse> getTree() { + return ApiResponse.success(catalogService.getCategoryTree()); + } + + @GetMapping("/{id}/products") + @PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('PRODUCT_VIEW','PRODUCT_EDIT','ASSET_UPLOAD')") + public ApiResponse> getProductsByCategory(@PathVariable Long id) { + return ApiResponse.success(catalogService.getProductsByCategory(id)); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/catalog/CategoryProductNodeResponse.java b/backend/src/main/java/com/teapot/system/catalog/CategoryProductNodeResponse.java new file mode 100644 index 0000000..6323b8f --- /dev/null +++ b/backend/src/main/java/com/teapot/system/catalog/CategoryProductNodeResponse.java @@ -0,0 +1,26 @@ +package com.teapot.system.catalog; + +public class CategoryProductNodeResponse { + + private final Long id; + private final String name; + private final Long productId; + + public CategoryProductNodeResponse(Long id, String name, Long productId) { + this.id = id; + this.name = name; + this.productId = productId; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Long getProductId() { + return productId; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/catalog/CategoryTreeNodeResponse.java b/backend/src/main/java/com/teapot/system/catalog/CategoryTreeNodeResponse.java new file mode 100644 index 0000000..c223a06 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/catalog/CategoryTreeNodeResponse.java @@ -0,0 +1,28 @@ +package com.teapot.system.catalog; + +import java.util.List; + +public class CategoryTreeNodeResponse { + + private final Long id; + private final String name; + private final List children; + + public CategoryTreeNodeResponse(Long id, String name, List children) { + this.id = id; + this.name = name; + this.children = children; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public List getChildren() { + return children; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/catalog/CreateProductRequest.java b/backend/src/main/java/com/teapot/system/catalog/CreateProductRequest.java new file mode 100644 index 0000000..3e06b14 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/catalog/CreateProductRequest.java @@ -0,0 +1,19 @@ +package com.teapot.system.catalog; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; + +public class CreateProductRequest extends UpdateProductRequest { + + @NotNull(message = "所属类目不能为空") + @Min(value = 1, message = "所属类目非法") + private Long categoryId; + + public Long getCategoryId() { + return categoryId; + } + + public void setCategoryId(Long categoryId) { + this.categoryId = categoryId; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/catalog/ProductAssetContent.java b/backend/src/main/java/com/teapot/system/catalog/ProductAssetContent.java new file mode 100644 index 0000000..0b02878 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/catalog/ProductAssetContent.java @@ -0,0 +1,44 @@ +package com.teapot.system.catalog; + +public class ProductAssetContent { + + private final Long id; + private final String fileRole; + private final String fileName; + private final String mimeType; + private final Long fileSize; + private final byte[] content; + + public ProductAssetContent(Long id, String fileRole, String fileName, String mimeType, Long fileSize, byte[] content) { + this.id = id; + this.fileRole = fileRole; + this.fileName = fileName; + this.mimeType = mimeType; + this.fileSize = fileSize; + this.content = content; + } + + public Long getId() { + return id; + } + + public String getFileRole() { + return fileRole; + } + + public String getFileName() { + return fileName; + } + + public String getMimeType() { + return mimeType; + } + + public Long getFileSize() { + return fileSize; + } + + public byte[] getContent() { + return content; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/catalog/ProductAssetResponse.java b/backend/src/main/java/com/teapot/system/catalog/ProductAssetResponse.java new file mode 100644 index 0000000..b13f26a --- /dev/null +++ b/backend/src/main/java/com/teapot/system/catalog/ProductAssetResponse.java @@ -0,0 +1,56 @@ +package com.teapot.system.catalog; + +public class ProductAssetResponse { + + private final Long id; + private final String fileRole; + private final String fileName; + private final String mimeType; + private final Long fileSize; + private final Integer sortNo; + private final boolean primary; + private final String createdAt; + + public ProductAssetResponse(Long id, String fileRole, String fileName, String mimeType, Long fileSize, Integer sortNo, boolean primary, String createdAt) { + this.id = id; + this.fileRole = fileRole; + this.fileName = fileName; + this.mimeType = mimeType; + this.fileSize = fileSize; + this.sortNo = sortNo; + this.primary = primary; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public String getFileRole() { + return fileRole; + } + + public String getFileName() { + return fileName; + } + + public String getMimeType() { + return mimeType; + } + + public Long getFileSize() { + return fileSize; + } + + public Integer getSortNo() { + return sortNo; + } + + public boolean isPrimary() { + return primary; + } + + public String getCreatedAt() { + return createdAt; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/catalog/ProductController.java b/backend/src/main/java/com/teapot/system/catalog/ProductController.java new file mode 100644 index 0000000..92d1989 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/catalog/ProductController.java @@ -0,0 +1,110 @@ +package com.teapot.system.catalog; + +import com.teapot.system.common.ApiResponse; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.Valid; +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@RequestMapping("/api/products") +public class ProductController { + + private final CatalogService catalogService; + + public ProductController(CatalogService catalogService) { + this.catalogService = catalogService; + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN') or hasAuthority('PRODUCT_EDIT')") + public ApiResponse createProduct(@Valid @RequestBody CreateProductRequest request) { + return ApiResponse.success("商品已创建", catalogService.createProduct(request)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('PRODUCT_VIEW','PRODUCT_EDIT','ASSET_UPLOAD')") + public ApiResponse getProduct(@PathVariable Long id) { + return ApiResponse.success(catalogService.getProduct(id)); + } + + @GetMapping("/{id}/assets") + @PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('PRODUCT_VIEW','PRODUCT_EDIT','ASSET_UPLOAD')") + public ApiResponse> getProductAssets(@PathVariable Long id) { + return ApiResponse.success(catalogService.getProductAssets(id)); + } + + @PostMapping(value = "/{id}/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasRole('ADMIN') or hasAuthority('ASSET_UPLOAD')") + public ApiResponse uploadProductAsset( + @PathVariable Long id, + @RequestParam String fileRole, + @RequestParam(defaultValue = "false") boolean isPrimary, + @RequestPart("file") MultipartFile file) { + return ApiResponse.success("上传成功", catalogService.uploadProductAsset(id, fileRole, isPrimary, file)); + } + + @GetMapping("/{id}/assets/{assetId}/download") + @PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('PRODUCT_VIEW','PRODUCT_EDIT','ASSET_UPLOAD')") + public ResponseEntity downloadProductAsset(@PathVariable Long id, @PathVariable Long assetId) { + ProductAssetContent assetContent = catalogService.downloadProductAsset(id, assetId); + HttpHeaders headers = new HttpHeaders(); + headers.setContentDisposition(ContentDisposition.attachment().filename(assetContent.getFileName(), StandardCharsets.UTF_8).build()); + return ResponseEntity.ok() + .headers(headers) + .contentType(resolveMediaType(assetContent.getMimeType())) + .contentLength(assetContent.getFileSize()) + .body(assetContent.getContent()); + } + + @PutMapping("/{id}/assets/{assetId}/primary") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('ASSET_UPLOAD')") + public ApiResponse setPrimaryProductAsset(@PathVariable Long id, @PathVariable Long assetId) { + return ApiResponse.success("主图已更新", catalogService.setPrimaryProductAsset(id, assetId)); + } + + @PutMapping("/{id}/assets/image-order") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('ASSET_UPLOAD')") + public ApiResponse> reorderProductImageAssets( + @PathVariable Long id, + @Valid @RequestBody ReorderProductImageAssetsRequest request) { + return ApiResponse.success("图片顺序已更新", catalogService.reorderProductImageAssets(id, request.getAssetIds())); + } + + @DeleteMapping("/{id}/assets/{assetId}") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('ASSET_UPLOAD')") + public ApiResponse> deleteProductAsset(@PathVariable Long id, @PathVariable Long assetId) { + catalogService.deleteProductAsset(id, assetId); + return ApiResponse.success(java.util.Map.of("message", "附件已删除")); + } + + @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('PRODUCT_EDIT')") + public ApiResponse updateProduct(@PathVariable Long id, @Valid @RequestBody UpdateProductRequest request) { + return ApiResponse.success("保存成功", catalogService.updateProduct(id, request)); + } + + private MediaType resolveMediaType(String mimeType) { + try { + return MediaType.parseMediaType(mimeType); + } catch (Exception exception) { + return MediaType.APPLICATION_OCTET_STREAM; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/catalog/ProductDetailResponse.java b/backend/src/main/java/com/teapot/system/catalog/ProductDetailResponse.java new file mode 100644 index 0000000..f893720 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/catalog/ProductDetailResponse.java @@ -0,0 +1,80 @@ +package com.teapot.system.catalog; + +import java.math.BigDecimal; + +public class ProductDetailResponse { + + private final Long id; + private final Long categoryId; + private final String model; + private final String name; + private final String sku; + private final BigDecimal price; + private final Integer stock; + private final String status; + private final String statusType; + private final String remark; + + public ProductDetailResponse( + Long id, + Long categoryId, + String model, + String name, + String sku, + BigDecimal price, + Integer stock, + String status, + String statusType, + String remark) { + this.id = id; + this.categoryId = categoryId; + this.model = model; + this.name = name; + this.sku = sku; + this.price = price; + this.stock = stock; + this.status = status; + this.statusType = statusType; + this.remark = remark; + } + + public Long getId() { + return id; + } + + public Long getCategoryId() { + return categoryId; + } + + public String getModel() { + return model; + } + + public String getName() { + return name; + } + + public String getSku() { + return sku; + } + + public BigDecimal getPrice() { + return price; + } + + public Integer getStock() { + return stock; + } + + public String getStatus() { + return status; + } + + public String getStatusType() { + return statusType; + } + + public String getRemark() { + return remark; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/catalog/ProductSummaryResponse.java b/backend/src/main/java/com/teapot/system/catalog/ProductSummaryResponse.java new file mode 100644 index 0000000..4086514 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/catalog/ProductSummaryResponse.java @@ -0,0 +1,87 @@ +package com.teapot.system.catalog; + +import java.math.BigDecimal; + +public class ProductSummaryResponse { + + private final Long id; + private final Long categoryId; + private final String model; + private final String name; + private final String sku; + private final BigDecimal price; + private final Integer stock; + private final String status; + private final String statusType; + private final Long coverAssetId; + private final String remark; + + public ProductSummaryResponse( + Long id, + Long categoryId, + String model, + String name, + String sku, + BigDecimal price, + Integer stock, + String status, + String statusType, + Long coverAssetId, + String remark) { + this.id = id; + this.categoryId = categoryId; + this.model = model; + this.name = name; + this.sku = sku; + this.price = price; + this.stock = stock; + this.status = status; + this.statusType = statusType; + this.coverAssetId = coverAssetId; + this.remark = remark; + } + + public Long getId() { + return id; + } + + public Long getCategoryId() { + return categoryId; + } + + public String getModel() { + return model; + } + + public String getName() { + return name; + } + + public String getSku() { + return sku; + } + + public BigDecimal getPrice() { + return price; + } + + public Integer getStock() { + return stock; + } + + public String getStatus() { + return status; + } + + public String getStatusType() { + return statusType; + } + + public Long getCoverAssetId() { + return coverAssetId; + } + + public String getRemark() { + return remark; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/catalog/ReorderProductImageAssetsRequest.java b/backend/src/main/java/com/teapot/system/catalog/ReorderProductImageAssetsRequest.java new file mode 100644 index 0000000..91bc8fc --- /dev/null +++ b/backend/src/main/java/com/teapot/system/catalog/ReorderProductImageAssetsRequest.java @@ -0,0 +1,19 @@ +package com.teapot.system.catalog; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +public class ReorderProductImageAssetsRequest { + + @NotEmpty(message = "图片顺序不能为空") + private List<@NotNull(message = "图片编号不能为空") Long> assetIds; + + public List getAssetIds() { + return assetIds; + } + + public void setAssetIds(List assetIds) { + this.assetIds = assetIds; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/catalog/UpdateProductRequest.java b/backend/src/main/java/com/teapot/system/catalog/UpdateProductRequest.java new file mode 100644 index 0000000..2904e18 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/catalog/UpdateProductRequest.java @@ -0,0 +1,88 @@ +package com.teapot.system.catalog; + +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.math.BigDecimal; + +public class UpdateProductRequest { + + @NotBlank(message = "型号不能为空") + private String model; + + @NotBlank(message = "商品名称不能为空") + private String name; + + @NotBlank(message = "SKU 不能为空") + private String sku; + + @NotNull(message = "价格不能为空") + @DecimalMin(value = "0.0", inclusive = true, message = "价格不能为负数") + private BigDecimal price; + + @NotNull(message = "库存不能为空") + @Min(value = 0, message = "库存不能为负数") + private Integer stock; + + @NotBlank(message = "状态不能为空") + private String status; + + private String remark; + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getSku() { + return sku; + } + + public void setSku(String sku) { + this.sku = sku; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + public Integer getStock() { + return stock; + } + + public void setStock(Integer stock) { + this.stock = stock; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/common/ApiException.java b/backend/src/main/java/com/teapot/system/common/ApiException.java new file mode 100644 index 0000000..b71d872 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/common/ApiException.java @@ -0,0 +1,8 @@ +package com.teapot.system.common; + +public class ApiException extends RuntimeException { + + public ApiException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/common/ApiResponse.java b/backend/src/main/java/com/teapot/system/common/ApiResponse.java new file mode 100644 index 0000000..2d4881b --- /dev/null +++ b/backend/src/main/java/com/teapot/system/common/ApiResponse.java @@ -0,0 +1,38 @@ +package com.teapot.system.common; + +public class ApiResponse { + + private final boolean success; + private final String message; + private final T data; + + private ApiResponse(boolean success, String message, T data) { + this.success = success; + this.message = message; + this.data = data; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, "ok", data); + } + + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(true, message, data); + } + + public static ApiResponse failure(String message) { + return new ApiResponse<>(false, message, null); + } + + public boolean isSuccess() { + return success; + } + + public String getMessage() { + return message; + } + + public T getData() { + return data; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/common/GlobalExceptionHandler.java b/backend/src/main/java/com/teapot/system/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..ad69d83 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/common/GlobalExceptionHandler.java @@ -0,0 +1,33 @@ +package com.teapot.system.common; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ApiException.class) + public ResponseEntity> handleApiException(ApiException exception) { + return ResponseEntity.badRequest().body(ApiResponse.failure(exception.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException exception) { + String message = exception.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining("; ")); + return ResponseEntity.badRequest().body(ApiResponse.failure(message)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception exception) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.failure("服务端异常: " + exception.getMessage())); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/config/BootstrapDataInitializer.java b/backend/src/main/java/com/teapot/system/config/BootstrapDataInitializer.java new file mode 100644 index 0000000..8d4c342 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/config/BootstrapDataInitializer.java @@ -0,0 +1,131 @@ +package com.teapot.system.config; + +import com.teapot.system.auth.PermissionCodes; +import com.teapot.system.catalog.CatalogService; +import com.teapot.system.order.OrderService; +import com.teapot.system.user.UserAccountRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.List; + +@Component +public class BootstrapDataInitializer implements CommandLineRunner { + + private final UserAccountRepository userAccountRepository; + private final PasswordEncoder passwordEncoder; + private final CatalogService catalogService; + private final OrderService orderService; + + public BootstrapDataInitializer( + UserAccountRepository userAccountRepository, + PasswordEncoder passwordEncoder, + CatalogService catalogService, + OrderService orderService) { + this.userAccountRepository = userAccountRepository; + this.passwordEncoder = passwordEncoder; + this.catalogService = catalogService; + this.orderService = orderService; + } + + @Override + public void run(String... args) { + seedAdmin(); + seedCustomer(); + seedPacker(); + seedCategoriesAndProducts(); + seedOrders(); + } + + private void seedAdmin() { + if (userAccountRepository.existsByUsername("admin")) { + return; + } + + long userId = userAccountRepository.insertUser( + "admin", + passwordEncoder.encode("Admin@123"), + "系统管理员", + "ADMIN", + null + ); + userAccountRepository.replacePermissions(userId, List.of( + PermissionCodes.PRODUCT_VIEW, + PermissionCodes.PRODUCT_EDIT, + PermissionCodes.ASSET_UPLOAD, + PermissionCodes.ORDER_VIEW, + PermissionCodes.ORDER_PROCESS, + PermissionCodes.ORDER_COMPLETE, + PermissionCodes.STATISTICS_VIEW, + PermissionCodes.USER_MANAGE + )); + } + + private void seedCustomer() { + if (userAccountRepository.existsByUsername("customer")) { + return; + } + + long userId = userAccountRepository.insertUser( + "customer", + passwordEncoder.encode("Customer@123"), + "客户演示用户", + "NORMAL", + 1L + ); + userAccountRepository.replacePermissions(userId, List.of( + PermissionCodes.PRODUCT_VIEW + )); + } + + private void seedPacker() { + if (userAccountRepository.existsByUsername("packer")) { + return; + } + + long userId = userAccountRepository.insertUser( + "packer", + passwordEncoder.encode("Packer@123"), + "打包工演示用户", + "NORMAL", + 1L + ); + userAccountRepository.replacePermissions(userId, List.of( + PermissionCodes.ORDER_VIEW, + PermissionCodes.ORDER_PROCESS, + PermissionCodes.ORDER_COMPLETE + )); + } + + private void seedCategoriesAndProducts() { + if (catalogService.countCategories() == 0) { + catalogService.seedCategory(1L, null, "1号普通壶", 1, true, "PRODUCT"); + catalogService.seedCategory(2L, null, "2号锤纹壶", 2, true, "PRODUCT"); + catalogService.seedCategory(3L, null, "宣传物料", 3, false, "MATERIAL"); + } + + if (catalogService.countProducts() == 0) { + catalogService.seedProduct(101L, 1L, "TH-001-A", "花纹A", "1号普通壶", "AVAILABLE", new BigDecimal("198"), 82, "常规热销款式,适合批发出货。"); + catalogService.seedProduct(102L, 1L, "TH-001-B", "花纹B", "1号普通壶", "LOW_STOCK", new BigDecimal("228"), 9, "库存较少,建议控制订单数量。"); + catalogService.seedProduct(103L, 1L, "TH-001-C", "花纹C", "1号普通壶", "HIGH_STOCK", new BigDecimal("268"), 145, "库存充足,适合大单。"); + catalogService.seedProduct(201L, 2L, "TH-002-A", "锤纹青古", "2号锤纹壶", "AVAILABLE", new BigDecimal("318"), 36, "主打纹理工艺,适合展示。"); + } + } + + private void seedOrders() { + if (orderService.countOrders() > 0) { + return; + } + + orderService.seedOrder(1L, "TH20260411-008", "PENDING", 36, new BigDecimal("5412"), "", "2026-04-11 09:30:00", null); + orderService.seedOrderItem(1L, 101L, "花纹A", "TH-001-A", "1号普通壶", new BigDecimal("198"), 10, new BigDecimal("1980")); + orderService.seedOrderItem(1L, 102L, "花纹B", "TH-001-B", "1号普通壶", new BigDecimal("228"), 8, new BigDecimal("1824")); + orderService.seedOrderItem(1L, 103L, "花纹C", "TH-001-C", "1号普通壶", new BigDecimal("268"), 6, new BigDecimal("1608")); + + orderService.seedOrder(2L, "TH20260410-005", "COMPLETED", 18, new BigDecimal("5004"), "SF847266012210", "2026-04-10 15:10:00", "2026-04-10 18:20:00"); + orderService.seedOrderItem(2L, 201L, "锤纹青古", "TH-002-A", "2号锤纹壶", new BigDecimal("318"), 12, new BigDecimal("3816")); + orderService.seedOrderItem(2L, 101L, "花纹A", "TH-001-A", "1号普通壶", new BigDecimal("198"), 6, new BigDecimal("1188")); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/config/SecurityConfig.java b/backend/src/main/java/com/teapot/system/config/SecurityConfig.java new file mode 100644 index 0000000..82ffd7b --- /dev/null +++ b/backend/src/main/java/com/teapot/system/config/SecurityConfig.java @@ -0,0 +1,63 @@ +package com.teapot.system.config; + +import com.teapot.system.auth.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableGlobalMethodSecurity(prePostEnabled = true) +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf().disable() + .cors().and() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() + .authorizeRequests() + .antMatchers("/api/auth/login", "/api/health").permitAll() + .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .anyRequest().authenticated() + .and() + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(false); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/order/CompleteOrderRequest.java b/backend/src/main/java/com/teapot/system/order/CompleteOrderRequest.java new file mode 100644 index 0000000..4f4b108 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/order/CompleteOrderRequest.java @@ -0,0 +1,17 @@ +package com.teapot.system.order; + +import javax.validation.constraints.NotBlank; + +public class CompleteOrderRequest { + + @NotBlank(message = "快递单号不能为空") + private String expressNo; + + public String getExpressNo() { + return expressNo; + } + + public void setExpressNo(String expressNo) { + this.expressNo = expressNo; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/order/CreateOrderRequest.java b/backend/src/main/java/com/teapot/system/order/CreateOrderRequest.java new file mode 100644 index 0000000..52eec81 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/order/CreateOrderRequest.java @@ -0,0 +1,30 @@ +package com.teapot.system.order; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import java.util.List; + +public class CreateOrderRequest { + + @NotEmpty(message = "订单明细不能为空") + @Valid + private List items; + + private String remark; + + public List getItems() { + return items; + } + + public void setItems(List items) { + this.items = items; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/order/OrderController.java b/backend/src/main/java/com/teapot/system/order/OrderController.java new file mode 100644 index 0000000..d0b0215 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/order/OrderController.java @@ -0,0 +1,82 @@ +package com.teapot.system.order; + +import com.teapot.system.common.ApiResponse; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/orders") +public class OrderController { + + private final OrderService orderService; + + public OrderController(OrderService orderService) { + this.orderService = orderService; + } + + @GetMapping + @PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('ORDER_VIEW','ORDER_PROCESS','ORDER_COMPLETE')") + public ApiResponse> listOrders() { + return ApiResponse.success(orderService.listOrders()); + } + + @GetMapping("/{orderNo}") + @PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('ORDER_VIEW','ORDER_PROCESS','ORDER_COMPLETE')") + public ApiResponse getOrder(@PathVariable String orderNo) { + return ApiResponse.success(orderService.getOrderDetail(orderNo)); + } + + @GetMapping("/{orderNo}/labels/download") + @PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('ORDER_VIEW','ORDER_PROCESS','ORDER_COMPLETE')") + public ResponseEntity downloadOrderLabels(@PathVariable String orderNo) { + OrderLabelDownload download = orderService.downloadOrderLabels(orderNo); + HttpHeaders headers = new HttpHeaders(); + headers.setContentDisposition(ContentDisposition.attachment().filename(download.getFileName(), StandardCharsets.UTF_8).build()); + return ResponseEntity.ok() + .headers(headers) + .contentType(MediaType.parseMediaType(download.getMimeType())) + .contentLength(download.getFileSize()) + .body(download.getContent()); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN') or hasAuthority('ORDER_PROCESS')") + public ApiResponse createOrder(@Valid @RequestBody CreateOrderRequest request) { + return ApiResponse.success("订单创建成功", orderService.createOrder(request)); + } + + @PutMapping("/{orderNo}") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('ORDER_PROCESS')") + public ApiResponse updateOrder(@PathVariable String orderNo, @Valid @RequestBody UpdateOrderRequest request) { + return ApiResponse.success("订单已更新", orderService.updateOrder(orderNo, request)); + } + + @DeleteMapping("/{orderNo}") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('ORDER_PROCESS')") + public ApiResponse> deleteOrder(@PathVariable String orderNo) { + orderService.deleteOrder(orderNo); + return ApiResponse.success(Map.of("message", "订单已删除")); + } + + @PostMapping("/{orderNo}/complete") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('ORDER_COMPLETE')") + public ApiResponse completeOrder(@PathVariable String orderNo, @Valid @RequestBody CompleteOrderRequest request) { + return ApiResponse.success("订单已完成", orderService.completeOrder(orderNo, request.getExpressNo())); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/order/OrderDetailResponse.java b/backend/src/main/java/com/teapot/system/order/OrderDetailResponse.java new file mode 100644 index 0000000..7a20ec9 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/order/OrderDetailResponse.java @@ -0,0 +1,101 @@ +package com.teapot.system.order; + +import java.math.BigDecimal; +import java.util.List; + +public class OrderDetailResponse { + + private final String id; + private final String status; + private final String createdAt; + private final Integer totalQuantity; + private final BigDecimal totalAmount; + private final String expressNo; + private final String completedAt; + private final String remark; + private final List items; + private final boolean labelReady; + private final List missingLabelProducts; + + public OrderDetailResponse( + String id, + String status, + String createdAt, + Integer totalQuantity, + BigDecimal totalAmount, + String expressNo, + String completedAt, + String remark, + List items) { + this(id, status, createdAt, totalQuantity, totalAmount, expressNo, completedAt, remark, items, true, List.of()); + } + + public OrderDetailResponse( + String id, + String status, + String createdAt, + Integer totalQuantity, + BigDecimal totalAmount, + String expressNo, + String completedAt, + String remark, + List items, + boolean labelReady, + List missingLabelProducts) { + this.id = id; + this.status = status; + this.createdAt = createdAt; + this.totalQuantity = totalQuantity; + this.totalAmount = totalAmount; + this.expressNo = expressNo; + this.completedAt = completedAt; + this.remark = remark; + this.items = items; + this.labelReady = labelReady; + this.missingLabelProducts = missingLabelProducts == null ? List.of() : List.copyOf(missingLabelProducts); + } + + public String getId() { + return id; + } + + public String getStatus() { + return status; + } + + public String getCreatedAt() { + return createdAt; + } + + public Integer getTotalQuantity() { + return totalQuantity; + } + + public BigDecimal getTotalAmount() { + return totalAmount; + } + + public String getExpressNo() { + return expressNo; + } + + public String getCompletedAt() { + return completedAt; + } + + public String getRemark() { + return remark; + } + + public List getItems() { + return items; + } + + public boolean isLabelReady() { + return labelReady; + } + + public List getMissingLabelProducts() { + return missingLabelProducts; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/order/OrderItemRequest.java b/backend/src/main/java/com/teapot/system/order/OrderItemRequest.java new file mode 100644 index 0000000..f08ad80 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/order/OrderItemRequest.java @@ -0,0 +1,30 @@ +package com.teapot.system.order; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; + +public class OrderItemRequest { + + @NotNull(message = "商品不能为空") + private Long productId; + + @NotNull(message = "数量不能为空") + @Min(value = 1, message = "数量至少为 1") + private Integer quantity; + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/order/OrderItemResponse.java b/backend/src/main/java/com/teapot/system/order/OrderItemResponse.java new file mode 100644 index 0000000..134a4b8 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/order/OrderItemResponse.java @@ -0,0 +1,46 @@ +package com.teapot.system.order; + +import java.math.BigDecimal; + +public class OrderItemResponse { + + private final Long productId; + private final String name; + private final String sku; + private final BigDecimal price; + private final Integer quantity; + private final BigDecimal amount; + + public OrderItemResponse(Long productId, String name, String sku, BigDecimal price, Integer quantity, BigDecimal amount) { + this.productId = productId; + this.name = name; + this.sku = sku; + this.price = price; + this.quantity = quantity; + this.amount = amount; + } + + public Long getProductId() { + return productId; + } + + public String getName() { + return name; + } + + public String getSku() { + return sku; + } + + public BigDecimal getPrice() { + return price; + } + + public Integer getQuantity() { + return quantity; + } + + public BigDecimal getAmount() { + return amount; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/order/OrderLabelDownload.java b/backend/src/main/java/com/teapot/system/order/OrderLabelDownload.java new file mode 100644 index 0000000..a5a31db --- /dev/null +++ b/backend/src/main/java/com/teapot/system/order/OrderLabelDownload.java @@ -0,0 +1,32 @@ +package com.teapot.system.order; + +public class OrderLabelDownload { + + private final String fileName; + private final String mimeType; + private final Long fileSize; + private final byte[] content; + + public OrderLabelDownload(String fileName, String mimeType, Long fileSize, byte[] content) { + this.fileName = fileName; + this.mimeType = mimeType; + this.fileSize = fileSize; + this.content = content; + } + + public String getFileName() { + return fileName; + } + + public String getMimeType() { + return mimeType; + } + + public Long getFileSize() { + return fileSize; + } + + public byte[] getContent() { + return content; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/order/OrderLabelTemplateContent.java b/backend/src/main/java/com/teapot/system/order/OrderLabelTemplateContent.java new file mode 100644 index 0000000..112c19a --- /dev/null +++ b/backend/src/main/java/com/teapot/system/order/OrderLabelTemplateContent.java @@ -0,0 +1,32 @@ +package com.teapot.system.order; + +public class OrderLabelTemplateContent { + + private final String fileName; + private final String mimeType; + private final Long fileSize; + private final byte[] content; + + public OrderLabelTemplateContent(String fileName, String mimeType, Long fileSize, byte[] content) { + this.fileName = fileName; + this.mimeType = mimeType; + this.fileSize = fileSize; + this.content = content; + } + + public String getFileName() { + return fileName; + } + + public String getMimeType() { + return mimeType; + } + + public Long getFileSize() { + return fileSize; + } + + public byte[] getContent() { + return content; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/order/OrderProductSnapshot.java b/backend/src/main/java/com/teapot/system/order/OrderProductSnapshot.java new file mode 100644 index 0000000..0dcecec --- /dev/null +++ b/backend/src/main/java/com/teapot/system/order/OrderProductSnapshot.java @@ -0,0 +1,46 @@ +package com.teapot.system.order; + +import java.math.BigDecimal; + +public class OrderProductSnapshot { + + private final Long productId; + private final String productName; + private final String sku; + private final String modelName; + private final BigDecimal unitPrice; + private final Integer stockQuantity; + + public OrderProductSnapshot(Long productId, String productName, String sku, String modelName, BigDecimal unitPrice, Integer stockQuantity) { + this.productId = productId; + this.productName = productName; + this.sku = sku; + this.modelName = modelName; + this.unitPrice = unitPrice; + this.stockQuantity = stockQuantity; + } + + public Long getProductId() { + return productId; + } + + public String getProductName() { + return productName; + } + + public String getSku() { + return sku; + } + + public String getModelName() { + return modelName; + } + + public BigDecimal getUnitPrice() { + return unitPrice; + } + + public Integer getStockQuantity() { + return stockQuantity; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/order/OrderRecordData.java b/backend/src/main/java/com/teapot/system/order/OrderRecordData.java new file mode 100644 index 0000000..0513661 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/order/OrderRecordData.java @@ -0,0 +1,26 @@ +package com.teapot.system.order; + +public class OrderRecordData { + + private final Long id; + private final String orderNo; + private final String status; + + public OrderRecordData(Long id, String orderNo, String status) { + this.id = id; + this.orderNo = orderNo; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getOrderNo() { + return orderNo; + } + + public String getStatus() { + return status; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/order/OrderRepository.java b/backend/src/main/java/com/teapot/system/order/OrderRepository.java new file mode 100644 index 0000000..f5b933a --- /dev/null +++ b/backend/src/main/java/com/teapot/system/order/OrderRepository.java @@ -0,0 +1,251 @@ +package com.teapot.system.order; + +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcTemplate; + +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.Optional; + +@Repository +public class OrderRepository { + + private final JdbcTemplate jdbcTemplate; + + public OrderRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public long countOrders() { + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(1) FROM order_record", Integer.class); + return count == null ? 0L : count; + } + + public void insertOrder( + long id, + String orderNo, + String status, + int totalQuantity, + BigDecimal totalAmount, + String expressNo, + String createdAt, + String completedAt) { + jdbcTemplate.update( + "INSERT INTO order_record (id, order_no, status, total_quantity, total_amount, express_no, created_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + id, + orderNo, + status, + totalQuantity, + totalAmount, + expressNo, + createdAt, + completedAt + ); + } + + public void insertOrderItem( + long orderId, + long productId, + String productNameSnapshot, + String skuSnapshot, + String modelNameSnapshot, + BigDecimal unitPrice, + int quantity, + BigDecimal lineAmount) { + jdbcTemplate.update( + "INSERT INTO order_item (order_id, product_id, product_name_snapshot, sku_snapshot, model_name_snapshot, unit_price, quantity, line_amount) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + orderId, + productId, + productNameSnapshot, + skuSnapshot, + modelNameSnapshot, + unitPrice, + quantity, + lineAmount + ); + } + + public List findOrders() { + return jdbcTemplate.query( + "SELECT order_no, status, created_at, total_quantity, total_amount, express_no FROM order_record ORDER BY created_at DESC, id DESC", + (resultSet, rowNum) -> new OrderSummaryResponse( + resultSet.getString("order_no"), + resultSet.getString("status"), + resultSet.getString("created_at"), + resultSet.getInt("total_quantity"), + readDecimal(resultSet, "total_amount"), + resultSet.getString("express_no") + ) + ); + } + + public Optional findOrderRecordByOrderNo(String orderNo) { + List orders = jdbcTemplate.query( + "SELECT id, order_no, status FROM order_record WHERE order_no = ?", + (resultSet, rowNum) -> new OrderRecordData( + resultSet.getLong("id"), + resultSet.getString("order_no"), + resultSet.getString("status") + ), + orderNo + ); + + return orders.stream().findFirst(); + } + + public Optional findProductSnapshotById(Long productId) { + List products = jdbcTemplate.query( + "SELECT id, name, sku, model_name, wholesale_price, stock_quantity FROM product_item WHERE id = ?", + (resultSet, rowNum) -> new OrderProductSnapshot( + resultSet.getLong("id"), + resultSet.getString("name"), + resultSet.getString("sku"), + resultSet.getString("model_name"), + readDecimal(resultSet, "wholesale_price"), + resultSet.getInt("stock_quantity") + ), + productId + ); + + return products.stream().findFirst(); + } + + public int decreaseProductStock(Long productId, int quantity) { + return jdbcTemplate.update( + "UPDATE product_item SET stock_quantity = stock_quantity - ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND stock_quantity >= ?", + quantity, + productId, + quantity + ); + } + + public Optional findOrderByOrderNo(String orderNo) { + List orders = jdbcTemplate.query( + "SELECT id, order_no, status, created_at, total_quantity, total_amount, express_no, completed_at, remark FROM order_record WHERE order_no = ?", + (resultSet, rowNum) -> new OrderDetailResponse( + resultSet.getString("order_no"), + resultSet.getString("status"), + resultSet.getString("created_at"), + resultSet.getInt("total_quantity"), + readDecimal(resultSet, "total_amount"), + resultSet.getString("express_no"), + resultSet.getString("completed_at"), + resultSet.getString("remark"), + findItemsByOrderId(resultSet.getLong("id")) + ), + orderNo + ); + + return orders.stream().findFirst(); + } + + public Optional findLatestLabelTemplateByProductId(Long productId) { + List templates = jdbcTemplate.query( + "SELECT file_name, mime_type, file_size, content_blob FROM file_asset WHERE business_type = 'PRODUCT' AND business_id = ? AND file_role = 'LABEL' ORDER BY sort_no DESC, id DESC LIMIT 1", + (resultSet, rowNum) -> new OrderLabelTemplateContent( + resultSet.getString("file_name"), + resultSet.getString("mime_type"), + resultSet.getLong("file_size"), + resultSet.getBytes("content_blob") + ), + productId + ); + + return templates.stream().findFirst(); + } + + public int nextDailyOrderSequence(String orderPrefix) { + Integer value = jdbcTemplate.queryForObject( + "SELECT COALESCE(MAX(CAST(SUBSTR(order_no, 12) AS INTEGER)), 0) FROM order_record WHERE order_no LIKE ?", + Integer.class, + orderPrefix + "%" + ); + return (value == null ? 0 : value) + 1; + } + + public long createOrderRecord(String orderNo, int totalQuantity, BigDecimal totalAmount, String remark) { + Long orderId = jdbcTemplate.execute((ConnectionCallback) connection -> { + try (PreparedStatement insertStatement = connection.prepareStatement( + "INSERT INTO order_record (order_no, status, total_quantity, total_amount, remark) VALUES (?, 'PENDING', ?, ?, ?)"); + PreparedStatement identityStatement = connection.prepareStatement("SELECT last_insert_rowid()")) { + insertStatement.setString(1, orderNo); + insertStatement.setInt(2, totalQuantity); + insertStatement.setBigDecimal(3, totalAmount); + insertStatement.setString(4, remark); + insertStatement.executeUpdate(); + + try (ResultSet resultSet = identityStatement.executeQuery()) { + if (resultSet.next()) { + return resultSet.getLong(1); + } + } + + return null; + } + }); + + if (orderId == null) { + throw new IllegalStateException("创建订单后未返回编号"); + } + + return orderId; + } + + public void updatePendingOrder(Long orderId, int totalQuantity, BigDecimal totalAmount, String remark) { + jdbcTemplate.update( + "UPDATE order_record SET total_quantity = ?, total_amount = ?, remark = ?, express_no = NULL, completed_at = NULL WHERE id = ? AND status = 'PENDING'", + totalQuantity, + totalAmount, + remark, + orderId + ); + } + + public void deleteOrderItems(Long orderId) { + jdbcTemplate.update("DELETE FROM order_item WHERE order_id = ?", orderId); + } + + public void deleteOrderLogs(Long orderId) { + jdbcTemplate.update("DELETE FROM order_operation_log WHERE order_id = ?", orderId); + } + + public void deletePendingOrder(Long orderId) { + jdbcTemplate.update("DELETE FROM order_record WHERE id = ? AND status = 'PENDING'", orderId); + } + + public void deleteOrderRecord(Long orderId) { + jdbcTemplate.update("DELETE FROM order_record WHERE id = ?", orderId); + } + + public void completeOrder(String orderNo, String expressNo) { + jdbcTemplate.update( + "UPDATE order_record SET status = 'COMPLETED', express_no = ?, completed_at = CURRENT_TIMESTAMP WHERE order_no = ? AND status = 'PENDING'", + expressNo, + orderNo + ); + } + + private List findItemsByOrderId(Long orderId) { + return jdbcTemplate.query( + "SELECT product_id, product_name_snapshot, sku_snapshot, unit_price, quantity, line_amount FROM order_item WHERE order_id = ? ORDER BY id ASC", + (resultSet, rowNum) -> new OrderItemResponse( + resultSet.getLong("product_id"), + resultSet.getString("product_name_snapshot"), + resultSet.getString("sku_snapshot"), + readDecimal(resultSet, "unit_price"), + resultSet.getInt("quantity"), + readDecimal(resultSet, "line_amount") + ), + orderId + ); + } + + private BigDecimal readDecimal(ResultSet resultSet, String column) throws SQLException { + return BigDecimal.valueOf(resultSet.getDouble(column)); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/order/OrderService.java b/backend/src/main/java/com/teapot/system/order/OrderService.java new file mode 100644 index 0000000..7fc5283 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/order/OrderService.java @@ -0,0 +1,439 @@ +package com.teapot.system.order; + +import com.teapot.system.common.ApiException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +@Service +public class OrderService { + + private final OrderRepository orderRepository; + + public OrderService(OrderRepository orderRepository) { + this.orderRepository = orderRepository; + } + + public List listOrders() { + List orders = orderRepository.findOrders(); + List enrichedOrders = new ArrayList<>(); + for (OrderSummaryResponse order : orders) { + enrichedOrders.add(enrichOrderSummary(order)); + } + return enrichedOrders; + } + + public OrderDetailResponse getOrderDetail(String orderNo) { + return orderRepository.findOrderByOrderNo(orderNo) + .map(this::enrichOrderDetail) + .orElseThrow(() -> new ApiException("订单不存在")); + } + + @Transactional + public OrderDetailResponse createOrder(CreateOrderRequest request) { + PreparedOrder preparedOrder = prepareOrder(request.getItems(), request.getRemark()); + String orderNo = generateOrderNo(); + long orderId = orderRepository.createOrderRecord(orderNo, preparedOrder.getTotalQuantity(), preparedOrder.getTotalAmount(), preparedOrder.getRemark()); + persistOrderItems(orderId, preparedOrder.getLines()); + return getOrderDetail(orderNo); + } + + @Transactional + public OrderDetailResponse updateOrder(String orderNo, UpdateOrderRequest request) { + OrderRecordData orderRecord = requirePendingOrder(orderNo, "只有未完成订单才允许编辑"); + PreparedOrder preparedOrder = prepareOrder(request.getItems(), request.getRemark()); + orderRepository.updatePendingOrder(orderRecord.getId(), preparedOrder.getTotalQuantity(), preparedOrder.getTotalAmount(), preparedOrder.getRemark()); + orderRepository.deleteOrderItems(orderRecord.getId()); + persistOrderItems(orderRecord.getId(), preparedOrder.getLines()); + return getOrderDetail(orderNo); + } + + @Transactional + public void deleteOrder(String orderNo) { + OrderRecordData orderRecord = orderRepository.findOrderRecordByOrderNo(orderNo) + .orElseThrow(() -> new ApiException("订单不存在")); + if (!"PENDING".equals(orderRecord.getStatus()) && !isCurrentUserAdmin()) { + throw new ApiException("只有管理员才允许删除已完成订单"); + } + orderRepository.deleteOrderLogs(orderRecord.getId()); + orderRepository.deleteOrderItems(orderRecord.getId()); + orderRepository.deleteOrderRecord(orderRecord.getId()); + } + + @Transactional + public OrderDetailResponse completeOrder(String orderNo, String expressNo) { + requirePendingOrder(orderNo, "只有未完成订单才允许完成操作"); + OrderDetailResponse orderDetail = getOrderDetail(orderNo); + for (OrderItemResponse item : orderDetail.getItems()) { + int affectedRows = orderRepository.decreaseProductStock(item.getProductId(), item.getQuantity()); + if (affectedRows == 0) { + OrderProductSnapshot product = orderRepository.findProductSnapshotById(item.getProductId()) + .orElseThrow(() -> new ApiException("商品不存在: " + item.getProductId())); + throw new ApiException(buildInsufficientStockMessage(product, item.getQuantity())); + } + } + orderRepository.completeOrder(orderNo, expressNo.trim()); + return getOrderDetail(orderNo); + } + + public OrderLabelDownload downloadOrderLabels(String orderNo) { + OrderDetailResponse order = getOrderDetail(orderNo); + if (order.getItems() == null || order.getItems().isEmpty()) { + throw new ApiException("订单明细不能为空"); + } + + OrderLabelStatus labelStatus = buildLabelStatus(order.getItems()); + if (!labelStatus.isReady()) { + throw new ApiException("以下商品缺少标签模板: " + String.join("、", labelStatus.getMissingProducts())); + } + + Map templates = new HashMap<>(); + + for (OrderItemResponse item : order.getItems()) { + OrderLabelTemplateContent template = orderRepository.findLatestLabelTemplateByProductId(item.getProductId()) + .orElseThrow(() -> new ApiException("标签模板读取失败: " + item.getSku())); + + templates.put(item.getProductId(), template); + } + + byte[] zipContent = createLabelArchive(order, templates); + return new OrderLabelDownload(order.getId() + "-labels.zip", "application/zip", (long) zipContent.length, zipContent); + } + + public long countOrders() { + return orderRepository.countOrders(); + } + + public void seedOrder( + long id, + String orderNo, + String status, + int totalQuantity, + BigDecimal totalAmount, + String expressNo, + String createdAt, + String completedAt) { + orderRepository.insertOrder(id, orderNo, status, totalQuantity, totalAmount, expressNo, createdAt, completedAt); + } + + public void seedOrderItem( + long orderId, + long productId, + String productNameSnapshot, + String skuSnapshot, + String modelNameSnapshot, + BigDecimal unitPrice, + int quantity, + BigDecimal lineAmount) { + orderRepository.insertOrderItem(orderId, productId, productNameSnapshot, skuSnapshot, modelNameSnapshot, unitPrice, quantity, lineAmount); + } + + private OrderRecordData requirePendingOrder(String orderNo, String message) { + OrderRecordData orderRecord = orderRepository.findOrderRecordByOrderNo(orderNo) + .orElseThrow(() -> new ApiException("订单不存在")); + if (!"PENDING".equals(orderRecord.getStatus())) { + throw new ApiException(message); + } + return orderRecord; + } + + private boolean isCurrentUserAdmin() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + return false; + } + + return authentication.getAuthorities().stream() + .anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority())); + } + + private PreparedOrder prepareOrder(List items, String remark) { + Map mergedQuantities = new LinkedHashMap<>(); + for (OrderItemRequest item : items) { + mergedQuantities.merge(item.getProductId(), item.getQuantity(), Integer::sum); + } + + if (mergedQuantities.isEmpty()) { + throw new ApiException("订单明细不能为空"); + } + + List lines = new java.util.ArrayList<>(); + int totalQuantity = 0; + BigDecimal totalAmount = BigDecimal.ZERO; + + for (Map.Entry entry : mergedQuantities.entrySet()) { + OrderProductSnapshot product = orderRepository.findProductSnapshotById(entry.getKey()) + .orElseThrow(() -> new ApiException("商品不存在: " + entry.getKey())); + int quantity = entry.getValue(); + if (quantity > product.getStockQuantity()) { + throw new ApiException(buildInsufficientStockMessage(product, quantity)); + } + BigDecimal lineAmount = product.getUnitPrice().multiply(BigDecimal.valueOf(quantity)); + lines.add(new PreparedOrderLine(product, quantity, lineAmount)); + totalQuantity += quantity; + totalAmount = totalAmount.add(lineAmount); + } + + return new PreparedOrder(lines, totalQuantity, totalAmount, normalizeRemark(remark)); + } + + private void persistOrderItems(Long orderId, List lines) { + for (PreparedOrderLine line : lines) { + orderRepository.insertOrderItem( + orderId, + line.getProduct().getProductId(), + line.getProduct().getProductName(), + line.getProduct().getSku(), + line.getProduct().getModelName(), + line.getProduct().getUnitPrice(), + line.getQuantity(), + line.getLineAmount() + ); + } + } + + private String generateOrderNo() { + String prefix = "TH" + DateTimeFormatter.BASIC_ISO_DATE.format(LocalDate.now()) + "-"; + int sequence = orderRepository.nextDailyOrderSequence(prefix); + return prefix + String.format("%03d", sequence); + } + + private byte[] createLabelArchive(OrderDetailResponse order, Map templates) { + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8)) { + writeManifest(zipOutputStream, order, templates); + + for (OrderItemResponse item : order.getItems()) { + OrderLabelTemplateContent template = templates.get(item.getProductId()); + for (int index = 1; index <= item.getQuantity(); index++) { + ZipEntry entry = new ZipEntry(buildLabelEntryName(order.getId(), item, template.getFileName(), index)); + zipOutputStream.putNextEntry(entry); + zipOutputStream.write(template.getContent()); + zipOutputStream.closeEntry(); + } + } + + zipOutputStream.finish(); + return outputStream.toByteArray(); + } catch (IOException exception) { + throw new ApiException("标签文件导出失败"); + } + } + + private void writeManifest(ZipOutputStream zipOutputStream, OrderDetailResponse order, Map templates) throws IOException { + StringBuilder builder = new StringBuilder(); + builder.append("订单号: ").append(order.getId()).append('\n'); + builder.append("状态: ").append(order.getStatus()).append('\n'); + builder.append("创建时间: ").append(order.getCreatedAt()).append('\n'); + builder.append("快递单号: ").append(order.getExpressNo() == null || order.getExpressNo().isBlank() ? "待填写" : order.getExpressNo()).append('\n'); + builder.append("导出时间: ").append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))).append('\n'); + builder.append('\n'); + builder.append("明细:\n"); + + for (OrderItemResponse item : order.getItems()) { + OrderLabelTemplateContent template = templates.get(item.getProductId()); + builder.append("- ") + .append(item.getSku()) + .append(" | ") + .append(item.getName()) + .append(" | 数量 ") + .append(item.getQuantity()) + .append(" | 模板 ") + .append(template.getFileName()) + .append('\n'); + } + + zipOutputStream.putNextEntry(new ZipEntry(sanitizeFileComponent(order.getId()) + "/manifest.txt")); + zipOutputStream.write(builder.toString().getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + + private String buildLabelEntryName(String orderNo, OrderItemResponse item, String fileName, int index) { + String safeOrderNo = sanitizeFileComponent(orderNo); + String safeItemFolder = sanitizeFileComponent(item.getSku() + "-" + item.getName()); + String normalizedName = normalizeFileName(fileName); + int extensionIndex = normalizedName.lastIndexOf('.'); + String baseName = extensionIndex > 0 ? normalizedName.substring(0, extensionIndex) : normalizedName; + String extension = extensionIndex > 0 ? normalizedName.substring(extensionIndex) : ""; + int width = Math.max(2, String.valueOf(item.getQuantity()).length()); + String numberedName = sanitizeFileComponent(baseName) + "-" + String.format("%0" + width + "d", index) + extension; + return safeOrderNo + "/" + safeItemFolder + "/" + numberedName; + } + + private String normalizeFileName(String fileName) { + if (fileName == null || fileName.isBlank()) { + return "label.bin"; + } + + return fileName.trim(); + } + + private String sanitizeFileComponent(String value) { + String sanitized = value == null ? "item" : value.replaceAll("[\\\\/:*?\"<>|]", "_").trim(); + return sanitized.isEmpty() ? "item" : sanitized; + } + + private String buildInsufficientStockMessage(OrderProductSnapshot product, int requestedQuantity) { + return "库存不足: " + + product.getSku() + + " " + + product.getProductName() + + ",当前库存 " + + product.getStockQuantity() + + ",请求数量 " + + requestedQuantity; + } + + private OrderSummaryResponse enrichOrderSummary(OrderSummaryResponse order) { + OrderDetailResponse detail = orderRepository.findOrderByOrderNo(order.getId()) + .orElseThrow(() -> new ApiException("订单不存在")); + OrderLabelStatus labelStatus = buildLabelStatus(detail.getItems()); + return new OrderSummaryResponse( + order.getId(), + order.getStatus(), + order.getCreatedAt(), + order.getTotalQuantity(), + order.getTotalAmount(), + order.getExpressNo(), + labelStatus.isReady(), + labelStatus.getMissingProducts() + ); + } + + private OrderDetailResponse enrichOrderDetail(OrderDetailResponse order) { + OrderLabelStatus labelStatus = buildLabelStatus(order.getItems()); + return new OrderDetailResponse( + order.getId(), + order.getStatus(), + order.getCreatedAt(), + order.getTotalQuantity(), + order.getTotalAmount(), + order.getExpressNo(), + order.getCompletedAt(), + order.getRemark(), + order.getItems(), + labelStatus.isReady(), + labelStatus.getMissingProducts() + ); + } + + private OrderLabelStatus buildLabelStatus(List items) { + List missingProducts = new ArrayList<>(); + if (items == null || items.isEmpty()) { + return new OrderLabelStatus(true, missingProducts); + } + + for (OrderItemResponse item : items) { + OrderLabelTemplateContent template = orderRepository.findLatestLabelTemplateByProductId(item.getProductId()) + .orElse(null); + if (template == null || template.getContent() == null || template.getContent().length == 0) { + String productLabel = item.getSku() + " " + item.getName(); + if (!missingProducts.contains(productLabel)) { + missingProducts.add(productLabel); + } + } + } + + return new OrderLabelStatus(missingProducts.isEmpty(), missingProducts); + } + + private String normalizeRemark(String remark) { + if (remark == null) { + return null; + } + + String trimmed = remark.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static final class PreparedOrder { + + private final List lines; + private final int totalQuantity; + private final BigDecimal totalAmount; + private final String remark; + + private PreparedOrder(List lines, int totalQuantity, BigDecimal totalAmount, String remark) { + this.lines = lines; + this.totalQuantity = totalQuantity; + this.totalAmount = totalAmount; + this.remark = remark; + } + + private List getLines() { + return lines; + } + + private int getTotalQuantity() { + return totalQuantity; + } + + private BigDecimal getTotalAmount() { + return totalAmount; + } + + private String getRemark() { + return remark; + } + } + + private static final class PreparedOrderLine { + + private final OrderProductSnapshot product; + private final int quantity; + private final BigDecimal lineAmount; + + private PreparedOrderLine(OrderProductSnapshot product, int quantity, BigDecimal lineAmount) { + this.product = product; + this.quantity = quantity; + this.lineAmount = lineAmount; + } + + private OrderProductSnapshot getProduct() { + return product; + } + + private int getQuantity() { + return quantity; + } + + private BigDecimal getLineAmount() { + return lineAmount; + } + } + + private static final class OrderLabelStatus { + + private final boolean ready; + private final List missingProducts; + + private OrderLabelStatus(boolean ready, List missingProducts) { + this.ready = ready; + this.missingProducts = missingProducts; + } + + private boolean isReady() { + return ready; + } + + private List getMissingProducts() { + return missingProducts; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/order/OrderSummaryResponse.java b/backend/src/main/java/com/teapot/system/order/OrderSummaryResponse.java new file mode 100644 index 0000000..8917e61 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/order/OrderSummaryResponse.java @@ -0,0 +1,71 @@ +package com.teapot.system.order; + +import java.math.BigDecimal; +import java.util.List; + +public class OrderSummaryResponse { + + private final String id; + private final String status; + private final String createdAt; + private final Integer totalQuantity; + private final BigDecimal totalAmount; + private final String expressNo; + private final boolean labelReady; + private final List missingLabelProducts; + + public OrderSummaryResponse(String id, String status, String createdAt, Integer totalQuantity, BigDecimal totalAmount, String expressNo) { + this(id, status, createdAt, totalQuantity, totalAmount, expressNo, true, List.of()); + } + + public OrderSummaryResponse( + String id, + String status, + String createdAt, + Integer totalQuantity, + BigDecimal totalAmount, + String expressNo, + boolean labelReady, + List missingLabelProducts) { + this.id = id; + this.status = status; + this.createdAt = createdAt; + this.totalQuantity = totalQuantity; + this.totalAmount = totalAmount; + this.expressNo = expressNo; + this.labelReady = labelReady; + this.missingLabelProducts = missingLabelProducts == null ? List.of() : List.copyOf(missingLabelProducts); + } + + public String getId() { + return id; + } + + public String getStatus() { + return status; + } + + public String getCreatedAt() { + return createdAt; + } + + public Integer getTotalQuantity() { + return totalQuantity; + } + + public BigDecimal getTotalAmount() { + return totalAmount; + } + + public String getExpressNo() { + return expressNo; + } + + public boolean isLabelReady() { + return labelReady; + } + + public List getMissingLabelProducts() { + return missingLabelProducts; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/order/UpdateOrderRequest.java b/backend/src/main/java/com/teapot/system/order/UpdateOrderRequest.java new file mode 100644 index 0000000..714f474 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/order/UpdateOrderRequest.java @@ -0,0 +1,30 @@ +package com.teapot.system.order; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import java.util.List; + +public class UpdateOrderRequest { + + @NotEmpty(message = "订单明细不能为空") + @Valid + private List items; + + private String remark; + + public List getItems() { + return items; + } + + public void setItems(List items) { + this.items = items; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/statistics/StatisticsController.java b/backend/src/main/java/com/teapot/system/statistics/StatisticsController.java new file mode 100644 index 0000000..a9587b8 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/statistics/StatisticsController.java @@ -0,0 +1,33 @@ +package com.teapot.system.statistics; + +import com.teapot.system.common.ApiResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/statistics") +public class StatisticsController { + + private final StatisticsService statisticsService; + + public StatisticsController(StatisticsService statisticsService) { + this.statisticsService = statisticsService; + } + + @GetMapping("/monthly") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('STATISTICS_VIEW')") + public ApiResponse monthly( + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Integer month) { + return ApiResponse.success(statisticsService.getMonthly(year, month)); + } + + @GetMapping("/yearly") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('STATISTICS_VIEW')") + public ApiResponse yearly(@RequestParam(required = false) Integer year) { + return ApiResponse.success(statisticsService.getYearly(year)); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/statistics/StatisticsRepository.java b/backend/src/main/java/com/teapot/system/statistics/StatisticsRepository.java new file mode 100644 index 0000000..75e0278 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/statistics/StatisticsRepository.java @@ -0,0 +1,56 @@ +package com.teapot.system.statistics; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +@Repository +public class StatisticsRepository { + + private final JdbcTemplate jdbcTemplate; + + public StatisticsRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public List findMonthly(Integer year, Integer month) { + return jdbcTemplate.query( + "SELECT oi.product_name_snapshot, SUM(oi.quantity) AS total_quantity, oi.unit_price, SUM(oi.line_amount) AS total_amount " + + "FROM order_item oi JOIN order_record o ON oi.order_id = o.id " + + "WHERE substr(o.created_at, 1, 4) = ? AND substr(o.created_at, 6, 2) = ? AND o.status <> 'CANCELLED' " + + "GROUP BY oi.product_name_snapshot, oi.unit_price ORDER BY total_quantity DESC, total_amount DESC", + (resultSet, rowNum) -> new StatisticsRowResponse( + resultSet.getString("product_name_snapshot"), + resultSet.getInt("total_quantity"), + readDecimal(resultSet, "unit_price"), + readDecimal(resultSet, "total_amount") + ), + String.valueOf(year), + String.format("%02d", month) + ); + } + + public List findYearly(Integer year) { + return jdbcTemplate.query( + "SELECT oi.product_name_snapshot, SUM(oi.quantity) AS total_quantity, oi.unit_price, SUM(oi.line_amount) AS total_amount " + + "FROM order_item oi JOIN order_record o ON oi.order_id = o.id " + + "WHERE substr(o.created_at, 1, 4) = ? AND o.status <> 'CANCELLED' " + + "GROUP BY oi.product_name_snapshot, oi.unit_price ORDER BY total_quantity DESC, total_amount DESC", + (resultSet, rowNum) -> new StatisticsRowResponse( + resultSet.getString("product_name_snapshot"), + resultSet.getInt("total_quantity"), + readDecimal(resultSet, "unit_price"), + readDecimal(resultSet, "total_amount") + ), + String.valueOf(year) + ); + } + + private BigDecimal readDecimal(ResultSet resultSet, String column) throws SQLException { + return BigDecimal.valueOf(resultSet.getDouble(column)); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/statistics/StatisticsRowResponse.java b/backend/src/main/java/com/teapot/system/statistics/StatisticsRowResponse.java new file mode 100644 index 0000000..25cb332 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/statistics/StatisticsRowResponse.java @@ -0,0 +1,34 @@ +package com.teapot.system.statistics; + +import java.math.BigDecimal; + +public class StatisticsRowResponse { + + private final String name; + private final Integer quantity; + private final BigDecimal price; + private final BigDecimal subtotal; + + public StatisticsRowResponse(String name, Integer quantity, BigDecimal price, BigDecimal subtotal) { + this.name = name; + this.quantity = quantity; + this.price = price; + this.subtotal = subtotal; + } + + public String getName() { + return name; + } + + public Integer getQuantity() { + return quantity; + } + + public BigDecimal getPrice() { + return price; + } + + public BigDecimal getSubtotal() { + return subtotal; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/statistics/StatisticsService.java b/backend/src/main/java/com/teapot/system/statistics/StatisticsService.java new file mode 100644 index 0000000..0347a42 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/statistics/StatisticsService.java @@ -0,0 +1,48 @@ +package com.teapot.system.statistics; + +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.List; + +@Service +public class StatisticsService { + + private final StatisticsRepository statisticsRepository; + + public StatisticsService(StatisticsRepository statisticsRepository) { + this.statisticsRepository = statisticsRepository; + } + + public StatisticsSummaryResponse getMonthly(Integer year, Integer month) { + LocalDate now = LocalDate.now(); + int targetYear = year == null ? now.getYear() : year; + int targetMonth = month == null ? now.getMonthValue() : month; + List rows = statisticsRepository.findMonthly(targetYear, targetMonth); + return buildSummary(targetYear, targetMonth, rows); + } + + public StatisticsSummaryResponse getYearly(Integer year) { + LocalDate now = LocalDate.now(); + int targetYear = year == null ? now.getYear() : year; + List rows = statisticsRepository.findYearly(targetYear); + return buildSummary(targetYear, null, rows); + } + + private StatisticsSummaryResponse buildSummary(Integer year, Integer month, List rows) { + int totalQuantity = rows.stream().mapToInt(StatisticsRowResponse::getQuantity).sum(); + BigDecimal totalAmount = rows.stream() + .map(StatisticsRowResponse::getSubtotal) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal averagePrice = totalQuantity == 0 + ? BigDecimal.ZERO + : totalAmount.divide(BigDecimal.valueOf(totalQuantity), 0, RoundingMode.HALF_UP); + + String topProductName = rows.isEmpty() ? "-" : rows.get(0).getName(); + + return new StatisticsSummaryResponse(year, month, totalQuantity, totalAmount, averagePrice, topProductName, rows); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/statistics/StatisticsSummaryResponse.java b/backend/src/main/java/com/teapot/system/statistics/StatisticsSummaryResponse.java new file mode 100644 index 0000000..a4c4928 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/statistics/StatisticsSummaryResponse.java @@ -0,0 +1,60 @@ +package com.teapot.system.statistics; + +import java.math.BigDecimal; +import java.util.List; + +public class StatisticsSummaryResponse { + + private final Integer year; + private final Integer month; + private final Integer totalQuantity; + private final BigDecimal totalAmount; + private final BigDecimal averagePrice; + private final String topProductName; + private final List rows; + + public StatisticsSummaryResponse( + Integer year, + Integer month, + Integer totalQuantity, + BigDecimal totalAmount, + BigDecimal averagePrice, + String topProductName, + List rows) { + this.year = year; + this.month = month; + this.totalQuantity = totalQuantity; + this.totalAmount = totalAmount; + this.averagePrice = averagePrice; + this.topProductName = topProductName; + this.rows = rows; + } + + public Integer getYear() { + return year; + } + + public Integer getMonth() { + return month; + } + + public Integer getTotalQuantity() { + return totalQuantity; + } + + public BigDecimal getTotalAmount() { + return totalAmount; + } + + public BigDecimal getAveragePrice() { + return averagePrice; + } + + public String getTopProductName() { + return topProductName; + } + + public List getRows() { + return rows; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/user/CreateUserRequest.java b/backend/src/main/java/com/teapot/system/user/CreateUserRequest.java new file mode 100644 index 0000000..ca33d7c --- /dev/null +++ b/backend/src/main/java/com/teapot/system/user/CreateUserRequest.java @@ -0,0 +1,52 @@ +package com.teapot.system.user; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotEmpty; +import java.util.List; + +public class CreateUserRequest { + + @NotBlank(message = "用户名不能为空") + private String username; + + @NotBlank(message = "显示名称不能为空") + private String displayName; + + @NotBlank(message = "密码不能为空") + private String password; + + @NotEmpty(message = "权限不能为空") + private List permissions; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/user/UpdateUserPermissionsRequest.java b/backend/src/main/java/com/teapot/system/user/UpdateUserPermissionsRequest.java new file mode 100644 index 0000000..ec2624f --- /dev/null +++ b/backend/src/main/java/com/teapot/system/user/UpdateUserPermissionsRequest.java @@ -0,0 +1,18 @@ +package com.teapot.system.user; + +import javax.validation.constraints.NotEmpty; +import java.util.List; + +public class UpdateUserPermissionsRequest { + + @NotEmpty(message = "权限列表不能为空") + private List permissions; + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/user/UpdateUserStatusRequest.java b/backend/src/main/java/com/teapot/system/user/UpdateUserStatusRequest.java new file mode 100644 index 0000000..96a5138 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/user/UpdateUserStatusRequest.java @@ -0,0 +1,17 @@ +package com.teapot.system.user; + +import javax.validation.constraints.NotBlank; + +public class UpdateUserStatusRequest { + + @NotBlank(message = "状态不能为空") + private String status; + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/user/UserAccount.java b/backend/src/main/java/com/teapot/system/user/UserAccount.java new file mode 100644 index 0000000..3e6e27c --- /dev/null +++ b/backend/src/main/java/com/teapot/system/user/UserAccount.java @@ -0,0 +1,44 @@ +package com.teapot.system.user; + +public class UserAccount { + + private final Long id; + private final String username; + private final String passwordHash; + private final String displayName; + private final String userType; + private final String status; + + public UserAccount(Long id, String username, String passwordHash, String displayName, String userType, String status) { + this.id = id; + this.username = username; + this.passwordHash = passwordHash; + this.displayName = displayName; + this.userType = userType; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public String getPasswordHash() { + return passwordHash; + } + + public String getDisplayName() { + return displayName; + } + + public String getUserType() { + return userType; + } + + public String getStatus() { + return status; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/user/UserAccountRepository.java b/backend/src/main/java/com/teapot/system/user/UserAccountRepository.java new file mode 100644 index 0000000..b557aed --- /dev/null +++ b/backend/src/main/java/com/teapot/system/user/UserAccountRepository.java @@ -0,0 +1,115 @@ +package com.teapot.system.user; + +import com.teapot.system.auth.PermissionCodes; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.Optional; + +@Repository +public class UserAccountRepository { + + private final JdbcTemplate jdbcTemplate; + + public UserAccountRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public Optional findByUsername(String username) { + List results = jdbcTemplate.query( + "SELECT id, username, password_hash, display_name, user_type, status FROM user_account WHERE username = ?", + userRowMapper(), + username + ); + return results.stream().findFirst(); + } + + public Optional findById(Long id) { + List results = jdbcTemplate.query( + "SELECT id, username, password_hash, display_name, user_type, status FROM user_account WHERE id = ?", + userRowMapper(), + id + ); + return results.stream().findFirst(); + } + + public List findAll() { + return jdbcTemplate.query( + "SELECT id, username, password_hash, display_name, user_type, status FROM user_account ORDER BY id ASC", + userRowMapper() + ); + } + + public List findPermissionsByUserId(Long userId) { + return jdbcTemplate.queryForList( + "SELECT permission_code FROM user_permission WHERE user_id = ? ORDER BY permission_code ASC", + String.class, + userId + ); + } + + public long insertUser(String username, String passwordHash, String displayName, String userType, Long createdBy) { + jdbcTemplate.update( + "INSERT INTO user_account (username, password_hash, display_name, user_type, status, created_by) VALUES (?, ?, ?, ?, 'ENABLED', ?)", + username, + passwordHash, + displayName, + userType, + createdBy + ); + + Long id = jdbcTemplate.queryForObject("SELECT id FROM user_account WHERE username = ?", Long.class, username); + return id == null ? 0L : id; + } + + public void replacePermissions(Long userId, List permissions) { + jdbcTemplate.update("DELETE FROM user_permission WHERE user_id = ?", userId); + for (String permission : permissions) { + jdbcTemplate.update( + "INSERT INTO user_permission (user_id, permission_code, permission_name) VALUES (?, ?, ?)", + userId, + permission, + PermissionCodes.NAME_MAP.getOrDefault(permission, permission) + ); + } + } + + public void updateStatus(Long userId, String status) { + jdbcTemplate.update( + "UPDATE user_account SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + status, + userId + ); + } + + public void updateLastLoginAt(Long userId) { + jdbcTemplate.update( + "UPDATE user_account SET last_login_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + userId + ); + } + + public boolean existsByUsername(String username) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM user_account WHERE username = ?", + Integer.class, + username + ); + return count != null && count > 0; + } + + private RowMapper userRowMapper() { + return (resultSet, rowNum) -> new UserAccount( + resultSet.getLong("id"), + resultSet.getString("username"), + resultSet.getString("password_hash"), + resultSet.getString("display_name"), + resultSet.getString("user_type"), + resultSet.getString("status") + ); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/user/UserController.java b/backend/src/main/java/com/teapot/system/user/UserController.java new file mode 100644 index 0000000..fd8fee2 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/user/UserController.java @@ -0,0 +1,56 @@ +package com.teapot.system.user; + +import com.teapot.system.common.ApiResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/users") +public class UserController { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping + @PreAuthorize("hasRole('ADMIN') or hasAuthority('USER_MANAGE')") + public ApiResponse> listUsers() { + return ApiResponse.success(userService.listUsers()); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN') or hasAuthority('USER_MANAGE')") + public ApiResponse createUser(@Valid @RequestBody CreateUserRequest request) { + return ApiResponse.success("创建成功", userService.createNormalUser(request)); + } + + @PutMapping("/{id}/permissions") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('USER_MANAGE')") + public ApiResponse> updatePermissions( + @PathVariable Long id, + @Valid @RequestBody UpdateUserPermissionsRequest request) { + userService.updatePermissions(id, request.getPermissions()); + return ApiResponse.success(Map.of("message", "权限更新成功")); + } + + @PutMapping("/{id}/status") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('USER_MANAGE')") + public ApiResponse> updateStatus( + @PathVariable Long id, + @Valid @RequestBody UpdateUserStatusRequest request) { + userService.updateStatus(id, request.getStatus()); + return ApiResponse.success(Map.of("message", "状态更新成功")); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/user/UserListItemResponse.java b/backend/src/main/java/com/teapot/system/user/UserListItemResponse.java new file mode 100644 index 0000000..a729168 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/user/UserListItemResponse.java @@ -0,0 +1,46 @@ +package com.teapot.system.user; + +import java.util.List; + +public class UserListItemResponse { + + private final Long id; + private final String username; + private final String displayName; + private final String userType; + private final String status; + private final List permissions; + + public UserListItemResponse(Long id, String username, String displayName, String userType, String status, List permissions) { + this.id = id; + this.username = username; + this.displayName = displayName; + this.userType = userType; + this.status = status; + this.permissions = permissions; + } + + public Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public String getDisplayName() { + return displayName; + } + + public String getUserType() { + return userType; + } + + public String getStatus() { + return status; + } + + public List getPermissions() { + return permissions; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/teapot/system/user/UserService.java b/backend/src/main/java/com/teapot/system/user/UserService.java new file mode 100644 index 0000000..e614078 --- /dev/null +++ b/backend/src/main/java/com/teapot/system/user/UserService.java @@ -0,0 +1,98 @@ +package com.teapot.system.user; + +import com.teapot.system.auth.AuthenticatedUser; +import com.teapot.system.common.ApiException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class UserService { + + private final UserAccountRepository userAccountRepository; + private final PasswordEncoder passwordEncoder; + + public UserService(UserAccountRepository userAccountRepository, PasswordEncoder passwordEncoder) { + this.userAccountRepository = userAccountRepository; + this.passwordEncoder = passwordEncoder; + } + + public List listUsers() { + return userAccountRepository.findAll().stream() + .map(user -> new UserListItemResponse( + user.getId(), + user.getUsername(), + user.getDisplayName(), + user.getUserType(), + user.getStatus(), + userAccountRepository.findPermissionsByUserId(user.getId()) + )) + .collect(Collectors.toList()); + } + + public UserListItemResponse createNormalUser(CreateUserRequest request) { + if (userAccountRepository.existsByUsername(request.getUsername())) { + throw new ApiException("用户名已存在"); + } + + Long currentUserId = currentUser().getId(); + long userId = userAccountRepository.insertUser( + request.getUsername(), + passwordEncoder.encode(request.getPassword()), + request.getDisplayName(), + "NORMAL", + currentUserId + ); + userAccountRepository.replacePermissions(userId, request.getPermissions()); + + UserAccount user = userAccountRepository.findById(userId) + .orElseThrow(() -> new ApiException("创建用户失败")); + + return new UserListItemResponse( + user.getId(), + user.getUsername(), + user.getDisplayName(), + user.getUserType(), + user.getStatus(), + userAccountRepository.findPermissionsByUserId(user.getId()) + ); + } + + public void updatePermissions(Long userId, List permissions) { + UserAccount user = userAccountRepository.findById(userId) + .orElseThrow(() -> new ApiException("用户不存在")); + + if ("ADMIN".equals(user.getUserType())) { + throw new ApiException("管理员账号权限不允许修改"); + } + + userAccountRepository.replacePermissions(userId, permissions); + } + + public void updateStatus(Long userId, String status) { + UserAccount user = userAccountRepository.findById(userId) + .orElseThrow(() -> new ApiException("用户不存在")); + + if ("ADMIN".equals(user.getUserType())) { + throw new ApiException("管理员账号不允许停用"); + } + + if (!"ENABLED".equals(status) && !"DISABLED".equals(status)) { + throw new ApiException("用户状态非法"); + } + + userAccountRepository.updateStatus(userId, status); + } + + private AuthenticatedUser currentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof AuthenticatedUser)) { + throw new ApiException("未获取到登录用户"); + } + return (AuthenticatedUser) authentication.getPrincipal(); + } +} \ No newline at end of file diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties new file mode 100644 index 0000000..05d0ee7 --- /dev/null +++ b/backend/src/main/resources/application.properties @@ -0,0 +1,12 @@ +spring.application.name=teapot-system-backend + +server.port=8080 + +spring.datasource.url=jdbc:sqlite:./teapot-system.db +spring.datasource.driver-class-name=org.sqlite.JDBC +spring.sql.init.mode=always +spring.sql.init.schema-locations=classpath:db/schema.sql +spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration + +app.security.jwt-secret=teapot-system-demo-secret-key-for-jwt-auth-2026 +app.security.jwt-expire-hours=12 diff --git a/backend/src/main/resources/db/schema.sql b/backend/src/main/resources/db/schema.sql new file mode 100644 index 0000000..e8d9302 --- /dev/null +++ b/backend/src/main/resources/db/schema.sql @@ -0,0 +1,102 @@ +CREATE TABLE IF NOT EXISTS user_account ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + user_type TEXT NOT NULL, + status TEXT NOT NULL, + last_login_at TEXT, + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS user_permission ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + permission_code TEXT NOT NULL, + permission_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, permission_code) +); + +CREATE TABLE IF NOT EXISTS category ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_id INTEGER, + name TEXT NOT NULL, + sort_no INTEGER NOT NULL DEFAULT 0, + requires_detail_page INTEGER NOT NULL DEFAULT 1, + category_type TEXT NOT NULL DEFAULT 'PRODUCT', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS product_item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL, + sku TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + model_name TEXT, + status TEXT NOT NULL DEFAULT 'AVAILABLE', + wholesale_price NUMERIC NOT NULL DEFAULT 0, + stock_quantity INTEGER NOT NULL DEFAULT 0, + cover_asset_id INTEGER, + remark TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS file_asset ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + business_type TEXT NOT NULL, + business_id INTEGER NOT NULL, + file_role TEXT NOT NULL, + file_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + file_size INTEGER NOT NULL, + content_blob BLOB, + preview_blob BLOB, + sort_no INTEGER NOT NULL DEFAULT 0, + is_primary INTEGER NOT NULL DEFAULT 0, + sha256 TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS order_record ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_no TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'PENDING', + total_quantity INTEGER NOT NULL DEFAULT 0, + total_amount NUMERIC NOT NULL DEFAULT 0, + express_no TEXT, + remark TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TEXT +); + +CREATE TABLE IF NOT EXISTS order_item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + product_name_snapshot TEXT NOT NULL, + sku_snapshot TEXT NOT NULL, + model_name_snapshot TEXT, + unit_price NUMERIC NOT NULL DEFAULT 0, + quantity INTEGER NOT NULL DEFAULT 0, + line_amount NUMERIC NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS order_operation_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL, + operation_type TEXT NOT NULL, + operation_content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_user_permission_user_id ON user_permission(user_id); +CREATE INDEX IF NOT EXISTS idx_category_parent_id ON category(parent_id); +CREATE INDEX IF NOT EXISTS idx_product_category_id ON product_item(category_id); +CREATE INDEX IF NOT EXISTS idx_file_asset_business ON file_asset(business_type, business_id); +CREATE INDEX IF NOT EXISTS idx_order_item_order_id ON order_item(order_id); +CREATE INDEX IF NOT EXISTS idx_order_log_order_id ON order_operation_log(order_id); \ No newline at end of file diff --git a/backend/src/test/java/com/teapot/system/BackendApplicationTests.java b/backend/src/test/java/com/teapot/system/BackendApplicationTests.java new file mode 100644 index 0000000..14b6ef8 --- /dev/null +++ b/backend/src/test/java/com/teapot/system/BackendApplicationTests.java @@ -0,0 +1,18 @@ +package com.teapot.system; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:sqlite:./target/backend-application-tests.db", + "server.port=0" +}) +class BackendApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/backend/src/test/java/com/teapot/system/catalog/CatalogServiceIntegrationTests.java b/backend/src/test/java/com/teapot/system/catalog/CatalogServiceIntegrationTests.java new file mode 100644 index 0000000..1da7cb7 --- /dev/null +++ b/backend/src/test/java/com/teapot/system/catalog/CatalogServiceIntegrationTests.java @@ -0,0 +1,87 @@ +package com.teapot.system.catalog; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Transactional +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:sqlite:./target/catalog-service-integration-tests.db", + "server.port=0" +}) +class CatalogServiceIntegrationTests { + + @Autowired + private CatalogService catalogService; + + @Test + void getProductsByCategoryShouldReturnPrimaryCoverAssetId() { + MockMultipartFile image = new MockMultipartFile( + "file", + "cover-101.jpg", + "image/jpeg", + "cover-image-for-101".getBytes(StandardCharsets.UTF_8) + ); + + ProductAssetResponse uploaded = catalogService.uploadProductAsset(101L, "IMAGE", true, image); + ProductSummaryResponse product = catalogService.getProductsByCategory(1L).stream() + .filter(item -> Long.valueOf(101L).equals(item.getId())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("未找到商品 101 的摘要")); + + assertThat(product.getCoverAssetId()).isEqualTo(uploaded.getId()); + } + + @Test + void reorderProductImageAssetsShouldPersistSortOrder() { + MockMultipartFile firstImage = new MockMultipartFile( + "file", + "first.jpg", + "image/jpeg", + "first-image".getBytes(StandardCharsets.UTF_8) + ); + MockMultipartFile secondImage = new MockMultipartFile( + "file", + "second.jpg", + "image/jpeg", + "second-image".getBytes(StandardCharsets.UTF_8) + ); + MockMultipartFile thirdImage = new MockMultipartFile( + "file", + "third.jpg", + "image/jpeg", + "third-image".getBytes(StandardCharsets.UTF_8) + ); + + ProductAssetResponse first = catalogService.uploadProductAsset(101L, "IMAGE", true, firstImage); + ProductAssetResponse second = catalogService.uploadProductAsset(101L, "IMAGE", false, secondImage); + ProductAssetResponse third = catalogService.uploadProductAsset(101L, "IMAGE", false, thirdImage); + + catalogService.reorderProductImageAssets(101L, List.of(second.getId(), third.getId(), first.getId())); + + List reorderedImages = catalogService.getProductAssets(101L).stream() + .filter(asset -> "IMAGE".equals(asset.getFileRole())) + .collect(Collectors.toList()); + + assertThat(reorderedImages) + .extracting(ProductAssetResponse::getId) + .containsExactly(second.getId(), third.getId(), first.getId()); + assertThat(reorderedImages) + .extracting(ProductAssetResponse::getSortNo) + .containsExactly(1, 2, 3); + assertThat(reorderedImages) + .filteredOn(ProductAssetResponse::isPrimary) + .extracting(ProductAssetResponse::getId) + .containsExactly(first.getId()); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/teapot/system/order/OrderServiceIntegrationTests.java b/backend/src/test/java/com/teapot/system/order/OrderServiceIntegrationTests.java new file mode 100644 index 0000000..5c8684a --- /dev/null +++ b/backend/src/test/java/com/teapot/system/order/OrderServiceIntegrationTests.java @@ -0,0 +1,153 @@ +package com.teapot.system.order; + +import com.teapot.system.catalog.CatalogService; +import com.teapot.system.catalog.ProductDetailResponse; +import com.teapot.system.common.ApiException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@Transactional +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:sqlite:./target/order-service-integration-tests.db", + "server.port=0" +}) +class OrderServiceIntegrationTests { + + private static final String COMPLETED_ORDER_NO = "TH20260410-005"; + + @Autowired + private OrderService orderService; + + @Autowired + private CatalogService catalogService; + + @Test + void createOrderShouldRejectWhenQuantityExceedsStock() { + ProductDetailResponse product = catalogService.getProduct(201L); + + CreateOrderRequest request = createOrderRequest(201L, product.getStock() + 1, "超库存创建测试"); + + assertThatThrownBy(() -> orderService.createOrder(request)) + .isInstanceOf(ApiException.class) + .hasMessageContaining("库存不足") + .hasMessageContaining(product.getSku()); + } + + @Test + void updateOrderShouldRejectWhenQuantityExceedsStock() { + OrderDetailResponse createdOrder = orderService.createOrder(createOrderRequest(201L, 1, "编辑库存测试")); + ProductDetailResponse product = catalogService.getProduct(201L); + + UpdateOrderRequest request = createUpdateOrderRequest(201L, product.getStock() + 1, "超库存编辑测试"); + + assertThatThrownBy(() -> orderService.updateOrder(createdOrder.getId(), request)) + .isInstanceOf(ApiException.class) + .hasMessageContaining("库存不足") + .hasMessageContaining(product.getSku()); + } + + @Test + void completeOrderShouldDeductProductStock() { + int stockBefore = catalogService.getProduct(201L).getStock(); + OrderDetailResponse createdOrder = orderService.createOrder(createOrderRequest(201L, 2, "库存扣减测试")); + + OrderDetailResponse completedOrder = orderService.completeOrder(createdOrder.getId(), "SF-TEST-20260411"); + int stockAfter = catalogService.getProduct(201L).getStock(); + + assertThat(completedOrder.getStatus()).isEqualTo("COMPLETED"); + assertThat(completedOrder.getExpressNo()).isEqualTo("SF-TEST-20260411"); + assertThat(stockAfter).isEqualTo(stockBefore - 2); + } + + @Test + void deleteCompletedOrderShouldRejectForNonAdmin() { + assertThatThrownBy(() -> orderService.deleteOrder(COMPLETED_ORDER_NO)) + .isInstanceOf(ApiException.class) + .hasMessageContaining("只有管理员才允许删除已完成订单"); + } + + @Test + @WithMockUser(roles = "ADMIN") + void adminShouldDeleteCompletedOrder() { + orderService.deleteOrder(COMPLETED_ORDER_NO); + + assertThatThrownBy(() -> orderService.getOrderDetail(COMPLETED_ORDER_NO)) + .isInstanceOf(ApiException.class) + .hasMessageContaining("订单不存在"); + } + + @Test + void orderLabelStatusShouldChangeAfterUploadingTemplates() { + OrderDetailResponse detailBefore = orderService.getOrderDetail(COMPLETED_ORDER_NO); + OrderSummaryResponse summaryBefore = orderService.listOrders().stream() + .filter(order -> COMPLETED_ORDER_NO.equals(order.getId())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("未找到订单摘要")); + + assertThat(detailBefore.isLabelReady()).isFalse(); + assertThat(detailBefore.getMissingLabelProducts()).anyMatch(item -> item.contains("TH-002-A")); + assertThat(summaryBefore.isLabelReady()).isFalse(); + + uploadLabelTemplate(201L, "test-201-label.txt"); + uploadLabelTemplate(101L, "test-101-label.txt"); + + OrderDetailResponse detailAfter = orderService.getOrderDetail(COMPLETED_ORDER_NO); + OrderSummaryResponse summaryAfter = orderService.listOrders().stream() + .filter(order -> COMPLETED_ORDER_NO.equals(order.getId())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("未找到订单摘要")); + OrderLabelDownload labelDownload = orderService.downloadOrderLabels(COMPLETED_ORDER_NO); + + assertThat(detailAfter.isLabelReady()).isTrue(); + assertThat(detailAfter.getMissingLabelProducts()).isEmpty(); + assertThat(summaryAfter.isLabelReady()).isTrue(); + assertThat(summaryAfter.getMissingLabelProducts()).isEmpty(); + assertThat(labelDownload.getFileName()).isEqualTo(COMPLETED_ORDER_NO + "-labels.zip"); + assertThat(labelDownload.getFileSize()).isPositive(); + assertThat(labelDownload.getContent()).isNotEmpty(); + } + + private CreateOrderRequest createOrderRequest(Long productId, int quantity, String remark) { + CreateOrderRequest request = new CreateOrderRequest(); + request.setRemark(remark); + request.setItems(List.of(createOrderItem(productId, quantity))); + return request; + } + + private UpdateOrderRequest createUpdateOrderRequest(Long productId, int quantity, String remark) { + UpdateOrderRequest request = new UpdateOrderRequest(); + request.setRemark(remark); + request.setItems(List.of(createOrderItem(productId, quantity))); + return request; + } + + private OrderItemRequest createOrderItem(Long productId, int quantity) { + OrderItemRequest item = new OrderItemRequest(); + item.setProductId(productId); + item.setQuantity(quantity); + return item; + } + + private void uploadLabelTemplate(Long productId, String fileName) { + MockMultipartFile file = new MockMultipartFile( + "file", + fileName, + "text/plain", + ("label-template-for-" + productId).getBytes(StandardCharsets.UTF_8) + ); + + catalogService.uploadProductAsset(productId, "LABEL", false, file); + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7ac1ce6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: teapot_backend + restart: always + environment: + # 指定SQLite数据库文件挂载到/app/data中,防止容器重启丢失数据 + - SPRING_DATASOURCE_URL=jdbc:sqlite:/app/data/teapot-system.db + # NAS服务器的关键:确保商品图片或数据库不随容器重启销毁 + volumes: + - ./data:/app/data + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: teapot_frontend + restart: always + depends_on: + - backend + ports: + # 选择避开了NAS常见的80, 443, 3000, 8080等端口,用 8090 作为外部访问端。 + # 你可以在NAS路由器或面板中直接访问: http://群晖NAS_IP:8090 + - "8090:80" \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..77bc366 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,30 @@ +# Frontend Guidelines + +## Scope +- Applies to files under frontend/. +- Inherit the root [AGENTS.md](../AGENTS.md) and only override frontend-specific expectations here. + +## Stack +- Vue 3 + Vite + TypeScript + Vue Router + Pinia + Element Plus. +- Local API base URL is configured through [frontend/.env.local](.env.local) and should point to http://localhost:8081 for local development. + +## Build And Test +- Install dependencies from frontend/: npm install +- Start the dev server from frontend/: npm run dev +- Build from frontend/: npm run build +- Run the frontend build after any change under frontend/. + +## Conventions +- Keep route-level pages lazily loaded in [frontend/src/router/index.ts](src/router/index.ts). +- Preserve the current split between views, components, services, stores, layouts, and types. Do not move API calls into view files when they belong in [frontend/src/services](src/services). +- Keep permission-aware navigation and access rules centered in [frontend/src/router/index.ts](src/router/index.ts), [frontend/src/stores/auth.ts](src/stores/auth.ts), and [frontend/src/services/access.ts](src/services/access.ts). +- Keep Element Plus on-demand loading configured in [frontend/vite.config.ts](vite.config.ts). Do not reintroduce global Element Plus registration or full-library CSS import in [frontend/src/main.ts](src/main.ts). +- When adding heavy UI dependencies or shared utilities, preserve the current manual chunk strategy in [frontend/vite.config.ts](vite.config.ts) and watch for new build-size regressions. +- Reuse shared business components such as [frontend/src/components/OrderEditorDialog.vue](src/components/OrderEditorDialog.vue) instead of duplicating order editing flows. +- Preserve the established visual language from [前端页面UI方案.md](../前端页面UI方案.md): warm copper palette, fixed left navigation, and dedicated detail routes instead of modal-driven workflows. + +## Frontend Business Rules +- Product detail remains a standalone route and must keep the left category tree visible during navigation. +- Read-only users may browse product and order information but must not see edit-only actions. +- Product and order screens should reflect the same permission and stock rules enforced by the backend, not looser frontend-only behavior. +- Label download entry points stay in order detail flows, while product detail handles image and label template management. \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..befb9ce --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:18-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . + +# NAS部署非常关键的一步:由于走Nginx同域反代,需将URL置空以使用相对路径,避免局域网IP锁死 +ENV VITE_API_BASE_URL="" +RUN npm run build + +FROM nginx:alpine +# 获取vite构件结果 +COPY --from=builder /app/dist /usr/share/nginx/html +# 覆盖Nginx默认配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# 暴露的Web服务器访问端口 +EXPOSE 80 \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,5 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..3f82d0c --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 80; + listen [::]:80; + server_name localhost; + + # 开启 gzip 压缩 + gzip on; + gzip_min_length 1k; + gzip_comp_level 6; + gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + # 支持 Vue Router history 模式 + try_files $uri $uri/ /index.html; + } + + # 反向代理所有后端的 API 请求 -> backend服务容器 (Docker compose 网络内的名字和暴露的端口) + location /api/ { + proxy_pass http://backend:8080/api/; + + # 传递客户端真实IP给后端 + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f021c75 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1891 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "element-plus": "^2.13.7", + "pinia": "^3.0.4", + "vue": "^3.5.32", + "vue-router": "^5.0.4" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.9.1", + "typescript": "~6.0.2", + "unplugin-vue-components": "^32.0.0", + "vite": "^8.0.4", + "vue-tsc": "^3.2.6" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "dev": true, + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.6.tgz", + "integrity": "sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==" + }, + "node_modules/@vue/tsconfig": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.1.tgz", + "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==", + "dev": true, + "peerDependencies": { + "typescript": ">= 5.8", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", + "dependencies": { + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==" + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/element-plus": { + "version": "2.13.7", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.7.tgz", + "integrity": "sha512-XdHATFZOyzVFL1DaHQ90IOJQSg9UnSAV+bhDW+YB5UoZ0Hxs50mwqjqfwXkuwpSag+VXXizVcErBR6Movo5daw==", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ] + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ] + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true + }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/unplugin-vue-components": { + "version": "32.0.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.0.0.tgz", + "integrity": "sha512-uLdccgS7mf3pv1bCCP20y/hm+u1eOjAmygVkh+Oa70MPkzgl1eQv1L0CwdHNM3gscO8/GDMGIET98Ja47CBbZg==", + "dev": true, + "dependencies": { + "chokidar": "^5.0.0", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "obug": "^2.1.1", + "picomatch": "^4.0.3", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==" + }, + "node_modules/vue-router": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.4.tgz", + "integrity": "sha512-lCqDLCI2+fKVRl2OzXuzdSWmxXFLQRxQbmHugnRpTMyYiT+hNaycV0faqG5FBHDXoYrZ6MQcX87BvbY8mQ20Bg==", + "dependencies": { + "@babel/generator": "^7.28.6", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.0.6", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.17", + "pinia": "^3.0.4", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.1.tgz", + "integrity": "sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==", + "dependencies": { + "@vue/devtools-kit": "^8.1.1" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-kit": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.1.tgz", + "integrity": "sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==", + "dependencies": { + "@vue/devtools-shared": "^8.1.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-shared": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz", + "integrity": "sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==" + }, + "node_modules/vue-router/node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==" + }, + "node_modules/vue-tsc": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.6.tgz", + "integrity": "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==", + "dev": true, + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.6" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f08c9ec --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "element-plus": "^2.13.7", + "pinia": "^3.0.4", + "vue": "^3.5.32", + "vue-router": "^5.0.4" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.9.1", + "typescript": "~6.0.2", + "unplugin-vue-components": "^32.0.0", + "vite": "^8.0.4", + "vue-tsc": "^3.2.6" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..7c2aa3f --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/assets/brand-mark.svg b/frontend/src/assets/brand-mark.svg new file mode 100644 index 0000000..52adb2e --- /dev/null +++ b/frontend/src/assets/brand-mark.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..cc51a3d20ad4bc961b596a6adfd686685cd84bb0 GIT binary patch literal 44919 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 JPDHLkV1myZcL)Fg literal 0 HcmV?d00001 diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 0000000..5917e16 --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,93 @@ + + + diff --git a/frontend/src/components/OrderEditorDialog.vue b/frontend/src/components/OrderEditorDialog.vue new file mode 100644 index 0000000..fa6f873 --- /dev/null +++ b/frontend/src/components/OrderEditorDialog.vue @@ -0,0 +1,321 @@ + + + \ No newline at end of file diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue new file mode 100644 index 0000000..5e9f8c3 --- /dev/null +++ b/frontend/src/layouts/AppLayout.vue @@ -0,0 +1,156 @@ + + + \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..f08f544 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,9 @@ +import { createApp } from 'vue' +import 'element-plus/es/components/message/style/css' +import 'element-plus/es/components/message-box/style/css' +import App from './App.vue' +import router from './router' +import { pinia } from './stores' +import './style.css' + +createApp(App).use(pinia).use(router).mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..ee07cb4 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,111 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { pinia } from '../stores' +import { useAuthStore } from '../stores/auth' +import { resolveHomeRoute } from '../services/access' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/login', + name: 'login', + component: () => import('../views/auth/LoginView.vue'), + meta: { title: '登录' }, + }, + { + path: '/', + component: () => import('../layouts/AppLayout.vue'), + meta: { requiresAuth: true }, + children: [ + { + path: '', + redirect: '/categories/1', + }, + { + path: 'categories/:categoryId', + name: 'product-center', + component: () => import('../views/product/ProductCenterView.vue'), + meta: { + title: '商品中心', + permissions: ['PRODUCT_VIEW', 'PRODUCT_EDIT', 'ASSET_UPLOAD'], + }, + }, + { + path: 'products/:productId', + name: 'product-detail', + component: () => import('../views/product/ProductDetailView.vue'), + meta: { + title: '商品详情', + permissions: ['PRODUCT_VIEW', 'PRODUCT_EDIT', 'ASSET_UPLOAD'], + }, + }, + { + path: 'orders', + name: 'orders', + component: () => import('../views/order/OrdersView.vue'), + meta: { + title: '订单中心', + permissions: ['ORDER_VIEW', 'ORDER_PROCESS', 'ORDER_COMPLETE'], + }, + }, + { + path: 'orders/:orderId', + name: 'order-detail', + component: () => import('../views/order/OrderDetailView.vue'), + meta: { + title: '订单详情', + permissions: ['ORDER_VIEW', 'ORDER_PROCESS', 'ORDER_COMPLETE'], + }, + }, + { + path: 'statistics', + name: 'statistics', + component: () => import('../views/statistics/StatisticsView.vue'), + meta: { + title: '经营统计', + permissions: ['STATISTICS_VIEW'], + }, + }, + { + path: 'system/users', + name: 'users', + component: () => import('../views/system/UsersView.vue'), + meta: { + title: '用户管理', + permissions: ['USER_MANAGE'], + }, + }, + ], + }, + ], +}) + +router.beforeEach(async (to) => { + const authStore = useAuthStore(pinia) + + if (!authStore.initialized) { + await authStore.bootstrap() + } + + if (to.name === 'login' && authStore.isAuthenticated) { + return resolveHomeRoute(authStore.user) + } + + const requiresAuth = to.matched.some((record) => record.meta.requiresAuth) + if (requiresAuth && !authStore.isAuthenticated) { + return { name: 'login' } + } + + const permissions = to.matched.flatMap((record) => { + const metaPermissions = record.meta.permissions + return Array.isArray(metaPermissions) ? metaPermissions : [] + }) + + if (permissions.length > 0 && !authStore.canAny(permissions)) { + return resolveHomeRoute(authStore.user) + } + + return true +}) + +export default router \ No newline at end of file diff --git a/frontend/src/services/access.ts b/frontend/src/services/access.ts new file mode 100644 index 0000000..4135a88 --- /dev/null +++ b/frontend/src/services/access.ts @@ -0,0 +1,58 @@ +import type { RouteLocationRaw } from 'vue-router' +import type { CurrentUser } from '../types/auth' + +export const PRODUCT_PERMISSIONS = ['PRODUCT_VIEW', 'PRODUCT_EDIT', 'ASSET_UPLOAD'] +export const ORDER_PERMISSIONS = ['ORDER_VIEW', 'ORDER_PROCESS', 'ORDER_COMPLETE'] +export const STATISTICS_PERMISSIONS = ['STATISTICS_VIEW'] +export const USER_MANAGE_PERMISSIONS = ['USER_MANAGE'] + +export const PERMISSION_OPTIONS = [ + { code: 'PRODUCT_VIEW', label: '商品查看', group: '商品' }, + { code: 'PRODUCT_EDIT', label: '商品编辑', group: '商品' }, + { code: 'ASSET_UPLOAD', label: '附件上传', group: '商品' }, + { code: 'ORDER_VIEW', label: '订单查看', group: '订单' }, + { code: 'ORDER_PROCESS', label: '订单处理', group: '订单' }, + { code: 'ORDER_COMPLETE', label: '订单完成', group: '订单' }, + { code: 'STATISTICS_VIEW', label: '统计查看', group: '统计' }, + { code: 'USER_MANAGE', label: '用户管理', group: '系统' }, +] as const + +export const PERMISSION_LABEL_MAP: Record = Object.fromEntries( + PERMISSION_OPTIONS.map((option) => [option.code, option.label]), +) + +export function hasAnyPermission(user: CurrentUser | null, permissions: string[]) { + if (!user) { + return false + } + + if (user.userType === 'ADMIN') { + return true + } + + return permissions.some((permission) => user.permissions.includes(permission)) +} + +export function hasPermission(user: CurrentUser | null, permission: string) { + return hasAnyPermission(user, [permission]) +} + +export function resolveHomeRoute(user: CurrentUser | null): RouteLocationRaw { + if (hasAnyPermission(user, PRODUCT_PERMISSIONS)) { + return { name: 'product-center', params: { categoryId: '1' } } + } + + if (hasAnyPermission(user, ORDER_PERMISSIONS)) { + return { name: 'orders' } + } + + if (hasAnyPermission(user, STATISTICS_PERMISSIONS)) { + return { name: 'statistics' } + } + + if (hasAnyPermission(user, USER_MANAGE_PERMISSIONS)) { + return { name: 'users' } + } + + return { name: 'login' } +} \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..28adbbf --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,86 @@ +import type { ApiResponse } from '../types/auth' + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080' + +function resolveHeaders(options: RequestInit) { + const headers = new Headers(options.headers ?? {}) + const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData + + if (!headers.has('Content-Type') && options.body && !isFormData) { + headers.set('Content-Type', 'application/json') + } + + return headers +} + +function extractFileName(contentDisposition: string | null) { + if (!contentDisposition) { + return null + } + + const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i) + if (utf8Match?.[1]) { + return decodeURIComponent(utf8Match[1]) + } + + const simpleMatch = contentDisposition.match(/filename="?([^";]+)"?/i) + return simpleMatch?.[1] ?? null +} + +export async function request( + path: string, + options: RequestInit = {}, + token?: string, +): Promise { + const headers = resolveHeaders(options) + + if (token) { + headers.set('Authorization', `Bearer ${token}`) + } + + const response = await fetch(`${API_BASE_URL}${path}`, { + ...options, + headers, + }) + + const result = (await response.json()) as ApiResponse + + if (!response.ok || !result.success) { + throw new Error(result.message || '请求失败') + } + + return result.data +} + +export async function requestBlob( + path: string, + options: RequestInit = {}, + token?: string, +): Promise<{ blob: Blob; fileName: string | null; mimeType: string | null }> { + const headers = resolveHeaders(options) + + if (token) { + headers.set('Authorization', `Bearer ${token}`) + } + + const response = await fetch(`${API_BASE_URL}${path}`, { + ...options, + headers, + }) + + if (!response.ok) { + const contentType = response.headers.get('Content-Type') ?? '' + if (contentType.includes('application/json')) { + const result = (await response.json()) as ApiResponse + throw new Error(result.message || '请求失败') + } + + throw new Error(await response.text()) + } + + return { + blob: await response.blob(), + fileName: extractFileName(response.headers.get('Content-Disposition')), + mimeType: response.headers.get('Content-Type'), + } +} \ No newline at end of file diff --git a/frontend/src/services/asset.ts b/frontend/src/services/asset.ts new file mode 100644 index 0000000..5fba2ab --- /dev/null +++ b/frontend/src/services/asset.ts @@ -0,0 +1,45 @@ +import { request, requestBlob } from './api' +import type { DownloadedAssetFile, ProductAsset, ProductAssetRole } from '../types/asset' + +export function getProductAssetsApi(productId: string | number, token: string) { + return request(`/api/products/${productId}/assets`, undefined, token) +} + +export function uploadProductAssetApi( + productId: string | number, + payload: { fileRole: ProductAssetRole; file: File; isPrimary?: boolean }, + token: string, +) { + const formData = new FormData() + formData.set('fileRole', payload.fileRole) + formData.set('isPrimary', payload.isPrimary ? 'true' : 'false') + formData.set('file', payload.file) + + return request(`/api/products/${productId}/assets`, { + method: 'POST', + body: formData, + }, token) +} + +export function downloadProductAssetApi(productId: string | number, assetId: string | number, token: string): Promise { + return requestBlob(`/api/products/${productId}/assets/${assetId}/download`, undefined, token) +} + +export function setPrimaryProductAssetApi(productId: string | number, assetId: string | number, token: string) { + return request(`/api/products/${productId}/assets/${assetId}/primary`, { + method: 'PUT', + }, token) +} + +export function reorderProductImageAssetsApi(productId: string | number, assetIds: number[], token: string) { + return request(`/api/products/${productId}/assets/image-order`, { + method: 'PUT', + body: JSON.stringify({ assetIds }), + }, token) +} + +export function deleteProductAssetApi(productId: string | number, assetId: string | number, token: string) { + return request<{ message: string }>(`/api/products/${productId}/assets/${assetId}`, { + method: 'DELETE', + }, token) +} \ No newline at end of file diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts new file mode 100644 index 0000000..83eef2d --- /dev/null +++ b/frontend/src/services/auth.ts @@ -0,0 +1,13 @@ +import { request } from './api' +import type { CurrentUser, LoginResponsePayload } from '../types/auth' + +export function loginApi(payload: { username: string; password: string }) { + return request('/api/auth/login', { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export function getCurrentUserApi(token: string) { + return request('/api/auth/me', undefined, token) +} \ No newline at end of file diff --git a/frontend/src/services/catalog.ts b/frontend/src/services/catalog.ts new file mode 100644 index 0000000..b16b357 --- /dev/null +++ b/frontend/src/services/catalog.ts @@ -0,0 +1,65 @@ +import { request } from './api' +import type { + CreateProductPayload, + CategoryTreeNode, + ProductDetail, + ProductStatusCode, + ProductStatusTagType, + ProductSummary, + UpdateProductPayload, +} from '../types/catalog' + +export const PRODUCT_STATUS_OPTIONS: Array<{ label: string; value: ProductStatusCode }> = [ + { label: '可售', value: 'AVAILABLE' }, + { label: '库存少', value: 'LOW_STOCK' }, + { label: '库存多', value: 'HIGH_STOCK' }, + { label: '停产', value: 'DISCONTINUED' }, +] + +const PRODUCT_STATUS_LABELS: Record = { + AVAILABLE: '可售', + LOW_STOCK: '库存少', + HIGH_STOCK: '库存多', + DISCONTINUED: '停产', +} + +const PRODUCT_STATUS_TYPES: Record = { + AVAILABLE: 'success', + LOW_STOCK: 'warning', + HIGH_STOCK: 'info', + DISCONTINUED: 'danger', +} + +export function getProductStatusLabel(status: ProductStatusCode) { + return PRODUCT_STATUS_LABELS[status] +} + +export function getProductStatusType(status: ProductStatusCode) { + return PRODUCT_STATUS_TYPES[status] +} + +export function getCategoryTreeApi(token: string) { + return request('/api/categories/tree', undefined, token) +} + +export function getCategoryProductsApi(categoryId: string | number, token: string) { + return request(`/api/categories/${categoryId}/products`, undefined, token) +} + +export function getProductDetailApi(productId: string | number, token: string) { + return request(`/api/products/${productId}`, undefined, token) +} + +export function createProductApi(payload: CreateProductPayload, token: string) { + return request('/api/products', { + method: 'POST', + body: JSON.stringify(payload), + }, token) +} + +export function updateProductApi(productId: string | number, payload: UpdateProductPayload, token: string) { + return request(`/api/products/${productId}`, { + method: 'PUT', + body: JSON.stringify(payload), + }, token) +} \ No newline at end of file diff --git a/frontend/src/services/mock-data.ts b/frontend/src/services/mock-data.ts new file mode 100644 index 0000000..fdc0d6d --- /dev/null +++ b/frontend/src/services/mock-data.ts @@ -0,0 +1,142 @@ +import type { UserListItem } from '../types/auth' + +export const productNodes = [ + { + id: '1', + name: '1号普通壶', + children: [ + { id: '101', name: '花纹A', productId: '101' }, + { id: '102', name: '花纹B', productId: '102' }, + { id: '103', name: '花纹C', productId: '103' }, + ], + }, + { + id: '2', + name: '2号锤纹壶', + children: [ + { id: '201', name: '锤纹青古', productId: '201' }, + { id: '202', name: '锤纹亮铜', productId: '202' }, + ], + }, +] + +export const productCards = [ + { + id: '101', + categoryId: '1', + model: '1号普通壶', + name: '花纹A', + sku: 'TH-001-A', + price: 198, + stock: 82, + status: '可售', + statusType: 'success', + remark: '常规热销款式,适合批发出货。', + }, + { + id: '102', + categoryId: '1', + model: '1号普通壶', + name: '花纹B', + sku: 'TH-001-B', + price: 228, + stock: 9, + status: '库存少', + statusType: 'warning', + remark: '库存较少,建议控制订单数量。', + }, + { + id: '103', + categoryId: '1', + model: '1号普通壶', + name: '花纹C', + sku: 'TH-001-C', + price: 268, + stock: 145, + status: '库存多', + statusType: 'info', + remark: '库存充足,适合大单。', + }, + { + id: '201', + categoryId: '2', + model: '2号锤纹壶', + name: '锤纹青古', + sku: 'TH-002-A', + price: 318, + stock: 36, + status: '可售', + statusType: 'success', + remark: '主打纹理工艺,适合展示。', + }, +] + +export const orderList = [ + { + id: 'TH20260411-008', + status: 'PENDING', + createdAt: '2026-04-11 09:30', + totalQuantity: 36, + totalAmount: 7820, + expressNo: '', + items: [ + { name: '花纹A', sku: 'TH-001-A', price: 198, quantity: 10, amount: 1980 }, + { name: '花纹B', sku: 'TH-001-B', price: 228, quantity: 8, amount: 1824 }, + { name: '花纹C', sku: 'TH-001-C', price: 268, quantity: 6, amount: 1608 }, + ], + }, + { + id: 'TH20260410-005', + status: 'COMPLETED', + createdAt: '2026-04-10 15:10', + totalQuantity: 22, + totalAmount: 5280, + expressNo: 'SF847266012210', + items: [ + { name: '锤纹青古', sku: 'TH-002-A', price: 318, quantity: 12, amount: 3816 }, + { name: '花纹A', sku: 'TH-001-A', price: 198, quantity: 6, amount: 1188 }, + ], + }, +] + +export const statisticsRows = [ + { name: '花纹A', quantity: 58, price: 198, subtotal: 11484 }, + { name: '花纹B', quantity: 22, price: 228, subtotal: 5016 }, + { name: '锤纹青古', quantity: 31, price: 318, subtotal: 9858 }, +] + +export const demoUsers: UserListItem[] = [ + { + id: 1, + username: 'admin', + displayName: '系统管理员', + userType: 'ADMIN', + status: 'ENABLED', + permissions: [ + 'PRODUCT_VIEW', + 'PRODUCT_EDIT', + 'ASSET_UPLOAD', + 'ORDER_VIEW', + 'ORDER_PROCESS', + 'ORDER_COMPLETE', + 'STATISTICS_VIEW', + 'USER_MANAGE', + ], + }, + { + id: 2, + username: 'customer', + displayName: '客户演示用户', + userType: 'NORMAL', + status: 'ENABLED', + permissions: ['PRODUCT_VIEW'], + }, + { + id: 3, + username: 'packer', + displayName: '打包工演示用户', + userType: 'NORMAL', + status: 'ENABLED', + permissions: ['ORDER_VIEW', 'ORDER_PROCESS', 'ORDER_COMPLETE'], + }, +] \ No newline at end of file diff --git a/frontend/src/services/order.ts b/frontend/src/services/order.ts new file mode 100644 index 0000000..82ac832 --- /dev/null +++ b/frontend/src/services/order.ts @@ -0,0 +1,41 @@ +import { request, requestBlob } from './api' +import type { OrderDetail, OrderMutationPayload, OrderSummary } from '../types/order' + +export function getOrdersApi(token: string) { + return request('/api/orders', undefined, token) +} + +export function createOrderApi(payload: OrderMutationPayload, token: string) { + return request('/api/orders', { + method: 'POST', + body: JSON.stringify(payload), + }, token) +} + +export function getOrderDetailApi(orderNo: string, token: string) { + return request(`/api/orders/${orderNo}`, undefined, token) +} + +export function updateOrderApi(orderNo: string, payload: OrderMutationPayload, token: string) { + return request(`/api/orders/${orderNo}`, { + method: 'PUT', + body: JSON.stringify(payload), + }, token) +} + +export function deleteOrderApi(orderNo: string, token: string) { + return request<{ message: string }>(`/api/orders/${orderNo}`, { + method: 'DELETE', + }, token) +} + +export function completeOrderApi(orderNo: string, expressNo: string, token: string) { + return request(`/api/orders/${orderNo}/complete`, { + method: 'POST', + body: JSON.stringify({ expressNo }), + }, token) +} + +export function downloadOrderLabelsApi(orderNo: string, token: string) { + return requestBlob(`/api/orders/${orderNo}/labels/download`, undefined, token) +} \ No newline at end of file diff --git a/frontend/src/services/statistics.ts b/frontend/src/services/statistics.ts new file mode 100644 index 0000000..7a5d4d9 --- /dev/null +++ b/frontend/src/services/statistics.ts @@ -0,0 +1,26 @@ +import { request } from './api' +import type { StatisticsSummary } from '../types/statistics' + +function withQuery(path: string, query?: Record) { + if (!query) { + return path + } + + const params = new URLSearchParams() + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined) { + params.set(key, String(value)) + } + }) + + const queryString = params.toString() + return queryString ? `${path}?${queryString}` : path +} + +export function getMonthlyStatisticsApi(token: string, query?: { year?: number; month?: number }) { + return request(withQuery('/api/statistics/monthly', query), undefined, token) +} + +export function getYearlyStatisticsApi(token: string, query?: { year?: number }) { + return request(withQuery('/api/statistics/yearly', query), undefined, token) +} \ No newline at end of file diff --git a/frontend/src/services/user.ts b/frontend/src/services/user.ts new file mode 100644 index 0000000..7d1a50c --- /dev/null +++ b/frontend/src/services/user.ts @@ -0,0 +1,32 @@ +import { request } from './api' +import type { + CreateUserPayload, + UpdateUserPermissionsPayload, + UpdateUserStatusPayload, + UserListItem, +} from '../types/auth' + +export function getUsersApi(token: string) { + return request('/api/users', undefined, token) +} + +export function createUserApi(payload: CreateUserPayload, token: string) { + return request('/api/users', { + method: 'POST', + body: JSON.stringify(payload), + }, token) +} + +export function updateUserPermissionsApi(userId: number, payload: UpdateUserPermissionsPayload, token: string) { + return request<{ message: string }>(`/api/users/${userId}/permissions`, { + method: 'PUT', + body: JSON.stringify(payload), + }, token) +} + +export function updateUserStatusApi(userId: number, payload: UpdateUserStatusPayload, token: string) { + return request<{ message: string }>(`/api/users/${userId}/status`, { + method: 'PUT', + body: JSON.stringify(payload), + }, token) +} \ No newline at end of file diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..35a694c --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,95 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { getCurrentUserApi, loginApi } from '../services/auth' +import { hasAnyPermission, hasPermission } from '../services/access' +import type { CurrentUser } from '../types/auth' + +const TOKEN_KEY = 'teapot-system-token' +const USER_KEY = 'teapot-system-user' + +function readUserFromStorage() { + const raw = window.localStorage.getItem(USER_KEY) + if (!raw) { + return null + } + + try { + return JSON.parse(raw) as CurrentUser + } catch { + return null + } +} + +export const useAuthStore = defineStore('auth', () => { + const token = ref(window.localStorage.getItem(TOKEN_KEY) ?? '') + const user = ref(readUserFromStorage()) + const initialized = ref(false) + + const isAuthenticated = computed(() => Boolean(token.value && user.value)) + + function persistSession() { + if (token.value) { + window.localStorage.setItem(TOKEN_KEY, token.value) + } else { + window.localStorage.removeItem(TOKEN_KEY) + } + + if (user.value) { + window.localStorage.setItem(USER_KEY, JSON.stringify(user.value)) + } else { + window.localStorage.removeItem(USER_KEY) + } + } + + async function login(username: string, password: string) { + const payload = await loginApi({ username, password }) + token.value = payload.token + user.value = payload.user + initialized.value = true + persistSession() + } + + async function bootstrap() { + if (!token.value) { + initialized.value = true + return + } + + try { + user.value = await getCurrentUserApi(token.value) + } catch { + token.value = '' + user.value = null + persistSession() + } finally { + initialized.value = true + } + } + + function logout() { + token.value = '' + user.value = null + initialized.value = true + persistSession() + } + + function can(permission: string) { + return hasPermission(user.value, permission) + } + + function canAny(permissions: string[]) { + return hasAnyPermission(user.value, permissions) + } + + return { + token, + user, + initialized, + isAuthenticated, + login, + bootstrap, + logout, + can, + canAny, + } +}) \ No newline at end of file diff --git a/frontend/src/stores/catalog.ts b/frontend/src/stores/catalog.ts new file mode 100644 index 0000000..febd937 --- /dev/null +++ b/frontend/src/stores/catalog.ts @@ -0,0 +1,50 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { getCategoryTreeApi } from '../services/catalog' +import type { CategoryTreeNode } from '../types/catalog' + +export const useCatalogStore = defineStore('catalog', () => { + const categoryTree = ref([]) + const loading = ref(false) + const loaded = ref(false) + const productDataVersion = ref(0) + let currentRequest: Promise | null = null + + const firstCategoryId = computed(() => String(categoryTree.value[0]?.id ?? '1')) + + async function loadCategoryTree(token: string, force = false) { + if (loaded.value && !force) { + return categoryTree.value + } + + if (currentRequest && !force) { + return currentRequest + } + + loading.value = true + currentRequest = getCategoryTreeApi(token) + + try { + categoryTree.value = await currentRequest + loaded.value = true + return categoryTree.value + } finally { + loading.value = false + currentRequest = null + } + } + + function markProductDataChanged() { + productDataVersion.value += 1 + } + + return { + categoryTree, + loading, + loaded, + productDataVersion, + firstCategoryId, + loadCategoryTree, + markProductDataChanged, + } +}) \ No newline at end of file diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts new file mode 100644 index 0000000..e7d1ffb --- /dev/null +++ b/frontend/src/stores/index.ts @@ -0,0 +1,3 @@ +import { createPinia } from 'pinia' + +export const pinia = createPinia() \ No newline at end of file diff --git a/frontend/src/stores/orders.ts b/frontend/src/stores/orders.ts new file mode 100644 index 0000000..6af32df --- /dev/null +++ b/frontend/src/stores/orders.ts @@ -0,0 +1,81 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { getOrdersApi } from '../services/order' +import type { OrderDetail, OrderSummary } from '../types/order' + +function toOrderSummary(detail: OrderDetail): OrderSummary { + return { + id: detail.id, + status: detail.status, + createdAt: detail.createdAt, + totalQuantity: detail.totalQuantity, + totalAmount: detail.totalAmount, + expressNo: detail.expressNo, + labelReady: detail.labelReady, + missingLabelProducts: detail.missingLabelProducts, + } +} + +export const useOrdersStore = defineStore('orders', () => { + const orders = ref([]) + const loading = ref(false) + const loaded = ref(false) + let currentRequest: Promise | null = null + + const pendingCount = computed(() => orders.value.filter((order) => order.status === 'PENDING').length) + const completedCount = computed(() => orders.value.filter((order) => order.status === 'COMPLETED').length) + const monthAmount = computed(() => orders.value.reduce((sum, item) => sum + item.totalAmount, 0)) + const labelPendingCount = computed(() => orders.value.filter((order) => order.status === 'PENDING' && !order.expressNo).length) + const labelMissingCount = computed(() => orders.value.filter((order) => !order.labelReady).length) + + async function loadOrders(token: string, force = false) { + if (loaded.value && !force) { + return orders.value + } + + if (currentRequest && !force) { + return currentRequest + } + + loading.value = true + currentRequest = getOrdersApi(token) + + try { + orders.value = await currentRequest + loaded.value = true + return orders.value + } finally { + loading.value = false + currentRequest = null + } + } + + function applyOrderDetail(detail: OrderDetail) { + const summary = toOrderSummary(detail) + const targetIndex = orders.value.findIndex((order) => order.id === summary.id) + + if (targetIndex >= 0) { + orders.value[targetIndex] = summary + } else { + orders.value = [summary, ...orders.value] + } + } + + function removeOrder(orderId: string) { + orders.value = orders.value.filter((order) => order.id !== orderId) + } + + return { + orders, + loading, + loaded, + pendingCount, + completedCount, + monthAmount, + labelPendingCount, + labelMissingCount, + loadOrders, + applyOrderDetail, + removeOrder, + } +}) \ No newline at end of file diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..8da6266 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,1447 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --border: #e5e4e7; + --code-bg: #f4f3ec; + --accent: #aa3bff; + --accent-bg: rgba(170, 59, 255, 0.1); + --accent-border: rgba(170, 59, 255, 0.5); + --social-bg: rgba(244, 243, 236, 0.5); + --shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + :root { + font-family: 'Source Han Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; + color: #2f2923; + background: + radial-gradient(circle at top left, rgba(166, 101, 54, 0.18), transparent 28%), + radial-gradient(circle at bottom right, rgba(79, 109, 122, 0.14), transparent 24%), + linear-gradient(160deg, #f7f1e9 0%, #efe6dc 100%); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + --bg: #f4efe8; + --panel: rgba(251, 248, 243, 0.92); + --panel-strong: #fbf8f3; + --text: #2f2923; + --muted: #5b544d; + --brand: #a66536; + --brand-deep: #8f552c; + --line: #d9cabb; + --side: #24353a; + --success: #56735d; + --warning: #c68a35; + --danger: #b5533d; + --info: #4f6d7a; + --shadow: 0 22px 54px rgba(58, 39, 23, 0.12); + --soft-shadow: 0 10px 24px rgba(36, 53, 58, 0.08); + } + + * { + box-sizing: border-box; + } + + html, + body, + #app { + min-height: 100vh; + } + + body { + margin: 0; + min-width: 320px; + } + + a { + color: inherit; + text-decoration: none; + } + + button { + font: inherit; + } + + .app-shell { + display: block; + padding-left: 280px; + min-height: 100vh; + } + + .app-sidebar { + padding: 24px 20px; + background: linear-gradient(180deg, #24353a, #1f2d31); + color: rgba(247, 241, 233, 0.92); + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 280px; + overflow-y: auto; + z-index: 1000; + } + + .app-sidebar::-webkit-scrollbar { + width: 6px; + } + .app-sidebar::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 999px; + } + .app-sidebar::-webkit-scrollbar-track { + background: transparent; + } + + .app-sidebar__brand { + display: flex; + gap: 14px; + align-items: center; + padding: 10px 8px 20px; + margin-bottom: 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + } + + .brand-mark { + width: 42px; + height: 42px; + display: block; + flex-shrink: 0; + border-radius: 14px; + object-fit: cover; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 10px 22px rgba(166, 101, 54, 0.24); + } + + .app-sidebar__brand strong { + display: block; + font-size: 18px; + } + + .app-sidebar__brand span { + font-size: 12px; + color: rgba(247, 241, 233, 0.64); + } + + .app-sidebar__section { + margin-bottom: 22px; + } + + .app-sidebar__label { + margin: 0 0 10px; + padding: 0 8px; + font-size: 12px; + letter-spacing: 0.14em; + color: rgba(255, 231, 213, 0.48); + } + + .app-sidebar__state { + margin: 0; + padding: 0 8px; + font-size: 12px; + color: rgba(249, 242, 234, 0.62); + } + + .tree-group { + margin-bottom: 10px; + } + + .tree-node, + .tree-child, + .nav-item { + display: block; + border-radius: 14px; + } + + .tree-node, + .nav-item { + padding: 12px 14px; + background: rgba(255, 255, 255, 0.04); + margin-bottom: 8px; + } + + .tree-node.is-active, + .nav-item.is-active { + background: linear-gradient(90deg, rgba(166, 101, 54, 0.26), rgba(166, 101, 54, 0.08)); + box-shadow: inset 3px 0 0 rgba(255, 209, 169, 0.82); + } + + .tree-children { + display: grid; + gap: 8px; + margin: 0 0 8px 12px; + } + + .tree-child { + padding: 10px 12px; + font-size: 13px; + color: rgba(249, 242, 234, 0.76); + background: rgba(255, 255, 255, 0.03); + } + + .tree-child.is-current { + background: rgba(255, 237, 219, 0.12); + color: #fff4ea; + } + + .app-main { + display: flex; + flex-direction: column; + } + + .app-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 22px 28px; + border-bottom: 1px solid rgba(217, 202, 187, 0.84); + background: rgba(255, 252, 246, 0.82); + backdrop-filter: blur(8px); + } + + .app-topbar__title { + font-family: 'Source Han Serif SC', 'STSong', serif; + font-size: 28px; + line-height: 1.2; + } + + .app-topbar__subtitle { + margin-top: 8px; + color: var(--muted); + font-size: 13px; + } + + .app-topbar__actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + justify-content: flex-end; + } + + .topbar-search, + .topbar-chip, + .topbar-link, + .toolbar-cell, + .readonly-banner { + border-radius: 999px; + } + + .topbar-search, + .topbar-chip, + .toolbar-cell { + padding: 11px 15px; + border: 1px solid rgba(217, 202, 187, 0.9); + background: rgba(255, 253, 250, 0.96); + font-size: 13px; + color: #80756a; + } + + .topbar-search { + min-width: 280px; + } + + .topbar-chip--brand { + background: rgba(166, 101, 54, 0.14); + color: var(--brand-deep); + font-weight: 700; + } + + .topbar-link { + padding: 11px 16px; + border: 1px solid rgba(166, 101, 54, 0.24); + background: rgba(166, 101, 54, 0.16); + color: #582f16; + font-weight: 700; + cursor: pointer; + } + + .topbar-link:hover { + background: rgba(166, 101, 54, 0.24); + border-color: rgba(120, 78, 46, 0.44); + } + + .app-content { + padding: 24px; + } + + .page-stack { + display: grid; + gap: 20px; + } + + .page-hero-card, + .surface-panel, + .stats-card, + .product-panel, + .login-card { + border-radius: 24px; + border: 1px solid rgba(217, 202, 187, 0.9); + background: var(--panel); + box-shadow: var(--soft-shadow); + } + + .page-hero-card, + .surface-panel { + padding: 20px; + } + + .page-hero-card { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: flex-start; + } + + .page-hero-card__eyebrow, + .login-kicker, + .login-card__kicker { + margin: 0 0 10px; + font-size: 12px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--brand); + font-weight: 800; + } + + .page-hero-card h2, + .surface-panel h3, + .login-card h2, + .login-hero h1 { + margin: 0; + font-family: 'Source Han Serif SC', 'STSong', serif; + } + + .page-hero-card p, + .surface-panel p, + .stats-card p, + .login-hero p, + .login-card span, + .product-panel__remark { + color: var(--muted); + line-height: 1.7; + } + + .page-hero-card__actions, + .surface-panel__head, + .detail-actions-row, + .side-actions, + .table-actions, + .page-hero-card__actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + } + + .surface-panel__head { + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + } + + .toolbar-panel { + display: grid; + gap: 14px; + } + + .toolbar-panel--five { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + .toolbar-panel--six { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + + .toolbar-input { + width: 100%; + } + + .toolbar-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + .toolbar-actions .el-button { + flex: 1 1 120px; + } + + .toolbar-switch { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 44px; + padding: 11px 15px; + border: 1px solid rgba(217, 202, 187, 0.9); + border-radius: 18px; + background: rgba(255, 253, 250, 0.96); + } + + .toolbar-switch span { + font-size: 13px; + color: #80756a; + } + + .stats-grid { + display: grid; + gap: 16px; + } + + .stats-grid--three { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .stats-grid--four { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .stats-card { + padding: 20px; + } + + .stats-card span, + .metric-grid__item span, + .detail-field span { + font-size: 12px; + color: #877c71; + } + + .stats-card strong, + .metric-grid__item strong, + .detail-field strong { + display: block; + margin-top: 8px; + font-size: 28px; + line-height: 1.2; + } + + .card-grid--products { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 320px)); + justify-content: start; + align-items: start; + gap: 22px; + } + + .product-panel { + width: min(100%, 320px); + justify-self: start; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .product-detail__hero { + display: grid; + gap: 10px; + } + + .product-detail__back { + width: 40px; + height: 40px; + margin: 0 0 2px; + padding: 0; + } + + .product-panel__image, + .preview-stage { + position: relative; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0) 42%), + linear-gradient(180deg, #ead8c6 0%, #d3baa2 100%); + } + + .product-panel__image { + aspect-ratio: 1 / 1; + height: auto; + padding: 18px; + cursor: pointer; + } + + .preview-stage { + height: clamp(320px, 48vh, 520px); + min-height: 360px; + padding: 20px; + } + + .teapot-shape { + position: absolute; + inset: 26px 72px 30px; + border-radius: 48% 48% 42% 42% / 44% 44% 54% 54%; + background: linear-gradient(180deg, rgba(112, 69, 38, 0.95), rgba(155, 101, 56, 0.88)); + box-shadow: + inset -12px -18px 30px rgba(255, 214, 171, 0.2), + inset 10px 18px 24px rgba(34, 18, 10, 0.24), + 0 18px 30px rgba(74, 45, 21, 0.18); + } + + .teapot-shape::after { + content: ''; + position: absolute; + width: 56px; + height: 56px; + right: -16px; + top: 20px; + border-radius: 50%; + border: 8px solid rgba(111, 66, 35, 0.9); + box-shadow: 0 0 0 4px rgba(226, 196, 162, 0.65); + } + + .teapot-shape--large { + inset: 30px 110px 36px; + } + + .product-panel__cover, + .preview-stage__image { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; + display: block; + } + + .product-panel__body { + display: grid; + gap: 14px; + padding: 16px 14px 14px; + } + + .product-panel__title { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 10px; + margin: 0; + } + + .product-panel__title > div { + min-width: 0; + } + + .product-panel__title h3 { + margin: 0; + font-size: 15px; + line-height: 1.5; + word-break: break-word; + } + + .product-panel__title p { + margin: 4px 0 0; + font-size: 12px; + color: #8d8277; + word-break: break-all; + } + + .metric-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin: 0; + } + + .metric-grid__item, + .detail-field { + padding: 14px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.62); + border: 1px solid rgba(217, 202, 187, 0.84); + } + + .metric-grid__item strong, + .detail-field strong { + font-size: 18px; + } + + .metric-grid__item { + padding: 12px; + } + + .metric-grid__item strong { + font-size: 20px; + } + + .product-panel__remark { + margin: 0; + min-height: 44px; + font-size: 13px; + } + + .detail-field--full { + grid-column: 1 / -1; + } + + .detail-field__control, + .detail-field .el-input, + .detail-field .el-select, + .detail-field .el-input-number, + .side-actions .el-input { + width: 100%; + } + + .detail-field .el-input, + .detail-field .el-select, + .detail-field .el-input-number, + .detail-field textarea { + margin-top: 10px; + } + + .detail-field__control--stepper.el-input-number { + overflow: hidden; + --el-input-number-controls-width: 22px; + } + + .detail-field__control--stepper.el-input-number .el-input__wrapper { + padding-left: 36px; + padding-right: 36px; + } + + .detail-field__control--stepper.el-input-number .el-input-number__decrease, + .detail-field__control--stepper.el-input-number .el-input-number__increase { + top: 11px; + bottom: 1px; + margin: 0; + transform: none; + width: 34px; + height: 30px; + font-size: 14px; + line-height: 1; + color: #6f5844; + background: rgba(255, 252, 246, 0.6); + border: none; + border-radius: 0; + box-shadow: none; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + } + + .detail-field__control--stepper.el-input-number .el-input-number__decrease { + left: 1px; + border-right: 1px solid rgba(217, 202, 187, 0.6); + border-radius: 13px 0 0 13px; + } + + .detail-field__control--stepper.el-input-number .el-input-number__increase { + right: 1px; + border-left: 1px solid rgba(217, 202, 187, 0.6); + border-radius: 0 13px 13px 0; + } + + .detail-field__control--stepper.el-input-number .el-input-number__decrease:hover, + .detail-field__control--stepper.el-input-number .el-input-number__increase:hover { + background: rgba(166, 101, 54, 0.12); + color: #a66536; + } + + .detail-field__control--stepper.el-input-number .el-input-number__decrease [class*='el-icon'], + .detail-field__control--stepper.el-input-number .el-input-number__increase [class*='el-icon'] { + transform: scale(0.95); + } + + .product-panel__actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .product-panel__actions .el-button { + width: 100%; + margin: 0; + } + + .list-pagination { + display: flex; + justify-content: flex-end; + margin-top: 18px; + } + + .detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + } + + .detail-grid--order { + grid-template-columns: 1.2fr 0.8fr; + } + + .detail-grid--single { + grid-template-columns: 1fr; + } + + .detail-info-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + } + + .detail-info-grid--three { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 18px; + } + + .readonly-banner { + padding: 12px 16px; + margin-bottom: 14px; + background: rgba(79, 109, 122, 0.08); + color: var(--info); + border: 1px dashed rgba(79, 109, 122, 0.32); + } + + .preview-thumbs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 14px; + } + + .preview-thumbs__item { + height: 72px; + border-radius: 14px; + background: linear-gradient(180deg, #ead7c5, #dbc0a4); + border: 1px solid rgba(217, 202, 187, 0.9); + } + + .preview-thumbs__item--asset { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + position: relative; + padding: 10px 12px; + cursor: pointer; + text-align: left; + color: #5d4d3f; + user-select: none; + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease; + } + + .preview-thumbs__item--asset.is-draggable { + cursor: grab; + } + + .preview-thumbs__item--asset.is-dragging { + opacity: 0.46; + transform: scale(0.98); + cursor: grabbing; + } + + .preview-thumbs__item--asset span, + .preview-thumbs__item--asset small { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .preview-thumbs__item--asset small { + margin-top: 6px; + color: #8d8277; + } + + .preview-thumbs__item--asset.is-active { + border-color: rgba(166, 101, 54, 0.9); + box-shadow: inset 0 0 0 1px rgba(166, 101, 54, 0.24); + } + + .preview-thumbs__item--asset.is-drop-target { + border-color: rgba(123, 71, 35, 0.82); + box-shadow: inset 0 0 0 1px rgba(123, 71, 35, 0.24); + } + + .preview-thumbs__item--asset.is-drop-before::before, + .preview-thumbs__item--asset.is-drop-after::after { + content: ''; + position: absolute; + top: 8px; + bottom: 8px; + width: 4px; + border-radius: 999px; + background: #7b4723; + box-shadow: 0 0 0 2px rgba(255, 250, 244, 0.9); + } + + .preview-thumbs__item--asset.is-drop-before::before { + left: 4px; + } + + .preview-thumbs__item--asset.is-drop-after::after { + right: 4px; + } + + .preview-thumbs__empty { + grid-column: 1 / -1; + padding: 18px; + border-radius: 14px; + border: 1px dashed rgba(166, 101, 54, 0.28); + color: #877c71; + background: rgba(255, 250, 244, 0.72); + } + + .preview-thumbs__hint { + grid-column: 1 / -1; + margin: 2px 2px 0; + font-size: 12px; + color: #877c71; + } + + .customer-gallery { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + } + + .customer-gallery__item { + padding: 18px; + border-radius: 22px; + border: 1px solid rgba(217, 202, 187, 0.9); + background: rgba(255, 255, 255, 0.74); + min-height: 320px; + display: flex; + align-items: center; + justify-content: center; + } + + .customer-gallery__image { + width: 100%; + height: 100%; + max-height: 520px; + object-fit: contain; + display: block; + } + + .customer-gallery__loading { + font-size: 13px; + color: #877c71; + } + + .asset-file-list { + margin-top: 16px; + display: grid; + gap: 10px; + } + + .asset-image-actions { + margin-top: 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + flex-wrap: wrap; + } + + .asset-image-actions__meta strong, + .asset-file-list__content span { + display: block; + color: #5d4d3f; + } + + .asset-image-actions__meta span { + margin-top: 6px; + color: #877c71; + font-size: 12px; + } + + .asset-image-actions__buttons, + .asset-file-list__actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + .asset-file-list__head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + } + + .asset-file-list__summary { + display: grid; + gap: 4px; + } + + .asset-file-list__head h4 { + margin: 0; + font-size: 15px; + } + + .asset-file-list__head span, + .asset-file-list__item small { + color: #8d8277; + font-size: 12px; + } + + .asset-file-list__item { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(217, 202, 187, 0.9); + background: rgba(255, 255, 255, 0.7); + color: #5d4d3f; + } + + .asset-file-list__content { + min-width: 0; + } + + .asset-file-list__content span, + .asset-file-list__content small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .asset-file-list__empty { + padding: 14px; + border-radius: 14px; + border: 1px dashed rgba(217, 202, 187, 0.9); + color: #877c71; + background: rgba(255, 252, 246, 0.72); + } + + .hidden-file-input { + display: none; + } + + .user-table-actions { + justify-content: flex-start; + } + + .user-dialog-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + } + + .permission-editor { + margin-top: 18px; + display: grid; + gap: 14px; + } + + .permission-editor__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + } + + .permission-editor__head h4 { + margin: 0; + font-size: 16px; + } + + .permission-editor__head p, + .permission-editor__head span { + margin: 6px 0 0; + color: #877c71; + font-size: 13px; + } + + .permission-group-stack { + display: grid; + gap: 12px; + } + + .permission-group-panel { + padding: 14px; + border-radius: 16px; + border: 1px solid rgba(217, 202, 187, 0.9); + background: rgba(255, 252, 246, 0.72); + } + + .permission-group-panel strong { + display: block; + margin-bottom: 10px; + font-size: 14px; + } + + .permission-option-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .dialog-footer-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + } + + .order-editor-panel { + padding: 18px; + } + + .order-editor-list { + display: grid; + gap: 12px; + } + + .order-editor-line { + padding: 14px; + border-radius: 16px; + border: 1px solid rgba(217, 202, 187, 0.9); + background: rgba(255, 252, 246, 0.72); + } + + .order-editor-line__row, + .order-editor-line__meta { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + } + + .order-editor-line__product { + flex: 1 1 420px; + } + + .order-editor-line__meta { + margin-top: 10px; + color: #877c71; + font-size: 13px; + justify-content: space-between; + } + + .order-editor-line__meta strong { + color: #5d4d3f; + } + + .order-editor-line__warning { + color: #b85c38; + font-weight: 600; + } + + .side-actions { + flex-direction: column; + align-items: stretch; + } + + .permission-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .login-page { + min-height: 100vh; + display: grid; + grid-template-columns: 1.05fr 0.95fr; + } + + .login-hero { + padding: 56px; + background: + linear-gradient(135deg, rgba(36, 53, 58, 0.88), rgba(67, 45, 31, 0.8)), + linear-gradient(180deg, rgba(166, 101, 54, 0.8), rgba(36, 53, 58, 0.85)); + color: #f9f2ea; + display: flex; + flex-direction: column; + justify-content: center; + } + + .login-hero h1 { + font-size: 44px; + line-height: 1.2; + } + + .login-tags { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 26px; + } + + .login-tags span { + padding: 10px 14px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 13px; + } + + .login-card { + padding: 42px; + margin: 48px; + align-self: center; + } + + .login-card__head { + margin-bottom: 18px; + } + + .login-card__head h2 { + font-size: 30px; + margin-bottom: 10px; + } + + .login-card__form { + display: grid; + gap: 12px; + margin-bottom: 18px; + } + + .login-card__form label { + font-size: 13px; + color: var(--muted); + } + + .login-card__button { + margin-top: 6px; + } + + .table-actions { + align-items: center; + } + + .el-card, + .el-alert, + .el-table, + .el-input__wrapper, + .el-button { + border-radius: 14px; + } + + .el-button { + font-weight: 700; + letter-spacing: 0.02em; + box-shadow: 0 8px 18px rgba(58, 39, 23, 0.08); + --el-button-text-color: #24180d; + --el-button-bg-color: rgba(255, 250, 245, 0.98); + --el-button-border-color: rgba(120, 78, 46, 0.34); + --el-button-hover-text-color: #24180d; + --el-button-hover-bg-color: rgba(231, 204, 178, 0.88); + --el-button-hover-border-color: rgba(120, 78, 46, 0.7); + --el-button-active-text-color: #24180d; + --el-button-active-bg-color: rgba(220, 188, 157, 0.96); + --el-button-active-border-color: #7b4a28; + --el-button-disabled-text-color: #8f8479; + --el-button-disabled-bg-color: rgba(244, 238, 231, 0.96); + --el-button-disabled-border-color: rgba(207, 194, 180, 0.92); + } + + .el-button--primary:not(.is-plain) { + --el-button-bg-color: #7b4723; + --el-button-border-color: #7b4723; + --el-button-hover-bg-color: #623517; + --el-button-hover-border-color: #623517; + --el-button-active-bg-color: #4d2911; + --el-button-active-border-color: #4d2911; + --el-button-text-color: #fffaf4; + --el-button-hover-text-color: #fffaf4; + --el-button-active-text-color: #fffaf4; + } + + .el-button--primary.is-plain { + --el-button-text-color: #6d3e1d; + --el-button-bg-color: rgba(166, 101, 54, 0.14); + --el-button-border-color: rgba(123, 71, 35, 0.4); + --el-button-hover-text-color: #fffaf4; + --el-button-hover-bg-color: #7b4723; + --el-button-hover-border-color: #7b4723; + --el-button-active-text-color: #fffaf4; + --el-button-active-bg-color: #4d2911; + --el-button-active-border-color: #4d2911; + } + + .el-button--danger:not(.is-plain) { + --el-button-bg-color: #a4412a; + --el-button-border-color: #a4412a; + --el-button-hover-bg-color: #86321f; + --el-button-hover-border-color: #86321f; + --el-button-active-bg-color: #6f2819; + --el-button-active-border-color: #6f2819; + --el-button-text-color: #fffaf4; + --el-button-hover-text-color: #fffaf4; + --el-button-active-text-color: #fffaf4; + } + + .el-button--danger.is-plain { + --el-button-text-color: #8c3621; + --el-button-bg-color: rgba(181, 83, 61, 0.12); + --el-button-border-color: rgba(164, 65, 42, 0.4); + --el-button-hover-text-color: #fffaf4; + --el-button-hover-bg-color: #a4412a; + --el-button-hover-border-color: #a4412a; + --el-button-active-text-color: #fffaf4; + --el-button-active-bg-color: #6f2819; + --el-button-active-border-color: #6f2819; + } + + @media (max-width: 1280px) { + .card-grid--products, + .stats-grid--four, + .stats-grid--three, + .detail-grid, + .detail-grid--order, + .detail-info-grid, + .detail-info-grid--three, + .toolbar-panel--five, + .toolbar-panel--six, + .preview-thumbs, + .customer-gallery, + .user-dialog-grid, + .permission-option-grid, + .login-page { + grid-template-columns: 1fr; + } + + .asset-image-actions, + .asset-file-list__item, + .asset-file-list__actions { + flex-direction: column; + align-items: stretch; + } + + .order-editor-line__row, + .order-editor-line__meta { + flex-direction: column; + align-items: stretch; + } + + .login-card { + margin: 20px; + } + } + + @media (max-width: 960px) { + .app-shell { + padding-left: 0; + } + + .app-sidebar { + display: none; + } + + .app-topbar { + flex-direction: column; + align-items: stretch; + } + + .topbar-search { + min-width: 100%; + } + + .page-hero-card { + flex-direction: column; + } + } + padding: 4px 8px; + background: var(--code-bg); +} + +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#app { + width: 100%; + max-width: none; + margin: 0; + text-align: left; + border: 0; + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/types/asset.ts b/frontend/src/types/asset.ts new file mode 100644 index 0000000..95a715f --- /dev/null +++ b/frontend/src/types/asset.ts @@ -0,0 +1,18 @@ +export type ProductAssetRole = 'IMAGE' | 'LABEL' + +export interface ProductAsset { + id: number + fileRole: ProductAssetRole + fileName: string + mimeType: string + fileSize: number + sortNo: number + primary: boolean + createdAt: string +} + +export interface DownloadedAssetFile { + blob: Blob + fileName: string | null + mimeType: string | null +} \ No newline at end of file diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts new file mode 100644 index 0000000..0ca0359 --- /dev/null +++ b/frontend/src/types/auth.ts @@ -0,0 +1,45 @@ +export type UserType = 'ADMIN' | 'NORMAL' + +export interface ApiResponse { + success: boolean + message: string + data: T +} + +export interface CurrentUser { + id: number + username: string + displayName: string + userType: UserType + permissions: string[] +} + +export interface LoginResponsePayload { + token: string + expiresInSeconds: number + user: CurrentUser +} + +export interface UserListItem { + id: number + username: string + displayName: string + userType: UserType + status: 'ENABLED' | 'DISABLED' + permissions: string[] +} + +export interface CreateUserPayload { + username: string + displayName: string + password: string + permissions: string[] +} + +export interface UpdateUserPermissionsPayload { + permissions: string[] +} + +export interface UpdateUserStatusPayload { + status: 'ENABLED' | 'DISABLED' +} \ No newline at end of file diff --git a/frontend/src/types/catalog.ts b/frontend/src/types/catalog.ts new file mode 100644 index 0000000..6015a89 --- /dev/null +++ b/frontend/src/types/catalog.ts @@ -0,0 +1,56 @@ +export type ProductStatusCode = 'AVAILABLE' | 'LOW_STOCK' | 'HIGH_STOCK' | 'DISCONTINUED' + +export type ProductStatusTagType = 'success' | 'warning' | 'info' | 'danger' + +export interface CategoryProductNode { + id: number + name: string + productId: number +} + +export interface CategoryTreeNode { + id: number + name: string + children: CategoryProductNode[] +} + +export interface ProductSummary { + id: number + categoryId: number + model: string + name: string + sku: string + price: number + stock: number + status: string + statusType: ProductStatusTagType + coverAssetId: number | null + remark: string | null +} + +export interface ProductDetail { + id: number + categoryId: number + model: string + name: string + sku: string + price: number + stock: number + status: ProductStatusCode + statusType: ProductStatusTagType + remark: string | null +} + +export interface UpdateProductPayload { + model: string + name: string + sku: string + price: number + stock: number + status: ProductStatusCode + remark: string +} + +export interface CreateProductPayload extends UpdateProductPayload { + categoryId: number +} \ No newline at end of file diff --git a/frontend/src/types/order.ts b/frontend/src/types/order.ts new file mode 100644 index 0000000..3afc170 --- /dev/null +++ b/frontend/src/types/order.ts @@ -0,0 +1,37 @@ +export type OrderStatus = 'PENDING' | 'COMPLETED' + +export interface OrderSummary { + id: string + status: OrderStatus + createdAt: string + totalQuantity: number + totalAmount: number + expressNo: string | null + labelReady: boolean + missingLabelProducts: string[] +} + +export interface OrderItem { + productId: number + name: string + sku: string + price: number + quantity: number + amount: number +} + +export interface OrderDetail extends OrderSummary { + completedAt: string | null + remark: string | null + items: OrderItem[] +} + +export interface OrderMutationItem { + productId: number + quantity: number +} + +export interface OrderMutationPayload { + remark: string + items: OrderMutationItem[] +} \ No newline at end of file diff --git a/frontend/src/types/statistics.ts b/frontend/src/types/statistics.ts new file mode 100644 index 0000000..6ab1f9b --- /dev/null +++ b/frontend/src/types/statistics.ts @@ -0,0 +1,18 @@ +export type StatisticsMode = 'monthly' | 'yearly' + +export interface StatisticsRow { + name: string + quantity: number + price: number + subtotal: number +} + +export interface StatisticsSummary { + year: number + month: number | null + totalQuantity: number + totalAmount: number + averagePrice: number + topProductName: string | null + rows: StatisticsRow[] +} \ No newline at end of file diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue new file mode 100644 index 0000000..9ec5bf9 --- /dev/null +++ b/frontend/src/views/auth/LoginView.vue @@ -0,0 +1,64 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/order/OrderDetailView.vue b/frontend/src/views/order/OrderDetailView.vue new file mode 100644 index 0000000..f871117 --- /dev/null +++ b/frontend/src/views/order/OrderDetailView.vue @@ -0,0 +1,246 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/order/OrdersView.vue b/frontend/src/views/order/OrdersView.vue new file mode 100644 index 0000000..f66a04a --- /dev/null +++ b/frontend/src/views/order/OrdersView.vue @@ -0,0 +1,321 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/product/ProductCenterView.vue b/frontend/src/views/product/ProductCenterView.vue new file mode 100644 index 0000000..6a343fa --- /dev/null +++ b/frontend/src/views/product/ProductCenterView.vue @@ -0,0 +1,579 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/product/ProductDetailView.vue b/frontend/src/views/product/ProductDetailView.vue new file mode 100644 index 0000000..7c3345c --- /dev/null +++ b/frontend/src/views/product/ProductDetailView.vue @@ -0,0 +1,781 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/statistics/StatisticsView.vue b/frontend/src/views/statistics/StatisticsView.vue new file mode 100644 index 0000000..4110d05 --- /dev/null +++ b/frontend/src/views/statistics/StatisticsView.vue @@ -0,0 +1,112 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/system/UsersView.vue b/frontend/src/views/system/UsersView.vue new file mode 100644 index 0000000..465955e --- /dev/null +++ b/frontend/src/views/system/UsersView.vue @@ -0,0 +1,357 @@ + + + \ No newline at end of file diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..5c750c5 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..7342211 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + Components({ + dts: true, + resolvers: [ + ElementPlusResolver({ + importStyle: 'css', + directives: true, + }), + ], + }), + ], + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (!id.includes('node_modules')) { + return undefined + } + + if (id.includes('element-plus')) { + return 'element-plus' + } + + if (id.includes('vue-router') || id.includes('pinia') || id.includes('/vue/')) { + return 'vue-vendor' + } + + return 'vendor' + }, + }, + }, + }, +}) diff --git a/sql/V001__init.sql b/sql/V001__init.sql new file mode 100644 index 0000000..e8d9302 --- /dev/null +++ b/sql/V001__init.sql @@ -0,0 +1,102 @@ +CREATE TABLE IF NOT EXISTS user_account ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + user_type TEXT NOT NULL, + status TEXT NOT NULL, + last_login_at TEXT, + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS user_permission ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + permission_code TEXT NOT NULL, + permission_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, permission_code) +); + +CREATE TABLE IF NOT EXISTS category ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_id INTEGER, + name TEXT NOT NULL, + sort_no INTEGER NOT NULL DEFAULT 0, + requires_detail_page INTEGER NOT NULL DEFAULT 1, + category_type TEXT NOT NULL DEFAULT 'PRODUCT', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS product_item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL, + sku TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + model_name TEXT, + status TEXT NOT NULL DEFAULT 'AVAILABLE', + wholesale_price NUMERIC NOT NULL DEFAULT 0, + stock_quantity INTEGER NOT NULL DEFAULT 0, + cover_asset_id INTEGER, + remark TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS file_asset ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + business_type TEXT NOT NULL, + business_id INTEGER NOT NULL, + file_role TEXT NOT NULL, + file_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + file_size INTEGER NOT NULL, + content_blob BLOB, + preview_blob BLOB, + sort_no INTEGER NOT NULL DEFAULT 0, + is_primary INTEGER NOT NULL DEFAULT 0, + sha256 TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS order_record ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_no TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'PENDING', + total_quantity INTEGER NOT NULL DEFAULT 0, + total_amount NUMERIC NOT NULL DEFAULT 0, + express_no TEXT, + remark TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TEXT +); + +CREATE TABLE IF NOT EXISTS order_item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + product_name_snapshot TEXT NOT NULL, + sku_snapshot TEXT NOT NULL, + model_name_snapshot TEXT, + unit_price NUMERIC NOT NULL DEFAULT 0, + quantity INTEGER NOT NULL DEFAULT 0, + line_amount NUMERIC NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS order_operation_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL, + operation_type TEXT NOT NULL, + operation_content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_user_permission_user_id ON user_permission(user_id); +CREATE INDEX IF NOT EXISTS idx_category_parent_id ON category(parent_id); +CREATE INDEX IF NOT EXISTS idx_product_category_id ON product_item(category_id); +CREATE INDEX IF NOT EXISTS idx_file_asset_business ON file_asset(business_type, business_id); +CREATE INDEX IF NOT EXISTS idx_order_item_order_id ON order_item(order_id); +CREATE INDEX IF NOT EXISTS idx_order_log_order_id ON order_operation_log(order_id); \ No newline at end of file diff --git a/ui-preview.html b/ui-preview.html new file mode 100644 index 0000000..d924899 --- /dev/null +++ b/ui-preview.html @@ -0,0 +1,1252 @@ + + + + + + 铜壶管理系统 UI 效果图 + + + +
+
+
+
UI PREVIEW · COPPER TEAPOT SYSTEM
+

铜壶管理系统
前端效果图预览

+

+ 这版效果图按“铜器仓储 + 茶纸账册”的视觉方向展开,重点展示登录页、商品中心、商品详情和订单处理主界面,便于先确认整体气质和页面层次。 +

+
+ 铜棕主色 + 米白纸感背景 + 深青侧栏 + 商品图优先 + 多角色可视化差异 +
+
+ +
+ +
+ 主工作台效果图 + 管理员视角 + 商品管理 + 订单处理 + 用户管理 +
+ +
+
+
+
+
+ 铜壶管理系统 + 商品、订单、标签、权限统一管理 +
+
+
+ +
待处理订单 12
+
管理员 · 张经理
+
退出登录
+
+
+ +
+ + +
+
+
+
+

商品中心

+
以图片浏览效率为主,配合目录树、状态标签和快捷加购操作。
+
+
+
新增商品
+
+
+ +
+
关键词:花纹A / SKU
+
状态:可售
+
类目:1号普通壶
+
价格区间:100 - 300
+
排序:更新时间
+
重置筛选
+
+ +
+
+
当前型号款式数
+
18
+
花纹、盖型、表面工艺合并管理
+
+
+
可售款式
+
12
+
库存充足,可快速下单
+
+
+
库存偏少
+
4
+
建议优先提醒打包或补货
+
+
+
待处理订单
+
12
+
标签待下载 5 单
+
+
+ +
+
+
+
+

商品卡片墙

+
缩略图优先,价格、库存和状态在同一视线内完成识别。
+
+
管理员可编辑
+
+
+
+
+
+
+
+

1号普通壶 · 花纹A

+
SKU TH-001-A
+
+ 可售 +
+
+
+
批发价
+
198
+
+
+
库存
+
82
+
+
+
+ +
加入订单
+
+
+
+ +
+
+
+
+
+

1号普通壶 · 花纹B

+
SKU TH-001-B
+
+ 库存少 +
+
+
+
批发价
+
228
+
+
+
库存
+
9
+
+
+
+ +
加入订单
+
+
+
+ +
+
+
+
+
+

1号普通壶 · 花纹C

+
SKU TH-001-C
+
+ 库存多 +
+
+
+
批发价
+
268
+
+
+
库存
+
145
+
+
+
+ +
加入订单
+
+
+
+
+
+ +
+
+
+

订单概览

+
打包工用户登录时,这一块将替代部分商品管理操作成为视觉重点。
+
+
未完成 8
+
+ +
+
订单号:TH20260411-008
+
客户:常州仓储客户
+
总数量:36 件
+
总金额:7,820
+
继续处理订单
+
+
+
+ +
+
+
+
+

商品详情页效果

+
兼顾管理员编辑态与客户用户只读态。
+
+
只读 / 可编辑双态
+
+ +
客户用户进入时,这里显示只读查看标识,保存、上传、删除类按钮将自动隐藏。
+ +
+
+
SKU:TH-001-A
+
名称:1号普通壶 · 花纹A
+
批发价:198
+
库存:82
+
状态:可售
+
备注:适合常规批发发货
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + +
+ + \ No newline at end of file diff --git a/前端页面UI方案.md b/前端页面UI方案.md new file mode 100644 index 0000000..cfb6132 --- /dev/null +++ b/前端页面UI方案.md @@ -0,0 +1,392 @@ +# 铜壶管理系统前端页面 UI 方案 + +## 1. 设计定位 +本系统虽然是后台管理端,但不建议做成普通灰白色表格后台。UI 视觉建议围绕“铜器仓储 + 茶纸账册”展开,整体风格稳重、清晰、带一点材质感,既符合铜壶业务,也能让商品图片和订单信息更有辨识度。 + +设计关键词: +* 铜色质感 +* 纸感底色 +* 深青色信息层 +* 大块留白与清晰分区 +* 商品图优先、数据次级、操作明确 + +适用目标: +* 管理员快速维护商品、订单和用户权限 +* 客户用户清楚浏览商品详情,不误触编辑功能 +* 打包工用户专注查看和完成订单,减少无关干扰 + +--- + +## 2. 视觉风格建议 + +### 2.1 整体风格方向 +建议采用“暖铜主色 + 米白背景 + 深青辅助色”的组合: +* 页面大背景使用偏米白、纸张感的浅暖色,不要纯白。 +* 核心按钮和高亮区域使用铜棕色,形成品牌识别。 +* 目录栏、头部栏和统计图表辅助色使用深青灰,增强层次和稳定感。 +* 卡片边框与分割线使用浅铜灰色,避免机械式的纯灰边框。 + +### 2.2 字体建议 +* 页面标题:Source Han Serif SC +* 正文与表单:Source Han Sans SC +* 数字和价格:IBM Plex Sans + +建议规则: +* 页面一级标题偏稳重,使用衬线字体提升识别度。 +* 表格、表单、按钮和筛选区域统一使用无衬线字体,保证后台信息密度下的可读性。 + +### 2.3 颜色系统 +建议使用以下色板: + +```text +主背景色 #F4EFE8 米白纸感 +面板背景色 #FBF8F3 温和浅底 +主标题文字 #2F2923 深墨褐 +常规正文 #5B544D 暖灰褐 +主品牌色 #A66536 铜棕色 +主品牌悬停色 #8F552C 深铜色 +辅助深色 #24353A 深青灰 +边框颜色 #D9CABB 浅铜灰 +成功状态 #56735D 茶绿色 +警告状态 #C68A35 琥珀色 +危险状态 #B5533D 砖红色 +信息状态 #4F6D7A 蓝灰色 +``` + +### 2.4 背景与材质建议 +* 登录页和系统首页可使用轻微的铜色渐变背景,叠加低透明度斜向纹理。 +* 主工作区不建议大面积重纹理,避免影响表格和商品图片识别。 +* 卡片和弹层可加入非常轻的毛玻璃感或半透明浅底,但透明度应克制。 + +### 2.5 动效建议 +* 页面切换:150ms 到 220ms 的淡入上移。 +* 卡片 hover:图片轻微放大 1.02 倍,边框从浅铜灰过渡到铜棕色。 +* 订单入口:待处理订单数变化时可有一次短促脉冲效果。 +* 权限切换或按钮禁用:避免突然消失,使用淡出或骨架切换。 + +--- + +## 3. 全局布局方案 + +### 3.1 整体框架 +系统主框架建议保持固定左侧导航,不使用遮挡式弹窗作为主编辑方式。 + +推荐尺寸: +* 左侧目录栏宽度:260px 到 280px +* 顶部功能栏高度:68px 到 72px +* 主内容区左右内边距:24px 到 32px +* 主卡片圆角:16px 到 20px + +布局结构如下: + +```text ++-----------------------------------------------------------------------------------+ +| 顶部功能栏:面包屑 | 全局搜索 | 待处理订单胶囊 | 当前用户 | 退出登录 | ++---------------------------+-------------------------------------------------------+ +| 左侧目录树 | 右侧主内容区 | +| 型号 / 类目 / 款式 | 根据路由显示:商品墙 / 商品详情 / 订单 / 统计 / 用户 | +| 可折叠、可滚动 | 内容区以卡片分块,保留统一标题与操作区 | ++---------------------------+-------------------------------------------------------+ +``` + +### 3.2 顶部功能栏设计 +顶部栏不只承担导航作用,还承担身份与任务提示: +* 左侧:页面标题、面包屑、当前模块说明 +* 中间:全局搜索,可快速搜索 SKU、商品名、订单号 +* 右侧:订单胶囊、通知预留位、当前用户头像和用户名、退出登录 +* 管理员额外显示“用户管理”快捷入口 + +### 3.3 左侧目录栏设计 +目录栏建议采用深青灰背景,内容区域使用浅色节点卡片: +* 顶部显示系统 Logo 与“铜壶管理系统”名称 +* 中部为树形目录 +* 当前选中型号或款式采用铜棕色高亮边条 +* 子节点悬停时显示淡铜背景 +* 非商品类目使用独立图标,避免和商品类目混淆 + +--- + +## 4. 关键页面 UI 图方案 + +### 4.1 登录页 +登录页采用左右分栏,不做普通表单居中页。左侧负责品牌表达,右侧负责登录操作。 + +页面线框: + +```text ++-----------------------------------------------------------------------------------+ +| 左侧品牌视觉区 | 右侧登录区 | +| 铜色渐变背景 + 铜壶主视觉 | 标题:欢迎进入铜壶管理系统 | +| 文案:商品、订单、标签、权限统一管理 | 账号输入框 | +| 小字:内部业务系统 | 密码输入框 | +| | 记住登录状态 | +| | 登录按钮 | +| | 底部:系统版本 / 技术支持 | ++-----------------------------------------------------------------------------------+ +``` + +设计要点: +* 左侧可放置半透明铜壶轮廓图、铜纹理和一句品牌文案。 +* 右侧登录卡片宽度建议 420px 左右,表单区上紧下松,减少压迫感。 +* 登录按钮使用铜棕色实底,按钮宽度铺满表单区。 + +### 4.2 商品中心页 +这是系统访问频率最高的页面,视觉重点应放在“筛选效率”和“商品图浏览效率”上。 + +页面线框: + +```text ++-----------------------------------------------------------------------------------+ +| 顶部功能栏 | ++---------------------------+-------------------------------------------------------+ +| 左侧目录树 | 页面标题:商品中心 [新增商品] [批量导入] | +| 1号普通壶 +-------------------------------------------------------+ +| |- 花纹A | 筛选栏:关键词 | 状态 | 类目 | 价格区间 | 重置 | +| |- 花纹B +-------------------------------------------------------+ +| 2号锤纹壶 | 商品卡片网格 | +| 宣传物料 | +-----------+ +-----------+ +-----------+ | +| | | 缩略图 | | 缩略图 | | 缩略图 | | +| | | 名称 SKU | | 名称 SKU | | 名称 SKU | | +| | | 价格 库存 | | 价格 库存 | | 价格 库存 | | +| | | 状态 +加购 | | 状态 +加购 | | 状态 +加购 | | +| | +-----------+ +-----------+ +-----------+ | +| | 分页 / 懒加载区 | ++---------------------------+-------------------------------------------------------+ +``` + +设计要点: +* 卡片图片区域占比高于文字区,建议图片区高度 170px 到 190px。 +* 卡片底部操作固定,避免每张卡片按钮高度不一致。 +* 状态标签使用实色小胶囊,不要做成大面积色块。 +* “加入订单”按钮建议做成圆角小按钮,使用铜色描边或实底。 + +### 4.3 商品详情页 +商品详情页是管理员维护商品的核心区域,同时也是客户用户查看商品的只读页面,因此必须兼顾编辑态和只读态。 + +页面线框: + +```text ++-----------------------------------------------------------------------------------+ +| 顶部功能栏 | ++---------------------------+-------------------------------------------------------+ +| 左侧目录树 | 返回商品中心 / 商品详情 | +| +-------------------------------------------------------+ +| | 标题:1号普通壶 - 花纹A 状态标签 只读/可编辑 | +| +---------------------------+---------------------------+ +| | 基础资料卡 | 图片管理卡 | +| | SKU | 主图预览 | +| | 名称 | 原图上传 | +| | 价格 | 详情图列表 | +| | 库存 | 主图设置 / 排序 / 删除 | +| | 状态 | | +| +---------------------------+---------------------------+ +| | 标签文件卡 | 操作卡 | +| | 已上传模板 | 保存 | +| | 下载模板 | 返回 | +| | 重新上传 | 加入订单 | ++---------------------------+-------------------------------------------------------+ +``` + +设计要点: +* 页面使用双列卡片布局,左侧偏业务字段,右侧偏图片和附件。 +* 管理员模式显示完整表单和上传按钮。 +* 客户用户进入时,右上角显示“只读查看”标签,页面切换为单列图片画廊,不展示基础资料、标签文件和下载按钮。 +* 管理员与可编辑用户的商品图片区支持主图大图预览,下方横向缩略图切换,并可直接拖动缩略图调整多图顺序。 + +### 4.4 订单列表页 +订单列表页以效率为主,避免过多视觉装饰,但需要通过卡片统计和状态标签增强可读性。 + +页面线框: + +```text ++-----------------------------------------------------------------------------------+ +| 顶部功能栏 | ++-----------------------------------------------------------------------------------+ +| 页面标题:订单中心 [全部订单] [未完成] [已完成] [月度统计] [年度统计] | ++-----------------------------------------------------------------------------------+ +| 汇总卡片:未完成订单数 | 今日出货数 | 本月订单金额 | 待下载标签订单数 | ++-----------------------------------------------------------------------------------+ +| 筛选栏:订单号 | 快递单号 | 日期范围 | 状态 | 关键字 | 搜索 | 重置 | ++-----------------------------------------------------------------------------------+ +| 订单表格 | +| 订单号 | 创建时间 | 款式数 | 总数量 | 总价 | 状态 | 快递单号 | 操作 | +| 查看详情 | 编辑 | 删除 | 完成订单 | ++-----------------------------------------------------------------------------------+ +``` + +设计要点: +* 未完成订单使用浅琥珀标签,已完成订单使用茶绿色标签。 +* 打包工用户登录后,页面只保留与订单处理相关的操作按钮。 +* 管理员可见全部订单操作与统计入口。 + +### 4.5 订单详情页 +订单详情页建议做成“上摘要、下明细、右操作”的三段结构,但在窄屏下改为纵向堆叠。 + +页面线框: + +```text ++-----------------------------------------------------------------------------------+ +| 返回订单列表 / 订单详情 | ++-----------------------------------------------------------------------------------+ +| 订单摘要卡:订单号 | 状态 | 创建时间 | 快递单号 | 完成时间 | 总金额 | ++-----------------------------------------------+-----------------------------------+ +| 商品明细表 | 订单操作卡 | +| 型号 | 款式 | SKU | 单价 | 数量 | 小计 | 标签下载 | +| ... | 填写快递单号 | +| ... | 完成订单 | +| ... | 删除订单(管理员可删已完成订单) | ++-----------------------------------------------+-----------------------------------+ +| 操作时间线:创建订单 -> 修改订单 -> 完成订单 | ++-----------------------------------------------------------------------------------+ +``` + +设计要点: +* 右侧操作卡做成固定吸附区,方便打包工在查看商品明细时直接完成操作。 +* 标签下载按钮放在操作卡顶部,突出业务关键性。 +* 已完成订单时,普通用户的编辑类按钮禁用并替换为灰色只读提示;管理员保留删除按钮。 + +### 4.6 用户管理页 +用户管理页仅管理员可见,界面需要更偏“系统管理”,减少商品图和装饰性内容。 + +页面线框: + +```text ++-----------------------------------------------------------------------------------+ +| 页面标题:用户与权限管理 [新增普通用户] | ++-----------------------------------------------------------------------------------+ +| 筛选栏:用户名 | 用户类型 | 状态 | 权限关键词 | 搜索 | 重置 | ++-----------------------------------------------------------------------------------+ +| 用户表格 | +| 用户名 | 显示名 | 用户类型 | 状态 | 最近登录时间 | 权限摘要 | 操作 | +| 编辑 | 重置密码 | 启停 | 分配权限 | ++-----------------------------------------------------------------------------------+ +| 权限分配抽屉 / 侧栏 | +| [商品查看] [商品编辑] [附件上传] [订单查看] [订单处理] [订单完成] [统计查看] | +| [用户管理] | ++-----------------------------------------------------------------------------------+ +``` + +设计要点: +* 权限分配不要用过深层级树,建议使用分组复选框,更适合当前两类用户体系。 +* 管理员账号默认不可删除,只可重置密码或停用普通用户。 + +### 4.7 统计页面 +统计页面建议比订单页更“看板化”,但仍需保留数据明细表。 + +页面线框: + +```text ++-----------------------------------------------------------------------------------+ +| 页面标题:经营统计 [月度] [年度] 年份选择 / 月份选择 / 类目筛选 | ++-----------------------------------------------------------------------------------+ +| 指标卡:总销量 | 总销售额 | 销量最高款式 | 平均客单价 | ++-----------------------------------------------------------------------------------+ +| 左侧图表:销量趋势 / 类目占比 | 右侧图表:款式销量排行 | ++-----------------------------------------------------------------------------------+ +| 明细表:款式 | 数量 | 单价 | 小计 | 占比 | ++-----------------------------------------------------------------------------------+ +``` + +设计要点: +* KPI 卡片用浅铜色与浅青色交错,不建议使用高饱和商务蓝。 +* 图表建议用铜棕、茶绿、蓝灰三组主色,保证风格统一。 + +--- + +## 5. 不同用户的 UI 差异方案 + +### 5.1 管理员用户 +管理员显示全部导航与全部操作: +* 商品中心 +* 订单中心 +* 统计页面 +* 用户管理 +* 商品编辑、图片上传、标签上传、删除操作全部可见 + +### 5.2 客户用户 +客户用户建议采用明显的只读体验: +* 仅显示商品中心及被授权的只读页面 +* 顶部显示“只读访问”小标签 +* 商品详情中隐藏保存、上传、删除、加入订单等编辑按钮 +* 页面风格仍保持完整,不要因为只读而显得残缺 + +### 5.3 打包工用户 +打包工用户应减少商品管理干扰,只保留订单处理主路径: +* 左侧导航重点显示订单中心 +* 商品相关页面如被授权访问,默认为只读 +* 订单列表和订单详情中的“完成订单”按钮突出显示 +* 快递单号输入框与标签下载区在视觉上应优先于次级信息 + +--- + +## 6. 组件级设计建议 + +### 6.1 卡片组件 +* 使用 16px 到 20px 圆角 +* 卡片头部与内容区之间留足 16px 到 20px 间距 +* 卡片阴影不宜过深,建议采用浅阴影加浅边框 + +### 6.2 按钮体系 +建议至少区分四类按钮: +* 主按钮:铜棕实底,用于保存、登录、完成订单 +* 次按钮:白底铜边,用于返回、重置、重新上传 +* 危险按钮:砖红色,用于删除 +* 幽灵按钮:透明底,用于轻量级辅助操作 + +### 6.3 状态标签 +* 可售:茶绿色 +* 停产:深灰褐 +* 库存多:蓝灰色 +* 库存少:琥珀色 +* 已完成订单:茶绿色 +* 未完成订单:琥珀色 +* 只读用户:浅蓝灰标签 + +### 6.4 表格设计 +* 表头采用浅铜灰底色,不用纯灰。 +* 行 hover 背景使用极浅米色。 +* 金额字段右对齐,数量字段居中,文本字段左对齐。 + +--- + +## 7. 响应式与适配建议 +虽然系统主要面向桌面端,但仍建议兼容常见笔记本与平板宽度: +* 1440px 以上:双列卡片与完整右侧操作区 +* 1200px 到 1439px:商品详情页仍可双列,但图片区缩小 +* 992px 到 1199px:订单详情页改为上下布局,右侧操作卡下沉到底部 +* 768px 到 991px:左侧目录改为抽屉式切换,顶部功能区保留核心入口 + +移动端不建议作为一期重点,但登录页和订单详情页应避免完全不可用。 + +--- + +## 8. 推荐前端实现方式 +为了让设计落地更顺畅,建议前端实现时先建立统一设计变量: + +```text +颜色变量:背景色、品牌色、状态色、边框色、文字色 +尺寸变量:顶部栏高度、侧栏宽度、卡片圆角、间距、阴影 +权限变量:只读态、可编辑态、禁用态按钮样式 +``` + +建议将以下部分优先组件化: +* 顶部功能栏 +* 左侧树形导航 +* 商品卡片 +* 状态标签 +* 订单状态栏 +* 权限按钮包装组件 +* 用户权限分配面板 + +--- + +## 9. 最终建议方案 +如果只做一版第一期 UI,建议优先采用以下组合: +1. 登录页使用“品牌视觉区 + 登录卡片”的双栏布局。 +2. 主系统采用“深色左侧栏 + 浅色内容区 + 铜色主按钮”的稳定结构。 +3. 商品中心强调图片与筛选效率。 +4. 商品详情页采用双列卡片结构,同时支持编辑态与只读态。 +5. 订单详情页突出“标签下载”和“完成订单”两个核心操作。 +6. 用户管理页采用清晰表格加权限抽屉,不做复杂权限树。 + +这套方案适合当前的业务复杂度,也方便后续直接落地到 Vue 组件开发。 \ No newline at end of file diff --git a/商品详情与订单页面设计文档.md b/商品详情与订单页面设计文档.md new file mode 100644 index 0000000..df52658 --- /dev/null +++ b/商品详情与订单页面设计文档.md @@ -0,0 +1,394 @@ +# 铜壶管理系统 - 商品展示、详情与订单管理设计文档 + +## 1. 项目概述 +本项目面向内部商品管理、销售下单与库房贴标场景,建设一套网页端铜壶管理系统。系统需要支持商品目录管理、款式展示、商品详情维护、订单创建、订单完成流转、标签按量导出、月度/年度统计分析,以及多用户登录和权限分级控制。 + +本次设计明确以下前提: +1. 采用前后端分离架构,前端独立部署,后端提供 API 服务。 +2. 商品上传图片必须存入数据库,不直接依赖本地磁盘路径。 +3. 标签文件下载由后端统一生成和输出。 +4. 订单在填写快递单号前属于未完成状态,可编辑、可删除;填写快递单号并确认后才算完成。 +5. 系统支持两类用户:管理员与普通用户;管理员拥有全量权限,普通用户权限由管理员创建和分配。 + +--- + +## 2. 设计原则 +1. **前后端职责清晰**:前端负责页面展示、交互与状态管理;后端负责业务规则、数据持久化、文件处理、标签生成与统计计算。 +2. **左侧目录常驻**:详情页不采用遮挡主界面的弹窗形式,而是采用系统内部独立详情路由,左侧目录固定保留。 +3. **媒体数据统一管理**:图片采用数据库二进制存储,列表页与详情页通过独立接口读取缩略图或原图。 +4. **订单闭环管理**:订单从加购、创建、编辑、完成、统计形成闭环,并保留详情追溯能力。 +5. **权限前后端一致**:前端负责菜单、页面和按钮级可见性控制,后端负责接口级鉴权,避免仅前端隐藏导致越权。 +6. **非商品类目可轻量处理**:如商标图片、宣传物料等类目可以不进入商品详情业务流程,仅提供预览或下载。 + +--- + +## 3. 总体架构设计 + +### 3.1 系统组成 +系统拆分为三个核心层: +* **前端管理端**:负责登录页、权限菜单、目录树、商品卡片墙、商品详情编辑、订单列表、订单详情、统计报表、用户管理等页面。 +* **后端服务端**:负责认证鉴权、用户管理、类目管理、商品管理、图片上传入库、订单流转、标签文件导出、统计报表接口。 +* **数据库**:保存用户账号、权限数据、业务数据、订单数据、图片二进制内容、标签文件元数据及必要日志。 + +### 3.2 建议技术选型 +为保证后续开发效率与维护性,建议采用以下技术栈: +* **前端**:Vue 3 + Vite + Vue Router + Pinia + Element Plus +* **后端**:Spring Boot + Spring Security + JWT + MyBatis-Plus + Spring Validation +* **数据库**:SQLite 3 +* **标签导出**:后端使用 PDF 模板生成库输出 PDF,或按需打包 ZIP + +### 3.3 部署方式 +* 前端作为独立静态站点部署,可通过 Nginx 提供访问。 +* 后端独立部署为 API 服务,对外提供 RESTful 接口。 +* SQLite 作为本地数据库文件随同后端部署,统一负责业务表与图片二进制数据存储。 +* 前端与后端通过 HTTP API 通信,不直接访问数据库。 +* 为降低写入锁竞争,系统建议采用单机单实例部署,避免多节点同时写入同一个 SQLite 文件。 + +--- + +## 4. 页面信息架构与导航设计 + +### 4.1 页面布局 +系统采用“左侧目录树 + 右侧主内容区 + 顶部功能区”的布局: +* **左侧目录树**:显示所有型号、类目以及对应款式子节点。 +* **右侧主内容区**:根据当前路由显示款式墙、商品详情页、订单列表、订单详情或统计页面。 +* **顶部功能区**:显示当前待下单商品数量、当前登录用户、退出登录入口;管理员可从顶部进入用户管理页面。 + +### 4.2 路由建议 +建议前端至少包含以下页面路由: +* `/login`:登录页 +* `/categories/:categoryId`:某个型号或类目的款式墙页面 +* `/products/:productId`:某个款式的独立详情页 +* `/orders`:订单列表页 +* `/orders/:orderId`:订单详情页 +* `/statistics`:统计分析页 +* `/system/users`:用户管理页,仅管理员可访问 + +### 4.3 左侧目录交互规则 +* 点击型号节点:右侧显示该型号下全部款式卡片。 +* 点击款式子节点:右侧直接跳转到该产品详情页。 +* 点击不需要详情页的类目:右侧显示素材列表、预览区或下载按钮,不进入商品业务详情页。 +* 左侧目录支持新增、编辑、删除、排序。 +* 某型号下的产品款式支持新增、编辑、删除,新增后自动同步到目录子节点。 + +### 4.4 权限展示规则 +* 未登录用户只能访问登录页,不能直接进入业务页面。 +* 登录后前端根据当前用户权限动态渲染菜单、页面入口和操作按钮。 +* 管理员可见全部模块,包括商品管理、订单管理、统计和用户管理。 +* 普通用户只显示已授权模块;即使手动输入地址,后端接口仍需再次校验权限。 + +--- + +## 5. 核心功能模块设计 + +### 5.1 用户登录与权限管理 +系统需要支持多用户登录,并采用“管理员 + 普通用户”的两类用户模型: +* **管理员用户**:默认拥有全部页面查看、数据编辑、订单处理、统计查看、用户管理和权限分配能力。 +* **普通用户**:账号由管理员创建,权限由管理员按需分配,不默认拥有全部功能。 +* **登录方式**:采用用户名与密码登录,后端签发登录令牌,前端通过路由守卫校验登录状态。 +* **用户管理**:管理员可以新增普通用户、编辑基础信息、重置密码、启用/停用账号、分配权限。 +* **权限粒度**:至少区分页面访问权限与操作权限,例如商品查看、商品编辑、附件上传、订单查看、订单处理、订单完成、统计查看、用户管理。 +* **典型普通用户示例**: + * 客户用户:可浏览商品列表与商品详情,仅查看商品图片画廊,不展示基础资料与标签信息,不可编辑商品,不可处理订单。 + * 打包工用户:可查看订单列表与订单详情,可处理未完成订单、填写快递单号并完成订单,可按需下载标签,不可编辑商品和用户。 + +### 5.2 左侧目录结构管理 +目录管理模块需要满足以下能力: +* 支持多级树形结构,至少包含“型号/类目/款式”三级概念。 +* 创建类目时必须配置 `requires_detail_page`,明确该类目是否进入商品详情流程。 +* 标准商品类目进入商品管理流程;非标准类目进入素材预览/下载流程。 +* 左侧目录支持实时刷新,确保新增产品后可直接点击进入详情页。 + +### 5.3 主界面:款式墙 +当左侧选中某个需要详情页的型号或商品类目时,右侧显示该型号下的款式卡片墙: +* 卡片显示商品缩略图、款式名称、SKU、库存状态、批发价、库存数量。 +* 状态标签区分为:可售、停产、库存多、库存少。 +* 卡片右下角提供快捷加购按钮,点击后加入待下单列表。 +* 点击卡片主体跳转到独立商品详情页。 +* 支持搜索、按状态筛选、按名称筛选、分页或懒加载。 + +### 5.4 款式详情页 +商品详情页采用**独立页面路由**,不是浏览器弹出窗口,也不是遮挡主界面的弹窗。进入详情页后,左侧目录继续保留,用户可以在目录与详情之间快速切换。 + +详情页需要支持以下编辑和展示能力: +* 基础信息:型号、款式名称、SKU、批发价、库存数量、库存状态、备注。 +* 图片管理:支持上传产品缩略图、原图和多张详情图;支持设置主图、调整顺序、删除图片。 +* 原图下载:点击图片可下载数据库中保存的原图。 +* 标签文件管理:支持上传标签模板文件,支持预览文件信息与重新上传。 +* 下单操作:支持输入数量后加入订单。 + +建议详情页分为以下区域: +1. **基础资料区**:编辑 SKU、名称、价格、库存和状态。 +2. **图片管理区**:上传缩略图、上传原图、多图列表、主图设置、拖拽调整图片顺序、原图下载。 +3. **标签文件区**:上传标签模板、查看模板版本、下载模板。 +4. **操作区**:保存、返回列表、加入订单。 + +权限补充要求: +* 只拥有商品查看权限的普通用户进入商品详情时,仅展示全部商品图片,不展示基础资料、标签信息以及原图/标签下载入口。 +* 只有拥有商品编辑或附件管理权限的用户才可修改商品基础信息、上传图片和更新标签文件。 + +### 5.5 订单中心与订单列表页 +订单模块从“类似购物车”的待下单中心演进为正式订单管理模块。 + +订单列表页需要包含: +* 全部订单 +* 未完成订单 +* 已完成订单 +* 月度统计入口 +* 年度统计入口 + +订单列表页功能要求: +* 展示订单编号、创建时间、商品款式数量合计、订单总价、订单状态、快递单号。 +* 未完成订单允许继续编辑商品数量、删除商品、删除整单。 +* 已完成订单默认只允许查看详情;管理员额外保留删除整单能力。 +* 页面支持按订单状态、日期范围、关键词筛选。 +* 无订单权限的用户不显示订单入口;只有拥有订单查看权限的用户才可访问订单列表。 + +### 5.6 订单详情页 +订单详情页用于查看和处理单个订单的完整内容,需要单独页面展示,而不是仅在右侧侧边栏中查看。 + +订单详情页至少包含以下信息: +* 订单基础信息:订单号、创建时间、状态、快递单号、完成时间。 +* 商品明细:产品型号、款式名称、SKU、数量、单价、小计。 +* 金额汇总:商品总数量、订单总价。 +* 标签下载:从订单详情页统一触发,不再放在右侧订单摘要中。 + +订单详情页业务规则: +* 未完成订单:可编辑商品数量、删除商品、删除订单、填写快递单号。 +* 已完成订单:信息只读,保留标签文件下载能力;管理员可删除整单。 +* 点击“完成订单”前必须填写快递单号;填写成功后订单状态更新为已完成。 +* 只有拥有订单处理权限的用户才可修改订单明细;只有拥有订单完成权限的用户才可填写快递单号并完成订单。 + +### 5.7 标签按量下载 +标签下载是本系统的关键业务功能,必须由后端统一计算和输出: +* 系统读取订单中每个款式的购买数量。 +* 按数量复制对应款式的标签模板内容。 +* 输出为可打印的 PDF,或按客户需求输出 ZIP 包。 +* 标签下载入口放在订单详情页中,由用户针对单个订单执行。 + +### 5.8 月度与年度统计 +统计页面需要支持月度与年度两个维度,并输出: +* 每款产品的销量数量 +* 每款产品的单价 +* 每款产品的小计金额 +* 当前月份总销售额 +* 当前年份总销售额 + +统计页面建议支持以下筛选条件: +* 年份 +* 月份 +* 型号/类目 +* 款式名称或 SKU +* 建议仅管理员或拥有统计查看权限的用户访问 + +--- + +## 6. 媒体与附件存储设计 + +### 6.1 图片入库要求 +根据本项目要求,商品图片必须存数据库,不依赖磁盘目录路径。建议采用“业务表 + 文件表分离”的方式: +* 商品表只保留业务字段和主图引用,不直接保存大体积二进制字段。 +* 文件表单独保存图片二进制内容、缩略图二进制内容、文件元数据。 +* 列表查询只返回文件 ID、文件名称和访问接口,不在列表接口中直接返回 BLOB 内容。 +* SQLite 附件表使用 BLOB 字段保存文件内容,建议控制单张图片大小并定期归档历史数据。 + +### 6.2 图片处理建议 +* 上传原图后,由后端生成缩略图并一并入库。 +* 前端列表页默认加载缩略图接口,详情页按需加载原图接口。 +* 同一款式支持多图,需支持主图标识与排序。 +* 建议对图片大小、格式进行限制,例如 JPG、PNG、WEBP,单张不超过 10MB。 + +### 6.3 标签文件处理建议 +标签文件可与图片共用统一附件表,通过 `file_role` 字段区分图片与标签模板: +* 图片文件:`COVER`、`DETAIL` +* 标签文件:`LABEL_TEMPLATE` + +对于标签模板文件,建议保存文件元数据与二进制内容,便于后端统一生成导出内容与备份。 + +--- + +## 7. 关键数据模型建议 + +### 7.1 类目表 Category +* `id`:主键 +* `parent_id`:父级类目 ID +* `name`:类目名称 +* `sort_no`:排序号 +* `requires_detail_page`:是否需要详情页 +* `category_type`:类目类型,区分商品类目与素材类目 +* `created_at`:创建时间 +* `updated_at`:更新时间 + +### 7.2 商品表 ProductItem +* `id`:主键 +* `category_id`:所属类目 ID +* `sku`:商品 SKU +* `name`:款式名称 +* `model_name`:所属型号名称快照或冗余字段 +* `status`:状态,建议值为 `AVAILABLE`、`DISCONTINUED`、`HIGH_STOCK`、`LOW_STOCK` +* `wholesale_price`:批发价格 +* `stock_quantity`:库存数量 +* `cover_asset_id`:主图附件 ID +* `remark`:备注 +* `created_at`:创建时间 +* `updated_at`:更新时间 + +### 7.3 附件表 FileAsset +* `id`:主键 +* `business_type`:业务类型,如 `PRODUCT` +* `business_id`:业务对象 ID +* `file_role`:文件角色,如 `COVER`、`DETAIL`、`LABEL_TEMPLATE` +* `file_name`:文件名 +* `mime_type`:文件类型 +* `file_size`:文件大小 +* `content_blob`:原始二进制内容 +* `preview_blob`:缩略图或预览二进制内容 +* `sort_no`:排序号 +* `is_primary`:是否主图 +* `sha256`:文件摘要 +* `created_at`:创建时间 + +### 7.4 订单表 Order +* `id`:主键 +* `order_no`:订单编号 +* `status`:订单状态,建议值为 `PENDING`、`COMPLETED`、`CANCELLED` +* `total_quantity`:商品总数量 +* `total_amount`:订单总金额 +* `express_no`:快递单号 +* `remark`:订单备注 +* `created_at`:创建时间 +* `completed_at`:完成时间 + +### 7.5 订单明细表 OrderItem +* `id`:主键 +* `order_id`:订单 ID +* `product_id`:商品 ID +* `product_name_snapshot`:商品名称快照 +* `sku_snapshot`:SKU 快照 +* `model_name_snapshot`:型号名称快照 +* `unit_price`:下单时单价快照 +* `quantity`:数量 +* `line_amount`:小计金额 + +### 7.6 订单日志表 OrderOperationLog(建议) +* `id`:主键 +* `order_id`:订单 ID +* `operation_type`:操作类型 +* `operation_content`:操作内容 +* `created_at`:操作时间 + +### 7.7 用户表 UserAccount +* `id`:主键 +* `username`:登录账号 +* `password_hash`:密码哈希值 +* `display_name`:显示名称 +* `user_type`:用户类型,建议值为 `ADMIN`、`NORMAL` +* `status`:账号状态,建议值为 `ENABLED`、`DISABLED` +* `last_login_at`:最近登录时间 +* `created_by`:创建人 ID +* `created_at`:创建时间 +* `updated_at`:更新时间 + +### 7.8 用户权限表 UserPermission +* `id`:主键 +* `user_id`:用户 ID +* `permission_code`:权限编码,如 `PRODUCT_VIEW`、`PRODUCT_EDIT`、`ASSET_UPLOAD`、`ORDER_VIEW`、`ORDER_PROCESS`、`ORDER_COMPLETE`、`STATISTICS_VIEW`、`USER_MANAGE` +* `permission_name`:权限名称 +* `created_at`:创建时间 + +说明:管理员默认拥有全量权限;普通用户通过权限表进行授权。 + +--- + +## 8. 接口规划建议 + +### 8.1 认证与用户接口 +* `POST /api/auth/login`:用户名密码登录 +* `POST /api/auth/logout`:退出登录 +* `GET /api/auth/me`:获取当前登录用户及权限 +* `GET /api/users`:分页查询用户列表,仅管理员可用 +* `POST /api/users`:新增普通用户,仅管理员可用 +* `PUT /api/users/{id}`:编辑用户基础信息,仅管理员可用 +* `PUT /api/users/{id}/password`:重置用户密码,仅管理员可用 +* `PUT /api/users/{id}/status`:启用或停用用户,仅管理员可用 +* `PUT /api/users/{id}/permissions`:分配用户权限,仅管理员可用 + +### 8.2 类目接口 +* `GET /api/categories/tree`:获取类目树 +* `POST /api/categories`:新增类目 +* `PUT /api/categories/{id}`:编辑类目 +* `DELETE /api/categories/{id}`:删除类目 + +### 8.3 商品接口 +* `GET /api/categories/{id}/products`:获取某类目下商品列表 +* `GET /api/products/{id}`:获取商品详情 +* `POST /api/products`:新增商品 +* `PUT /api/products/{id}`:编辑商品 +* `DELETE /api/products/{id}`:删除商品 + +### 8.4 附件接口 +* `GET /api/products/{id}/assets`:获取商品附件元数据列表 +* `POST /api/products/{id}/assets`:上传商品图片或标签文件 +* `GET /api/products/{id}/assets/{assetId}/download`:获取原图或标签文件下载流 +* `PUT /api/products/{id}/assets/{assetId}/primary`:将图片附件设为主图 +* `PUT /api/products/{id}/assets/image-order`:按拖拽结果更新商品图片顺序 +* `DELETE /api/products/{id}/assets/{assetId}`:删除附件 + +### 8.5 订单接口 +* `POST /api/orders`:创建订单 +* `GET /api/orders`:查询订单列表 +* `GET /api/orders/{id}`:查询订单详情 +* `PUT /api/orders/{id}`:编辑未完成订单 +* `DELETE /api/orders/{id}`:删除未完成订单;管理员可删除已完成订单 +* `POST /api/orders/{id}/complete`:填写快递单号并完成订单 +* `GET /api/orders/{id}/labels/download`:按当前订单数量导出标签文件 + +### 8.6 统计接口 +* `GET /api/statistics/monthly`:月度统计 +* `GET /api/statistics/yearly`:年度统计 + +--- + +## 9. 关键业务规则 +1. 所有业务接口默认要求登录后访问;登录成功后才能进入系统主界面。 +2. 管理员用户拥有全部页面查看、数据编辑、订单处理、统计查看和用户管理权限。 +3. 普通用户账号必须由管理员创建,权限必须由管理员分配;未授权功能默认不可访问。 +4. 前端必须根据权限控制菜单、路由和按钮显示,后端必须同步校验接口权限,不能只依赖前端隐藏。 +5. `requires_detail_page = false` 的类目不进入商品详情页,不要求维护价格、库存等业务字段。 +6. 商品详情页必须支持直接上传图片和标签文件,并支持多图管理。 +7. 只拥有商品查看权限的普通用户可以浏览商品详情,但不可编辑商品、上传附件或删除数据。 +8. 停产商品默认不可直接加入订单;如需下单,应由后端做显式校验或弹出确认。 +9. 下单数量不得超过库存数量,库存不足时必须提示并拦截。 +10. 未完成订单允许编辑、删除;已完成订单默认只允许查看,但管理员可删除整单。 +11. 只有拥有订单处理或订单完成权限的用户才可修改订单、填写快递单号并完成订单。 +12. 标签下载入口统一放在订单详情页,不保留在右侧订单摘要中。 +13. 统计页面仅管理员或拥有统计查看权限的用户可访问。 +14. 统计数据建议基于订单明细快照字段计算,避免商品后续改价影响历史统计结果。 +15. 所有附件列表接口默认只返回元数据,不直接返回数据库二进制内容。 + +--- + +## 10. 非功能要求 +1. **性能**:商品列表与订单列表接口不得直接加载 BLOB 字段;图片通过独立接口按需读取。 +2. **安全**:上传接口需校验文件类型、大小与权限;下载接口需校验访问权限;密码必须加密存储;登录接口需具备基础防暴力破解能力。 +3. **稳定性**:订单创建、订单完成、库存扣减等操作必须在事务中处理;SQLite 建议启用 WAL 模式并控制并发写入。 +4. **备份**:由于图片保存在 SQLite 数据库文件中,备份策略必须覆盖数据库主文件及其相关日志文件。 +5. **可维护性**:接口统一返回结构,前后端统一错误码与提示文案。 + +--- + +## 11. 验收标准 +1. 系统支持用户名密码登录,未登录状态不能直接进入业务页面。 +2. 管理员用户可查看和编辑全部模块,并可进入用户管理页面。 +3. 管理员可创建普通用户、重置密码、启用或停用账号,并分配权限。 +4. 仅拥有商品查看权限的普通用户只能浏览商品列表和商品详情,不显示编辑、上传、删除按钮。 +5. 拥有订单处理权限的打包工用户可查看订单、填写快递单号并完成订单,但不可编辑商品和用户。 +6. 左侧目录支持类目与商品节点的新增、编辑、删除、跳转。 +7. 商品卡片墙可正常展示缩略图、状态、价格、库存并支持快捷加购。 +8. 商品详情页可编辑 SKU、价格、库存状态、库存数量,并支持多图上传与标签文件上传。 +9. 图片上传后保存在数据库中,刷新页面后仍可读取和下载原图。 +10. 订单列表页支持全部、未完成、已完成筛选。 +11. 未完成订单可编辑和删除;填写快递单号后可完成订单并转为只读。 +12. 订单详情页可查看完整商品明细并执行标签按量下载。 +13. 月度和年度统计页面能正确统计每款商品数量、单价、小计与总价。 + diff --git a/详细实施任务拆分.md b/详细实施任务拆分.md new file mode 100644 index 0000000..c222136 --- /dev/null +++ b/详细实施任务拆分.md @@ -0,0 +1,88 @@ +# 铜壶管理系统详细实施任务拆分 + +## 1. 当前阶段目标 +在现有设计文档、任务计划书和 UI 方案全部落地后,当前已经进入“主流程完成后的联调、验收与交付整理”阶段。 + +当前阶段目标聚焦以下事项: +1. 继续核对前后端主链路在真实 SQLite 数据库上的运行情况。 +2. 保证登录、权限、商品、附件、订单、标签下载、统计、用户管理等功能稳定可用。 +3. 同步更新设计文档与实施清单,避免后续联调继续参考过期说明。 +4. 整理最终验收项、部署注意点和可交付内容。 + +--- + +## 2. 本轮实施拆分 + +### 2.1 已完成准备工作 +1. 设计文档已确定功能边界。 +2. 任务计划书已明确阶段目标。 +3. UI 方案与静态效果图已完成。 +4. 前端 `frontend` 与后端 `backend` 工程骨架已生成。 + +### 2.2 第一批开发范围 + +#### A. 后端基础能力 +1. 调整后端工程到 Java 11 可运行版本。 +2. 接入 SQLite 数据源与初始化脚本。 +3. 建立用户、权限、类目、商品、订单等核心基础表结构。 +4. 实现统一返回结构和健康检查接口。 +5. 实现登录、获取当前用户、用户列表、用户创建、权限分配基础接口。 +6. 建立 JWT 鉴权过滤器与服务端权限校验骨架。 + +#### B. 前端基础能力 +1. 接入 Vue Router、Pinia、Element Plus。 +2. 实现登录页和登录态持久化。 +3. 实现主工作台布局、顶部栏、左侧导航。 +4. 实现基于权限的菜单显示和页面路由守卫。 +5. 实现商品中心、商品详情、订单列表、订单详情、统计页、用户管理页的第一版静态骨架。 +6. 接入后端登录接口和当前用户接口。 + +#### C. 联调验证 +1. 管理员登录后可访问全部基础页面。 +2. 普通用户根据权限显示对应菜单。 +3. 服务端未授权接口返回拒绝结果。 +4. 前端未登录状态不能进入业务页面。 + +### 2.3 当前已完成能力概览 +1. 登录、当前用户、JWT 鉴权、菜单与按钮级权限控制已经完成。 +2. 商品中心、商品详情、商品编辑、附件上传、附件下载、主图切换、附件删除已经完成。 +3. 订单创建、编辑、删除、完成、标签按量下载、库存校验与完成扣减已经完成。 +4. 月度统计、年度统计、用户管理页面与对应后端接口已经完成。 +5. 前端 `npm run build`、后端 `./mvnw.cmd test`、后端 8081 启动与默认管理员登录均已验证通过。 + +--- + +## 3. 当前剩余收尾项 + +### 3.1 联调与验收 +1. 继续用真实数据复核商品附件、订单标签下载、库存扣减等关键业务场景。 +2. 补充异常场景回归,包括越权访问、缺少标签模板、库存不足和空附件等边界情况。 + +### 3.2 文档与交付整理 +1. 同步设计文档、计划书、实施拆分与当前实现保持一致。 +2. 整理部署命令、默认账号、数据库备份与版本化 SQL 说明。 + +### 3.3 体验与性能收尾 +1. 视需要继续优化前端构建体积与页面加载体验。 +2. 视业务数据规模决定是否把当前前端本地筛选进一步升级为服务端分页查询。 + +--- + +## 4. 当前建议验收点 +本轮代码提交后,至少应满足: +1. 前端项目可构建通过,并能进入登录页和业务页面。 +2. 后端项目可启动并初始化 SQLite 数据库,8081 端口实例可正常访问。 +3. 使用默认管理员账号可以登录,并获取当前用户信息。 +4. 登录后可看到商品中心、商品详情、订单中心、订单详情、统计页、用户管理页。 +5. 商品附件上传、下载、设主图、删除和订单标签下载链路可正常工作。 +6. 订单创建、编辑、删除、完成和库存联动规则可正常工作。 + +--- + +## 5. 当前默认演示账号 +为方便第一轮联调,建议先保留以下默认账号: +1. 管理员:`admin / Admin@123` +2. 客户用户:`customer / Customer@123` +3. 打包工:`packer / Packer@123` + +后续可在用户管理页中由管理员进行新增、重置密码和权限调整。 \ No newline at end of file diff --git a/铜壶管理系统开发任务计划书.md b/铜壶管理系统开发任务计划书.md new file mode 100644 index 0000000..cf618ae --- /dev/null +++ b/铜壶管理系统开发任务计划书.md @@ -0,0 +1,199 @@ +# 铜壶管理系统开发任务计划书 + +## 1. 计划目标 +本计划书用于指导铜壶管理系统从文档阶段进入实施阶段,目标是在前后端分离架构下,完成多用户登录、权限控制、商品目录、商品详情、订单管理、标签导出、统计分析等核心模块开发,并满足“图片存数据库”的明确要求。 + +本计划默认采用以下实施前提: +1. 前端与后端独立开发、独立部署。 +2. 商品图片与标签模板通过统一附件能力管理,其中图片必须保存到 SQLite 数据库。 +3. 本计划不包含 Git 分支管理流程,交付以源码包、数据库脚本、部署文档为准。 +4. 系统仅设置两类用户:管理员与普通用户;管理员拥有全量权限,普通用户权限由管理员创建和分配。 + +--- + +## 2. 交付范围 +本次交付建议至少包含以下内容: +* 前端管理端项目一套 +* 后端 API 服务项目一套 +* SQLite 建表与初始化脚本一套 +* 登录认证与用户权限管理能力 +* 附件上传、读取、下载与标签导出能力 +* 部署说明、初始化说明、验收清单 + +不在本次优先范围内的内容: +* 小程序或移动 App +* 多租户能力 +* 复杂组织架构、部门审批、数据域级权限中心 +* 自动化 CI/CD 流程 + +--- + +## 3. 推荐项目结构 +建议后续源码按以下目录拆分: + +```text +teapot_system/ + docs/ + frontend/ + backend/ + sql/ + deploy/ +``` + +说明: +* `frontend`:前端管理端源码 +* `backend`:后端 API 服务源码 +* `sql`:建表脚本、初始化数据、升级脚本 +* `deploy`:部署说明、环境变量模板、打包说明 + +--- + +## 4. 工期与人员建议 +建议投入方式如下: +* **标准配置**:1 名前端 + 1 名后端 + 1 名兼职测试/业务验收 +* **并行开发周期**:约 5 至 7 周 +* **单人开发周期**:约 8 至 10 周 + +如需更快交付,可优先上线“登录权限 + 商品管理 + 订单管理 + 标签下载”主链路,统计功能作为第二阶段补充。 + +--- + +## 5. 阶段计划 + +| 阶段 | 预计工期 | 主要目标 | 前端任务 | 后端任务 | 产出物 | 验收重点 | +| --- | --- | --- | --- | --- | --- | --- | +| 阶段 1:需求冻结与原型细化 | 2 天 | 固化业务规则和页面流转 | 确认页面清单、字段清单、路由结构 | 确认接口域、状态流转、数据实体 | 最终设计文档、页面草图、字段清单 | 需求边界明确,无冲突点 | +| 阶段 2:架构与数据库设计 | 2-3 天 | 明确前后端边界与 SQLite 表结构 | 规划页面布局、接口调用层、状态管理结构、权限展示规则 | 设计数据库、接口规范、统一返回结构 | ER 图、接口清单、SQLite 脚本初版 | 表结构可覆盖用户、商品、订单、图片入库需求 | +| 阶段 3:基础工程搭建 | 2 天 | 搭好可运行项目骨架 | 初始化 Vue 项目、路由、Pinia、请求封装、基础布局 | 初始化 Spring Boot 项目、SQLite 连接、统一异常处理、参数校验、接口骨架 | 前后端可运行空壳工程 | 前后端基础访问联通 | +| 阶段 4:登录与权限 | 3-4 天 | 完成登录、鉴权和用户管理基础能力 | 开发登录页、登录态保持、路由守卫、权限菜单、用户管理页 | 完成登录认证、密码加密、接口鉴权、用户 CRUD、权限分配接口 | 登录页、用户管理页、认证接口 | 管理员和普通用户权限差异生效 | +| 阶段 5:类目与商品列表 | 4-5 天 | 完成目录树与款式墙主流程 | 开发左侧目录树、商品卡片墙、筛选条件、快捷加购入口 | 完成类目 CRUD、商品列表、商品 CRUD 接口 | 目录页和商品列表页 | 目录切换、卡片加载、商品新增编辑正常 | +| 阶段 6:商品详情与图片入库 | 4-5 天 | 完成商品详情编辑与附件入库 | 开发商品详情页、图片上传组件、标签文件上传组件、多图排序、只读态展示 | 完成附件上传入库、缩略图生成、原图下载、标签文件管理接口 | 商品详情页、附件表、附件接口 | 图片刷新后可读取,且确实保存在数据库 | +| 阶段 7:订单与标签导出 | 5-6 天 | 完成订单创建、编辑、完成、标签下载 | 开发订单列表页、订单详情页、数量编辑、完成按钮、快递单号录入、权限按钮控制 | 完成订单 CRUD、库存校验、完成流转、标签按量导出 | 订单模块主流程 | 未完成订单可编辑,已完成订单默认只读且管理员可删除,标签下载正确 | +| 阶段 8:统计分析 | 2-3 天 | 完成月度与年度统计 | 开发统计筛选页、表格与汇总展示 | 开发月度/年度统计聚合接口 | 统计页面 | 数量、单价、小计、总价计算准确 | +| 阶段 9:联调、测试与部署 | 3-4 天 | 修复问题并形成交付物 | 接口联调、页面细节修正、异常提示完善 | 性能优化、SQL 校验、导出测试、部署脚本整理 | 部署说明、验收清单、发布包 | 主流程稳定可用,可完成验收 | + +建议预留 2 天缓冲时间,用于处理标签排版细节、数据修正或现场反馈调整。 + +--- + +## 6. 模块级任务拆解 + +### 6.1 前端任务清单 +1. 搭建基础框架:路由、状态管理、请求封装、全局异常提示。 +2. 开发登录页、退出登录、登录态保持和路由守卫。 +3. 开发主布局:左侧树、顶部订单入口、当前用户信息、右侧内容区。 +4. 开发菜单级、页面级、按钮级权限控制,根据当前用户动态显示入口与操作按钮。 +5. 开发管理员用户管理页:用户新增、编辑、启停、密码重置、权限分配。 +6. 开发类目树组件:树节点增删改、展开收起、点击跳转。 +7. 开发商品卡片墙:卡片展示、状态标签、筛选、分页、快捷加购。 +8. 开发商品详情页:基础表单、多图上传、主图设置、原图下载、标签文件上传、只读展示。 +9. 开发订单列表页:全部/未完成/已完成切换、搜索筛选、删除操作、权限差异展示。 +10. 开发订单详情页:订单明细、数量编辑、金额汇总、快递单号录入、标签下载。 +11. 开发统计页面:月度统计、年度统计、筛选条件、汇总展示。 +12. 完成接口联调、错误提示、空状态和加载状态处理。 + +### 6.2 后端任务清单 +1. 初始化项目骨架:统一返回结构、异常处理、日志、参数校验。 +2. 实现登录认证:用户名密码校验、密码哈希存储、登录令牌签发与退出。 +3. 实现接口鉴权:基于管理员全量权限和普通用户权限编码控制 API 访问。 +4. 实现用户管理:管理员新增普通用户、编辑用户、重置密码、启停账号、分配权限。 +5. 设计并实现类目管理接口与商品管理接口。 +6. 实现附件上传能力:接收图片、校验格式、生成缩略图、写入数据库。 +7. 实现附件读取能力:缩略图预览、原图下载、标签模板下载。 +8. 实现订单模块:创建订单、编辑订单、删除订单、订单完成。 +9. 实现库存校验逻辑:下单不得超过库存,订单完成逻辑可选扣减库存或锁定库存。 +10. 实现标签导出逻辑:根据订单明细和数量生成 PDF 或 ZIP。 +11. 实现统计接口:月度与年度维度聚合统计。 +12. 输出部署配置、SQLite 初始化脚本、测试数据脚本。 + +### 6.3 数据库任务清单 +1. 设计用户、用户权限、类目、商品、附件、订单、订单明细、订单日志表。 +2. 明确 SQLite 中图片 BLOB 字段、缩略图字段和索引策略。 +3. 建立用户类型、账号状态、订单状态、商品状态、附件类型、权限编码等枚举规范。 +4. 输出建表脚本与初始化脚本,并配置 WAL 模式与基础 pragma 策略。 +5. 输出 SQLite 数据库文件备份策略,确保图片入库后可完整恢复。 + +### 6.4 测试任务清单 +1. 测试登录、退出登录、未登录拦截、停用账号拦截。 +2. 测试管理员创建普通用户、重置密码、分配权限是否生效。 +3. 测试普通用户菜单显示、页面访问和接口访问是否与权限一致。 +4. 测试目录新增、编辑、删除和跳转。 +5. 测试商品新增、编辑、状态变更、库存修改。 +6. 测试图片上传、刷新读取、原图下载、多图排序。 +7. 测试标签模板上传与订单标签导出。 +8. 测试订单创建、编辑、删除、完成、快递单号校验。 +9. 测试月度与年度统计是否与订单明细一致。 +10. 测试大图片上传、异常文件上传、库存不足下单、越权访问等边界场景。 + +--- + +## 7. 关键里程碑定义 +1. **M1:基础架构完成** + 前后端空壳工程可运行,数据库脚本初版完成。 +2. **M2:登录与权限完成** + 管理员和普通用户可登录,权限菜单、接口鉴权和用户管理能力可用。 +3. **M3:商品管理主流程完成** + 可完成目录管理、商品列表和商品详情编辑。 +4. **M4:图片入库完成** + 图片上传后写入数据库,前端可正常显示缩略图并下载原图。 +5. **M5:订单链路完成** + 可创建订单、编辑未完成订单、填写快递单号后完成订单。 +6. **M6:标签下载完成** + 可根据订单数量导出对应标签文件。 +7. **M7:统计与验收完成** + 月度/年度统计上线,系统进入验收阶段。 + +--- + +## 8. 验收清单 +1. 系统支持用户名密码登录、退出登录,未登录状态不能进入业务页面。 +2. 管理员拥有全部查看和编辑权限,可进入用户管理页面。 +3. 管理员可创建普通用户、重置密码、启停账号并分配权限。 +4. 商品查看权限用户只能浏览商品列表与详情,不可编辑商品和上传附件。 +5. 打包工权限用户可查看订单、填写快递单号并完成订单,但不可编辑商品和用户。 +6. 左侧目录可维护,点击型号和产品节点的跳转行为正确。 +7. 商品列表页能显示缩略图、状态、库存、价格,并支持快捷加购。 +8. 商品详情页可以维护 SKU、价格、库存状态、库存数量。 +9. 商品详情页支持上传多张图片,上传后图片保存于数据库而不是磁盘路径。 +10. 订单列表可区分全部、未完成、已完成。 +11. 未完成订单可以修改数量、删除商品、删除整单。 +12. 填写快递单号后订单可完成,完成后普通用户不可编辑;管理员可删除已完成订单。 +13. 订单详情页可下载对应数量的标签文件。 +14. 统计页面可按月、按年查看款式数量、单价、小计和总价。 +15. 数据库备份后可恢复图片与业务数据。 + +--- + +## 9. 风险与应对措施 +1. **图片入库导致数据库体积增长过快** + 方案:附件表与业务表分离,列表接口不查 BLOB,建立定期备份与归档策略。 +2. **标签导出排版复杂,容易反复调整** + 方案:优先确认固定标签模板,再进入导出开发阶段。 +3. **SQLite 写并发能力有限** + 方案:采用单实例部署,启用 WAL 模式,订单创建与修改时增加事务校验并缩短写事务时间。 +4. **仅做前端隐藏导致权限越权** + 方案:前后端双重校验权限,所有核心接口都做服务端鉴权。 +5. **无 Git 流程时版本留痕不足** + 方案:按里程碑输出压缩包、数据库脚本编号和变更说明文档。 + +--- + +## 10. 交付与备份建议 +由于本项目不采用 Git 流程,建议至少执行以下管理方式: +1. 每个里程碑打一个源码压缩包,例如 `teapot_system_m3_20260411.zip`。 +2. 数据库脚本按版本号编号,例如 `V001__init.sql`、`V002__order_module.sql`。 +3. 每次发布前保留 SQLite 数据库文件全量备份,并同时关注 WAL 或日志文件。 +4. 发布包中同时包含部署说明、环境变量说明和初始化 SQL。 + +--- + +## 11. 实施顺序建议 +如需尽快上线,建议按以下优先级实施: +1. 登录与权限 +2. 左侧目录 + 商品卡片墙 +3. 商品详情页 + 图片入库 +4. 订单创建/编辑/完成 +5. 标签按量下载 +6. 统计分析 + +这样可以先打通登录权限、商品与订单主链路,再补统计和优化项。 \ No newline at end of file