From 77c47b89f9f1c38ce8b72c70ae063c3d104c8cab Mon Sep 17 00:00:00 2001 From: jiachenlong Date: Mon, 23 Mar 2026 10:56:23 +0800 Subject: [PATCH] Full release v1.2.12 - all files updated --- {backend => backend.bak}/.dockerignore | 0 {backend => backend.bak}/.env.example | 0 {backend => backend.bak}/Dockerfile | 0 {backend => backend.bak}/README.md | 0 {backend => backend.bak}/app/core/__init__.py | 0 {backend => backend.bak}/app/core/auth.py | 0 {backend => backend.bak}/app/core/database.py | 0 .../app/core/error_handler.py | 0 .../app/core/logging_config.py | 0 {backend => backend.bak}/app/main.py | 0 .../app/middleware/logging.py | 0 .../app/models/__init__.py | 0 {backend => backend.bak}/app/models/models.py | 0 .../app/routers/__init__.py | 0 {backend => backend.bak}/app/routers/auth.py | 0 .../app/routers/collections.py | 0 {backend => backend.bak}/app/routers/ocr.py | 0 .../app/routers/operations.py | 0 {backend => backend.bak}/app/routers/users.py | 0 .../app/schemas/__init__.py | 0 .../app/schemas/schemas.py | 0 {backend => backend.bak}/app/services/oss.py | 0 {backend => backend.bak}/app/services/sms.py | 0 {backend => backend.bak}/requirements.txt | 0 {backend => backend.bak}/uploads/.gitkeep | 0 backend_src/.dockerignore | 9 + backend_src/.env.example | 26 + backend_src/Dockerfile | 25 + backend_src/README.md | 42 + backend_src/app/core/__init__.py | 0 backend_src/app/core/auth.py | 95 +++ backend_src/app/core/database.py | 31 + backend_src/app/core/error_handler.py | 174 +++++ backend_src/app/core/logging_config.py | 113 +++ backend_src/app/main.py | 103 +++ backend_src/app/middleware/logging.py | 88 +++ backend_src/app/models/__init__.py | 0 backend_src/app/models/models.py | 132 ++++ backend_src/app/routers/__init__.py | 0 backend_src/app/routers/auth.py | 182 +++++ backend_src/app/routers/collections.py | 725 ++++++++++++++++++ backend_src/app/routers/ocr.py | 376 +++++++++ backend_src/app/routers/operations.py | 104 +++ backend_src/app/routers/users.py | 249 ++++++ backend_src/app/schemas/__init__.py | 0 backend_src/app/schemas/schemas.py | 213 +++++ backend_src/app/services/oss.py | 126 +++ backend_src/app/services/sms.py | 106 +++ backend_src/requirements.txt | 13 + backend_src/uploads/.gitkeep | 0 config_src/VERSION | 1 + config_src/docker-compose-test.yml | 50 ++ config_src/docker-compose.yml | 73 ++ config_src/nginx.conf | 60 ++ frontend/{src => frontend_src}/App.jsx | 0 .../{src => frontend_src}/config/version.js | 0 frontend/{src => frontend_src}/index.css | 0 frontend/{src => frontend_src}/main.jsx | 0 frontend/{src => frontend_src}/pages/Add.jsx | 0 .../{src => frontend_src}/pages/Admin.jsx | 0 .../{src => frontend_src}/pages/BatchMode.jsx | 0 .../{src => frontend_src}/pages/Detail.jsx | 0 frontend/{src => frontend_src}/pages/Edit.jsx | 0 .../pages/Edit.jsx.dual_column_bak | 0 frontend/{src => frontend_src}/pages/Home.jsx | 0 frontend/frontend_src/pages/List.jsx | 603 +++++++++++++++ .../{src => frontend_src}/pages/Login.jsx | 0 frontend/{src => frontend_src}/pages/OCR.jsx | 0 .../{src => frontend_src}/pages/Settings.jsx | 0 frontend/frontend_src/pages/Stats.jsx | 292 +++++++ frontend/{src => frontend_src}/utils/api.js | 0 .../{src => frontend_src}/utils/errorCodes.js | 0 frontend/src.bak/App.jsx | 110 +++ frontend/src.bak/config/version.js | 24 + frontend/src.bak/index.css | 1 + frontend/src.bak/main.jsx | 23 + frontend/src.bak/pages/Add.jsx | 673 ++++++++++++++++ frontend/src.bak/pages/Admin.jsx | 418 ++++++++++ frontend/src.bak/pages/BatchMode.jsx | 513 +++++++++++++ frontend/src.bak/pages/Detail.jsx | 388 ++++++++++ frontend/src.bak/pages/Edit.jsx | 491 ++++++++++++ .../src.bak/pages/Edit.jsx.dual_column_bak | 575 ++++++++++++++ frontend/src.bak/pages/Home.jsx | 255 ++++++ frontend/{src => src.bak}/pages/List.jsx | 0 frontend/src.bak/pages/Login.jsx | 671 ++++++++++++++++ frontend/src.bak/pages/OCR.jsx | 654 ++++++++++++++++ frontend/src.bak/pages/Settings.jsx | 434 +++++++++++ frontend/{src => src.bak}/pages/Stats.jsx | 0 frontend/src.bak/utils/api.js | 230 ++++++ frontend/src.bak/utils/errorCodes.js | 165 ++++ 90 files changed, 9636 insertions(+) rename {backend => backend.bak}/.dockerignore (100%) rename {backend => backend.bak}/.env.example (100%) rename {backend => backend.bak}/Dockerfile (100%) rename {backend => backend.bak}/README.md (100%) rename {backend => backend.bak}/app/core/__init__.py (100%) rename {backend => backend.bak}/app/core/auth.py (100%) rename {backend => backend.bak}/app/core/database.py (100%) rename {backend => backend.bak}/app/core/error_handler.py (100%) rename {backend => backend.bak}/app/core/logging_config.py (100%) rename {backend => backend.bak}/app/main.py (100%) rename {backend => backend.bak}/app/middleware/logging.py (100%) rename {backend => backend.bak}/app/models/__init__.py (100%) rename {backend => backend.bak}/app/models/models.py (100%) rename {backend => backend.bak}/app/routers/__init__.py (100%) rename {backend => backend.bak}/app/routers/auth.py (100%) rename {backend => backend.bak}/app/routers/collections.py (100%) rename {backend => backend.bak}/app/routers/ocr.py (100%) rename {backend => backend.bak}/app/routers/operations.py (100%) rename {backend => backend.bak}/app/routers/users.py (100%) rename {backend => backend.bak}/app/schemas/__init__.py (100%) rename {backend => backend.bak}/app/schemas/schemas.py (100%) rename {backend => backend.bak}/app/services/oss.py (100%) rename {backend => backend.bak}/app/services/sms.py (100%) rename {backend => backend.bak}/requirements.txt (100%) rename {backend => backend.bak}/uploads/.gitkeep (100%) create mode 100644 backend_src/.dockerignore create mode 100644 backend_src/.env.example create mode 100644 backend_src/Dockerfile create mode 100644 backend_src/README.md create mode 100644 backend_src/app/core/__init__.py create mode 100644 backend_src/app/core/auth.py create mode 100644 backend_src/app/core/database.py create mode 100644 backend_src/app/core/error_handler.py create mode 100644 backend_src/app/core/logging_config.py create mode 100644 backend_src/app/main.py create mode 100644 backend_src/app/middleware/logging.py create mode 100644 backend_src/app/models/__init__.py create mode 100644 backend_src/app/models/models.py create mode 100644 backend_src/app/routers/__init__.py create mode 100644 backend_src/app/routers/auth.py create mode 100644 backend_src/app/routers/collections.py create mode 100644 backend_src/app/routers/ocr.py create mode 100644 backend_src/app/routers/operations.py create mode 100644 backend_src/app/routers/users.py create mode 100644 backend_src/app/schemas/__init__.py create mode 100644 backend_src/app/schemas/schemas.py create mode 100644 backend_src/app/services/oss.py create mode 100644 backend_src/app/services/sms.py create mode 100644 backend_src/requirements.txt create mode 100644 backend_src/uploads/.gitkeep create mode 100644 config_src/VERSION create mode 100644 config_src/docker-compose-test.yml create mode 100644 config_src/docker-compose.yml create mode 100644 config_src/nginx.conf rename frontend/{src => frontend_src}/App.jsx (100%) rename frontend/{src => frontend_src}/config/version.js (100%) rename frontend/{src => frontend_src}/index.css (100%) rename frontend/{src => frontend_src}/main.jsx (100%) rename frontend/{src => frontend_src}/pages/Add.jsx (100%) rename frontend/{src => frontend_src}/pages/Admin.jsx (100%) rename frontend/{src => frontend_src}/pages/BatchMode.jsx (100%) rename frontend/{src => frontend_src}/pages/Detail.jsx (100%) rename frontend/{src => frontend_src}/pages/Edit.jsx (100%) rename frontend/{src => frontend_src}/pages/Edit.jsx.dual_column_bak (100%) rename frontend/{src => frontend_src}/pages/Home.jsx (100%) create mode 100644 frontend/frontend_src/pages/List.jsx rename frontend/{src => frontend_src}/pages/Login.jsx (100%) rename frontend/{src => frontend_src}/pages/OCR.jsx (100%) rename frontend/{src => frontend_src}/pages/Settings.jsx (100%) create mode 100644 frontend/frontend_src/pages/Stats.jsx rename frontend/{src => frontend_src}/utils/api.js (100%) rename frontend/{src => frontend_src}/utils/errorCodes.js (100%) create mode 100644 frontend/src.bak/App.jsx create mode 100644 frontend/src.bak/config/version.js create mode 100644 frontend/src.bak/index.css create mode 100644 frontend/src.bak/main.jsx create mode 100644 frontend/src.bak/pages/Add.jsx create mode 100644 frontend/src.bak/pages/Admin.jsx create mode 100644 frontend/src.bak/pages/BatchMode.jsx create mode 100644 frontend/src.bak/pages/Detail.jsx create mode 100644 frontend/src.bak/pages/Edit.jsx create mode 100644 frontend/src.bak/pages/Edit.jsx.dual_column_bak create mode 100644 frontend/src.bak/pages/Home.jsx rename frontend/{src => src.bak}/pages/List.jsx (100%) create mode 100644 frontend/src.bak/pages/Login.jsx create mode 100644 frontend/src.bak/pages/OCR.jsx create mode 100644 frontend/src.bak/pages/Settings.jsx rename frontend/{src => src.bak}/pages/Stats.jsx (100%) create mode 100644 frontend/src.bak/utils/api.js create mode 100644 frontend/src.bak/utils/errorCodes.js diff --git a/backend/.dockerignore b/backend.bak/.dockerignore similarity index 100% rename from backend/.dockerignore rename to backend.bak/.dockerignore diff --git a/backend/.env.example b/backend.bak/.env.example similarity index 100% rename from backend/.env.example rename to backend.bak/.env.example diff --git a/backend/Dockerfile b/backend.bak/Dockerfile similarity index 100% rename from backend/Dockerfile rename to backend.bak/Dockerfile diff --git a/backend/README.md b/backend.bak/README.md similarity index 100% rename from backend/README.md rename to backend.bak/README.md diff --git a/backend/app/core/__init__.py b/backend.bak/app/core/__init__.py similarity index 100% rename from backend/app/core/__init__.py rename to backend.bak/app/core/__init__.py diff --git a/backend/app/core/auth.py b/backend.bak/app/core/auth.py similarity index 100% rename from backend/app/core/auth.py rename to backend.bak/app/core/auth.py diff --git a/backend/app/core/database.py b/backend.bak/app/core/database.py similarity index 100% rename from backend/app/core/database.py rename to backend.bak/app/core/database.py diff --git a/backend/app/core/error_handler.py b/backend.bak/app/core/error_handler.py similarity index 100% rename from backend/app/core/error_handler.py rename to backend.bak/app/core/error_handler.py diff --git a/backend/app/core/logging_config.py b/backend.bak/app/core/logging_config.py similarity index 100% rename from backend/app/core/logging_config.py rename to backend.bak/app/core/logging_config.py diff --git a/backend/app/main.py b/backend.bak/app/main.py similarity index 100% rename from backend/app/main.py rename to backend.bak/app/main.py diff --git a/backend/app/middleware/logging.py b/backend.bak/app/middleware/logging.py similarity index 100% rename from backend/app/middleware/logging.py rename to backend.bak/app/middleware/logging.py diff --git a/backend/app/models/__init__.py b/backend.bak/app/models/__init__.py similarity index 100% rename from backend/app/models/__init__.py rename to backend.bak/app/models/__init__.py diff --git a/backend/app/models/models.py b/backend.bak/app/models/models.py similarity index 100% rename from backend/app/models/models.py rename to backend.bak/app/models/models.py diff --git a/backend/app/routers/__init__.py b/backend.bak/app/routers/__init__.py similarity index 100% rename from backend/app/routers/__init__.py rename to backend.bak/app/routers/__init__.py diff --git a/backend/app/routers/auth.py b/backend.bak/app/routers/auth.py similarity index 100% rename from backend/app/routers/auth.py rename to backend.bak/app/routers/auth.py diff --git a/backend/app/routers/collections.py b/backend.bak/app/routers/collections.py similarity index 100% rename from backend/app/routers/collections.py rename to backend.bak/app/routers/collections.py diff --git a/backend/app/routers/ocr.py b/backend.bak/app/routers/ocr.py similarity index 100% rename from backend/app/routers/ocr.py rename to backend.bak/app/routers/ocr.py diff --git a/backend/app/routers/operations.py b/backend.bak/app/routers/operations.py similarity index 100% rename from backend/app/routers/operations.py rename to backend.bak/app/routers/operations.py diff --git a/backend/app/routers/users.py b/backend.bak/app/routers/users.py similarity index 100% rename from backend/app/routers/users.py rename to backend.bak/app/routers/users.py diff --git a/backend/app/schemas/__init__.py b/backend.bak/app/schemas/__init__.py similarity index 100% rename from backend/app/schemas/__init__.py rename to backend.bak/app/schemas/__init__.py diff --git a/backend/app/schemas/schemas.py b/backend.bak/app/schemas/schemas.py similarity index 100% rename from backend/app/schemas/schemas.py rename to backend.bak/app/schemas/schemas.py diff --git a/backend/app/services/oss.py b/backend.bak/app/services/oss.py similarity index 100% rename from backend/app/services/oss.py rename to backend.bak/app/services/oss.py diff --git a/backend/app/services/sms.py b/backend.bak/app/services/sms.py similarity index 100% rename from backend/app/services/sms.py rename to backend.bak/app/services/sms.py diff --git a/backend/requirements.txt b/backend.bak/requirements.txt similarity index 100% rename from backend/requirements.txt rename to backend.bak/requirements.txt diff --git a/backend/uploads/.gitkeep b/backend.bak/uploads/.gitkeep similarity index 100% rename from backend/uploads/.gitkeep rename to backend.bak/uploads/.gitkeep diff --git a/backend_src/.dockerignore b/backend_src/.dockerignore new file mode 100644 index 0000000..73f416f --- /dev/null +++ b/backend_src/.dockerignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +.git +.env +uploads/* +!uploads/.gitkeep +logs/* +*.log diff --git a/backend_src/.env.example b/backend_src/.env.example new file mode 100644 index 0000000..9f5443e --- /dev/null +++ b/backend_src/.env.example @@ -0,0 +1,26 @@ +# 数据库配置 +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/zodiac + +# JWT配置 +SECRET_KEY=your-production-secret-key-change-this +ACCESS_TOKEN_EXPIRE_MINUTES=60 + +# 阿里云 DashScope OCR API(必须配置!) +DASHSCOPE_API_KEY=sk-9389024a37da4f7bb455ac9a6b28776f + +# 阿里云 OSS配置 +OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG +OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1 +OSS_BUCKET_NAME=jiachenlong-oss +OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com +OSS_PUBLIC_URL=https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com + +# 阿里云短信配置(必须配置!) +SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE +SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1 +SMS_SIGN_NAME=苏州算力 +SMS_TEMPLATE_CODE=SMS_501590956 + +# 服务配置 +PORT=3000 +HOST=0.0.0.0 diff --git a/backend_src/Dockerfile b/backend_src/Dockerfile new file mode 100644 index 0000000..f4c2a67 --- /dev/null +++ b/backend_src/Dockerfile @@ -0,0 +1,25 @@ +# FastAPI 后端 Docker 镜像 +FROM python:3.12-slim + +WORKDIR /app + +# 安装系统依赖 +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# 安装 Python 依赖 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 复制应用代码 +COPY . . + +# 创建上传目录 +RUN mkdir -p uploads + +# 暴露端口 +EXPOSE 3000 + +# 启动命令 +CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3000"] diff --git a/backend_src/README.md b/backend_src/README.md new file mode 100644 index 0000000..834be5c --- /dev/null +++ b/backend_src/README.md @@ -0,0 +1,42 @@ +# 后端服务 - FastAPI + +## 启动方式 + +### 开发环境 + +```bash +# 安装依赖 +pip install -r requirements.txt + +# 启动服务 +python -m uvicorn app.main:app --port 3000 --host 0.0.0.0 --reload +``` + +### 生产环境 + +```bash +# 后台运行 +nohup python -m uvicorn app.main:app --port 3000 --host 0.0.0.0 > /tmp/uvicorn.log 2>&1 & + +# 或使用 systemd +sudo systemctl start zodiac-backend +``` + +## 配置说明 + +编辑 `.env` 文件: + +```ini +# 数据库 +DATABASE_URL=postgresql://postgres:密码@localhost:5432/zodiac + +# JWT +SECRET_KEY=你的密钥 + +# OCR API +DASHSCOPE_API_KEY=sk-你的密钥 +``` + +## API 文档 + +启动后访问:http://localhost:3000/docs diff --git a/backend_src/app/core/__init__.py b/backend_src/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend_src/app/core/auth.py b/backend_src/app/core/auth.py new file mode 100644 index 0000000..3702b7e --- /dev/null +++ b/backend_src/app/core/auth.py @@ -0,0 +1,95 @@ +# 认证模块 +import os +import bcrypt +from datetime import datetime, timedelta +from typing import Optional +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt +from sqlalchemy.orm import Session +from app.core.database import SessionLocal +from app.models.models import User + +# 配置 +SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")) + +# HTTP Bearer 认证 +security = HTTPBearer(auto_error=False) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证密码""" + try: + return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) + except Exception: + return False + + +def get_password_hash(password: str) -> str: + """生成密码哈希""" + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """创建访问令牌""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def decode_access_token(token: str) -> Optional[dict]: + """解码访问令牌""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + return None + + +def get_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: Session = Depends(lambda: SessionLocal()) +) -> User: + """获取当前用户""" + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未提供认证信息", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + payload = decode_access_token(token) + + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的令牌", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id: str = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的令牌", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = db.query(User).filter(User.f99_90_id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user diff --git a/backend_src/app/core/database.py b/backend_src/app/core/database.py new file mode 100644 index 0000000..3690a30 --- /dev/null +++ b/backend_src/app/core/database.py @@ -0,0 +1,31 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://postgres:postgres@localhost:5432/zodiac" +) + +engine = create_engine( + DATABASE_URL, + pool_pre_ping=True, + pool_size=10, + max_overflow=20, + pool_recycle=3600, + pool_timeout=30, + echo=False +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def get_db(): + db = SessionLocal(expire_on_commit=False) + try: + yield db + finally: + db.close() diff --git a/backend_src/app/core/error_handler.py b/backend_src/app/core/error_handler.py new file mode 100644 index 0000000..c3d29ca --- /dev/null +++ b/backend_src/app/core/error_handler.py @@ -0,0 +1,174 @@ +# 统一错误处理 +from fastapi import FastAPI, Request, HTTPException, status +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from pydantic import ValidationError +from app.core.logging_config import logger + +# 错误码定义 +ERROR_CODES = { + # 认证错误 (10-19) + 401: "E00010", # 未授权 + 403: "E00014", # 禁止访问 + + # 验证错误 (20-29) + 400: "E00000", # 请求错误 + 422: "E00000", # 验证错误 + + # 资源错误 (30-39) + 404: "E00033", # 资源不存在 + + # 服务器错误 (50-59) + 500: "E00003", # 服务器内部错误 +} + +# 错误信息映射 +ERROR_MESSAGES = { + "E00010": "未登录或登录已过期", + "E00011": "用户名或密码错误", + "E00012": "验证码错误", + "E00014": "无权访问此资源", + "E00015": "令牌无效或已过期", + "E00020": "请输入用户名和密码", + "E00021": "用户名至少 3 个字符", + "E00022": "密码至少 6 个字符", + "E00023": "用户名已存在", + "E00024": "邮箱已被注册", + "E00030": "藏品名称不能为空", + "E00031": "藏品名称至少 2 个字符", + "E00032": "藏品分类不能为空", + "E00033": "藏品不存在", + "E00034": "禁止重复:此冠字号已存在", + "E00035": "成本价格必须>=0", + "E00036": "目标价格必须>=0", + "E00037": "发行年份必须是 4 位数字", + "E00040": "请选择图片文件", + "E00041": "图片尺寸太小,无法识别", + "E00042": "OCR 识别失败,请重试", + "E00050": "仅管理员可访问", + "E00051": "用户不存在", + "E00000": "请求失败", + "E00001": "网络连接失败", + "E00003": "服务器内部错误", +} + +def setup_error_handlers(app: FastAPI): + """设置全局错误处理器""" + + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException): + """处理 HTTP 异常""" + # 从 detail 中提取错误码 + detail = exc.detail + error_code = ERROR_CODES.get(exc.status_code, "E00000") + + # 如果 detail 已经包含错误码,直接使用 + if isinstance(detail, str) and detail.startswith("E"): + parts = detail.split(":", 1) + error_code = parts[0] + message = parts[1].strip() if len(parts) > 1 else ERROR_MESSAGES.get(error_code, detail) + else: + message = ERROR_MESSAGES.get(error_code, detail if isinstance(detail, str) else "请求失败") + + return JSONResponse( + status_code=exc.status_code, + content={ + "error": { + "code": error_code, + "message": message, + "status": exc.status_code + } + } + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError): + """处理请求验证错误""" + errors = exc.errors() + if errors: + error = errors[0] + field = ".".join(str(x) for x in error.get("loc", [])) + msg = error.get("msg", "验证失败") + + # 根据字段和消息匹配错误码 + # 先检查请求路径,区分用户接口和藏品接口 + path = request.url.path + + if "cost_price" in field or "价格" in msg: + error_code = "E00035" + message = "成本价格必须>=0" + elif "target_price" in field: + error_code = "E00036" + message = "目标价格必须>=0" + elif "issue_year" in field or "年份" in msg: + error_code = "E00037" + message = "发行年份必须是 4 位数字" + elif "name" in field: + # 根据路径区分用户 name 和藏品 name + if "/auth/" in path or "/users/" in path or "/admin/users/" in path: + error_code = "E00021" + message = "用户名至少 3 个字符" + else: + error_code = "E00031" + message = "藏品名称至少 2 个字符" + elif "category" in field: + error_code = "E00032" + message = "藏品分类不能为空" + else: + error_code = "E00000" + message = f"{field}: {msg}" + + return JSONResponse( + status_code=422, + content={ + "error": { + "code": error_code, + "message": message, + "status": 422, + "field": field + } + } + ) + + return JSONResponse( + status_code=422, + content={ + "error": { + "code": "E00000", + "message": "验证失败", + "status": 422 + } + } + ) + + @app.exception_handler(404) + async def not_found_handler(request: Request, exc: Exception): + """处理 404 错误""" + return JSONResponse( + status_code=404, + content={ + "error": { + "code": "E00033", + "message": "接口不存在", + "status": 404 + } + } + ) + + @app.exception_handler(Exception) + async def general_exception_handler(request: Request, exc: Exception): + """处理未捕获的异常""" + import traceback + error_trace = traceback.format_exc() + logger.error(f"未捕获异常:{str(exc)}\n{error_trace}") + + return JSONResponse( + status_code=500, + content={ + "error": { + "code": "E00003", + "message": "服务器内部错误", + "status": 500 + } + } + ) diff --git a/backend_src/app/core/logging_config.py b/backend_src/app/core/logging_config.py new file mode 100644 index 0000000..d5b2960 --- /dev/null +++ b/backend_src/app/core/logging_config.py @@ -0,0 +1,113 @@ +# 日志系统配置 +import logging +import json +import os +from datetime import datetime +from typing import Optional +import uuid +from logging.handlers import RotatingFileHandler + +class JSONFormatter(logging.Formatter): + """JSON 格式日志处理器""" + + def format(self, record): + log_data = { + 'timestamp': datetime.utcnow().isoformat() + 'Z', + 'level': record.levelname, + 'logger': record.name, + 'message': record.getMessage(), + 'trace_id': getattr(record, 'trace_id', str(uuid.uuid4())), + 'user_id': getattr(record, 'user_id', None), + 'request_id': getattr(record, 'request_id', str(uuid.uuid4())), + 'ip_address': getattr(record, 'ip_address', None), + 'duration_ms': getattr(record, 'duration_ms', None), + } + + # 添加额外字段 + if hasattr(record, 'data'): + log_data['data'] = record.data + + # 添加异常信息 + if record.exc_info: + log_data['exception'] = self.formatException(record.exc_info) + + return json.dumps(log_data, ensure_ascii=False, default=str) + + +def setup_logging( + log_file: str = 'logs/app.log', + level: str = 'INFO', + max_bytes: int = 10*1024*1024, # 10MB + backup_count: int = 5 +): + """配置日志系统""" + + # 创建日志目录 + os.makedirs(os.path.dirname(log_file), exist_ok=True) + + # 根日志器 + logger = logging.getLogger() + logger.setLevel(level) + + # 清空现有处理器 + logger.handlers.clear() + + # 文件处理器(带轮转) + file_handler = RotatingFileHandler( + log_file, + maxBytes=max_bytes, + backupCount=backup_count, + encoding='utf-8' + ) + file_handler.setFormatter(JSONFormatter()) + logger.addHandler(file_handler) + + # 控制台处理器(仅开发环境) + if os.getenv('ENV', 'development') == 'development': + console_handler = logging.StreamHandler() + console_handler.setFormatter(JSONFormatter()) + logger.addHandler(console_handler) + + return logger + + +# 创建日志器实例 +logger = setup_logging() + + +# 日志装饰器 +def log_operation(operation_name: str): + """记录操作日志的装饰器""" + def decorator(func): + import functools + @functools.wraps(func) + def wrapper(*args, **kwargs): + import time + start_time = time.time() + + try: + result = func(*args, **kwargs) + duration = (time.time() - start_time) * 1000 + + logger.info( + f"{operation_name} 成功", + extra={ + 'duration_ms': duration, + 'data': {'function': func.__name__} + } + ) + return result + + except Exception as e: + duration = (time.time() - start_time) * 1000 + logger.error( + f"{operation_name} 失败:{str(e)}", + extra={ + 'duration_ms': duration, + 'data': {'function': func.__name__, 'error': str(e)} + }, + exc_info=True + ) + raise + return wrapper + return decorator diff --git a/backend_src/app/main.py b/backend_src/app/main.py new file mode 100644 index 0000000..3dc746d --- /dev/null +++ b/backend_src/app/main.py @@ -0,0 +1,103 @@ +# FastAPI 应用入口 +import os +from pathlib import Path +from dotenv import load_dotenv +load_dotenv() + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from app.core.database import engine, Base +from app.core.logging_config import logger, setup_logging +from app.core.error_handler import setup_error_handlers +from app.middleware.logging import logging_middleware +from app.routers import auth, collections, operations +from app.routers import ocr as ocr_router +from app.routers import users as users_router + +# 版本信息 - 从 config/VERSION 文件读取 +def get_version(): + """从 config/VERSION 文件读取版本号""" + try: + version_file = Path(__file__).parent.parent.parent / "config" / "VERSION" + if version_file.exists(): + with open(version_file, 'r', encoding='utf-8') as f: + for line in f: + if line.startswith('VERSION='): + return line.strip().split('=', 1)[1] + except Exception as e: + logger.error(f"读取 VERSION 文件失败:{e}") + return "0.0.0" # 默认版本号 + +__version__ = get_version() +__app_name__ = "甲辰收藏系统 FastAPI 后端" + +# 启动时创建数据库表 +Base.metadata.create_all(bind=engine) + +# 初始化日志系统 +setup_logging() +logger.info(f"{__app_name__} v{__version__} 启动成功") + +# 创建 FastAPI 应用 +app = FastAPI( + title=__app_name__, + version=__version__, + description="生肖纪念钞收藏管理系统后端 API" +) + +# 设置全局错误处理器 +setup_error_handlers(app) + +# CORS 配置 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 生产环境应该限制域名 + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 挂载静态文件目录(图片上传和项目静态资源) +uploads_dir = "uploads" +os.makedirs(uploads_dir, exist_ok=True) +app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads") + +# 挂载项目静态资源目录(可选,生产环境建议用 Nginx) +# static_dir = Path(__file__).parent.parent.parent / "static" +# if static_dir.exists(): +# app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + +# 添加日志中间件 +app.middleware("http")(logging_middleware) + +# 注册路由 +app.include_router(auth.router) +app.include_router(collections.router) +app.include_router(operations.router) +app.include_router(ocr_router.router) # OCR 识别 +app.include_router(users_router.router) # 当前用户接口 +app.include_router(users_router.admin_router) # 管理员用户管理 + + +@app.get("/") +def root(): + """根路径""" + return { + "name": __app_name__, + "version": __version__, + "status": "running" + } + + +@app.get("/health") +def health_check(): + """健康检查""" + return {"status": "healthy"} + + +if __name__ == "__main__": + import uvicorn + port = int(os.getenv("PORT", "3000")) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/backend_src/app/middleware/logging.py b/backend_src/app/middleware/logging.py new file mode 100644 index 0000000..684a55a --- /dev/null +++ b/backend_src/app/middleware/logging.py @@ -0,0 +1,88 @@ +# 请求日志中间件 - 优化版 +import time +import uuid +from fastapi import Request, Response +from app.core.logging_config import logger + + +async def logging_middleware(request: Request, call_next): + """记录所有 API 请求的日志 - 优化版""" + + # 生成请求 ID + request_id = str(uuid.uuid4()) + start_time = time.time() + + # 获取用户信息(如果已登录) + user_id = None + try: + auth_header = request.headers.get('Authorization', '') + if auth_header.startswith('Bearer '): + user_id = "authenticated" + except Exception as e: + # 静默失败,不影响主流程 + pass + + # 执行请求 + response_status = 500 + try: + response = await call_next(request) + response_status = response.status_code + except Exception as e: + duration = (time.time() - start_time) * 1000 + try: + logger.error( + f"API 请求异常:{request.method} {request.url.path}", + extra={ + 'request_id': request_id, + 'user_id': user_id, + 'ip_address': request.client.host if request.client else None, + 'duration_ms': duration, + 'data': { + 'method': request.method, + 'path': request.url.path, + 'query': str(request.query_params), + 'error': str(e) + } + }, + exc_info=True + ) + except: + pass # 日志记录失败不影响主流程 + raise + + # 记录响应 + duration = (time.time() - start_time) * 1000 + + try: + log_level = 'INFO' + if response_status >= 500: + log_level = 'ERROR' + elif response_status >= 400: + log_level = 'WARNING' + + getattr(logger, log_level)( + f"API 请求完成:{request.method} {request.url.path}", + extra={ + 'request_id': request_id, + 'user_id': user_id, + 'ip_address': request.client.host if request.client else None, + 'duration_ms': duration, + 'data': { + 'method': request.method, + 'path': request.url.path, + 'query': str(request.query_params), + 'status_code': response_status + } + } + ) + except Exception as e: + # 日志记录失败不影响主流程 + pass + + # 在响应头中添加请求 ID + try: + response.headers['X-Request-ID'] = request_id + except: + pass + + return response diff --git a/backend_src/app/models/__init__.py b/backend_src/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend_src/app/models/models.py b/backend_src/app/models/models.py new file mode 100644 index 0000000..4d96a70 --- /dev/null +++ b/backend_src/app/models/models.py @@ -0,0 +1,132 @@ +# 数据库模型 - 使用字段编码 +from sqlalchemy import Column, String, Float, Boolean, DateTime, Integer, Text, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.database import Base +import uuid + + +def generate_uuid(): + """生成 UUID 字符串""" + return str(uuid.uuid4()) + + +class User(Base): + __tablename__ = "users" + + # f99 系统字段 + f99_90_id = Column(String(36), primary_key=True, default=generate_uuid) + user_code = Column(String(10), unique=True, nullable=True, index=True) # 用户编码 + f99_91_user_id = Column(String(36), unique=True, nullable=False, index=True) + f01_01_name = Column(String(255), unique=True, nullable=False, index=True) # username + email = Column(String(255), unique=True, nullable=True, index=True) + phone = Column(String(50), nullable=True) + avatar = Column(String(500), nullable=True) + address = Column(String(500), nullable=True) + bio = Column(Text, nullable=True) + password = Column(String(255), nullable=False) + role = Column(String(50), default="user") + # last_login = Column(DateTime(timezone=True), nullable=True) + f99_92_created_at = Column(DateTime(timezone=True), server_default=func.now()) + f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan") + operations = relationship("Operation", back_populates="user", cascade="all, delete-orphan") + custom_fields = relationship("CustomField", back_populates="user", cascade="all, delete-orphan") + + +class Collection(Base): + __tablename__ = "collections" + + # f99 系统字段 + f99_90_id = Column(String(36), primary_key=True, default=generate_uuid) + f99_91_user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + f99_92_created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # f01 基本信息 + f01_01_name = Column(String(255), nullable=False) + f01_02_code = Column(String(50), nullable=True, index=True) + f01_03_category = Column(String(100), nullable=False, index=True) + f01_04_status = Column(String(50), default="in_collection", index=True) + f01_05_remark = Column(Text, nullable=True) + + # f02 详细字段 + f02_10_prefix_serial = Column(String(50), nullable=True, index=True) + f02_11_version = Column(String(100), nullable=True, index=True) + f02_12_packaging = Column(String(100), nullable=True, index=True) + f02_13_rarity = Column(String(50), nullable=True, index=True) # 珍惜度 + f02_14_number_category = Column(String(20), nullable=True, index=True) # 号码分类 + + # f03 评级信息 + f03_20_is_graded = Column(Boolean, default=False, index=True) + f03_21_grading_company = Column(String(100), nullable=True, index=True) + f03_22_grading_score = Column(String(20), nullable=True, index=True) + f03_23_three_star = Column(Boolean, default=False, index=True) + + # f04 特殊信息 + f04_30_special_mark = Column(String(200), nullable=True, index=True) + f04_31_serial_feature = Column(String(100), nullable=True, index=True) + f04_32_issuer = Column(String(100), nullable=True, index=True) + f04_33_issue_year = Column(String(20), nullable=True, index=True) + f04_34_material = Column(String(50), nullable=True) + f04_35_denomination = Column(String(20), nullable=True) + f04_36_issue_quantity = Column(String(50), nullable=True) + + # f05 价格信息 + f05_40_cost_price = Column(Float, nullable=True, index=True) + f05_41_target_price = Column(Float, nullable=True, index=True) + f05_42_goal_price = Column(Float, nullable=True) + f05_43_repair_fee = Column(Float, nullable=True) + f05_44_grading_fee = Column(Float, nullable=True) + + # f06 其他信息 + f06_50_purpose = Column(String(100), nullable=True) + + user = relationship("User", back_populates="collections") + images = relationship("CollectionImage", back_populates="collection", cascade="all, delete-orphan") + operations = relationship("Operation", back_populates="collection", cascade="all, delete-orphan") + + +class CollectionImage(Base): + __tablename__ = "collection_images" + + id = Column(String(36), primary_key=True, default=generate_uuid) + collection_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + filename = Column(String(255), nullable=False) + original_name = Column(String(255), nullable=True) + path = Column(String(500), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + collection = relationship("Collection", back_populates="images") + + +class Operation(Base): + __tablename__ = "operations" + + f99_90_id = Column(String(36), primary_key=True, default=generate_uuid) + f99_91_user_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + f99_92_user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + type = Column(String(50), nullable=False, index=True) + price = Column(Float, nullable=True) + note = Column(Text, nullable=True) + f99_93_created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + + collection = relationship("Collection", back_populates="operations") + user = relationship("User", back_populates="operations") + + +class CustomField(Base): + __tablename__ = "custom_fields" + + id = Column(String(36), primary_key=True, default=generate_uuid) + user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + name = Column(String(100), nullable=False) + field_type = Column(String(50), default="text") + options = Column(Text, nullable=True) + required = Column(Boolean, default=False) + visible = Column(Boolean, default=True) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + user = relationship("User", back_populates="custom_fields") diff --git a/backend_src/app/routers/__init__.py b/backend_src/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend_src/app/routers/auth.py b/backend_src/app/routers/auth.py new file mode 100644 index 0000000..6d49d97 --- /dev/null +++ b/backend_src/app/routers/auth.py @@ -0,0 +1,182 @@ +# 认证路由 - 使用字段编码 +from fastapi import APIRouter, Depends, HTTPException, status, Body +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.auth import verify_password, create_access_token, get_password_hash, get_current_user +from app.models.models import User +from app.schemas.schemas import Token, UserCreate, UserResponse + +router = APIRouter(prefix="/api/auth", tags=["认证"]) + + + + +def generate_user_code(db): + """生成用户编码,从0001开始""" + # 查找最大的user_code + max_code = db.query(User.user_code).filter(User.user_code != None).order_by(User.user_code.desc()).first() + if max_code and max_code[0]: + try: + num = int(max_code[0]) + 1 + return f"{num:04d}" + except: + pass + return "0001" + +@router.post("/register", response_model=UserResponse) +def register(user_data: UserCreate, db: Session = Depends(get_db)): + """用户注册""" + # 检查用户名是否已存在 + existing_user = db.query(User).filter(User.f01_01_name == user_data.f01_01_name).first() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="f01_01_name: 用户名已存在" + ) + + # 检查邮箱是否已存在 + if user_data.email: + existing_email = db.query(User).filter(User.email == user_data.email).first() + if existing_email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="邮箱已被注册" + ) + + # 创建用户 + import uuid + hashed_password = get_password_hash(user_data.password) + user = User( + f99_90_id=str(uuid.uuid4()), + f99_91_user_id=str(uuid.uuid4()), + user_code=generate_user_code(db), + f01_01_name=user_data.f01_01_name, + email=user_data.email, + phone=user_data.phone, + avatar=user_data.avatar, + address=user_data.address, + bio=user_data.bio, + password=hashed_password, + role="user" + ) + + db.add(user) + db.commit() + db.refresh(user) + + return user + + +@router.post("/login", response_model=Token) +def login( + form_data: OAuth2PasswordRequestForm = Depends(), + db: Session = Depends(get_db) +): + """用户登录""" + # 查找用户 + user = db.query(User).filter(User.f01_01_name == form_data.username).first() + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="E00011: 用户名或密码错误", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # 验证密码 + if not verify_password(form_data.password, user.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="E00011: 用户名或密码错误", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # 生成 token + access_token = create_access_token(data={"sub": user.f99_90_id}) + + return { + "access_token": access_token, + "token_type": "bearer" + } + + +@router.get("/me", response_model=UserResponse) +def get_current_user_info( + current_user: User = Depends(lambda: None) +): + """获取当前用户信息""" + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="请使用正确的依赖注入" + ) + + +@router.post("/change-password") +def change_password( + old_password: str = Body(...), + new_password: str = Body(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """修改当前用户密码""" + from app.core.auth import verify_password, get_password_hash + + # 在当前session中重新查询用户 + user = db.query(User).filter(User.f99_90_id == current_user.f99_90_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + # 验证旧密码 + if not verify_password(old_password, user.password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="当前密码错误" + ) + + # 更新密码 + user.password = get_password_hash(new_password) + db.commit() + + return {"message": "密码修改成功"} + + +# ============ 短信验证码接口 ============ + +@router.post("/send-verification-code") +def send_verification_code( + phone: str = Body(..., min_length=11, max_length=11), + purpose: str = Body("register") # register | login | reset_password +): + """发送短信验证码""" + from app.services.sms import send_verification_code as send_sms + + # 验证手机号格式 + if not phone.startswith("1") or len(phone) != 11: + return {"success": False, "message": "手机号格式不正确"} + + result = send_sms(phone) + + if result["success"]: + return { + "success": True, + "message": f"验证码已发送到 {phone[:3]}****{phone[7:]}", + "expire": result.get("expire", 300) + } + else: + return result + + +@router.post("/verify-code") +def verify_code( + phone: str = Body(...), + code: str = Body(..., min_length=6, max_length=6) +): + """验证短信验证码(仅验证,不执行后续操作)""" + from app.services.sms import verify_code as check_code + + is_valid = check_code(phone, code) + + if is_valid: + return {"success": True, "message": "验证成功"} + else: + return {"success": False, "message": "验证码错误或已过期"} diff --git a/backend_src/app/routers/collections.py b/backend_src/app/routers/collections.py new file mode 100644 index 0000000..ea44f59 --- /dev/null +++ b/backend_src/app/routers/collections.py @@ -0,0 +1,725 @@ +# 藏品路由 - 使用字段编码 +import os +import uuid +import re +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File +from sqlalchemy import func, text +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.auth import get_current_user +from app.core.logging_config import logger +from app.models.models import User, Collection, CollectionImage, Operation +from app.schemas.schemas import ( + CollectionCreate, CollectionUpdate, CollectionResponse, + CollectionListResponse, CollectionImageResponse +) +from app.services.oss import upload_to_oss, get_oss_path, delete_from_oss, get_public_url + +router = APIRouter(prefix="/api/collections", tags=["藏品"]) + + +def to_camel_case(data: dict) -> dict: + """将字段编码转换为 camelCase 格式""" + if not data: + return data + + mapping = { + 'f99_90_id': 'id', + 'f99_91_user_id': 'userId', + 'f99_92_created_at': 'createdAt', + 'f99_93_updated_at': 'updatedAt', + 'f01_01_name': 'name', + 'f01_02_code': 'code', + 'f01_03_category': 'category', + 'f01_04_status': 'status', + 'f01_05_remark': 'remark', + 'f02_10_prefix_serial': 'prefixSerial', + 'f02_11_version': 'version', + 'f02_12_packaging': 'packaging', + 'f02_13_rarity': 'rarity', + 'f02_14_number_category': 'numberCategory', + 'f03_20_is_graded': 'isGraded', + 'f03_21_grading_company': 'gradingCompany', + 'f03_22_grading_score': 'gradingScore', + 'f03_23_three_star': 'threeStar', + 'f04_30_special_mark': 'specialMark', + 'f04_31_serial_feature': 'serialFeature', + 'f04_32_issuer': 'issuer', + 'f04_33_issue_year': 'issueYear', + 'f04_34_material': 'material', + 'f04_35_denomination': 'denomination', + 'f04_36_issue_quantity': 'issueQuantity', + 'f05_40_cost_price': 'costPrice', + 'f05_41_target_price': 'targetPrice', + 'f05_42_goal_price': 'goalPrice', + 'f05_43_repair_fee': 'repairFee', + 'f05_44_grading_fee': 'gradingFee', + 'f06_50_purpose': 'purpose', + } + + return {mapping.get(k, k): v for k, v in data.items()} + + +# 编码生成函数 +def generate_code(version: str, user_id: str, db: Session) -> str: + """自动生成藏品编号 - 按用户独立编码""" + import re + + # 查询当前用户的非空编码(不与其他用户混算) + user_codes = db.query(Collection.f01_02_code).filter( + Collection.f01_02_code.isnot(None), + Collection.f99_91_user_id == user_id + ).all() + + max_num = 0 + for (code,) in user_codes: + # 处理纯数字编码(支持4位和5位) + if re.match(r'^\d{4,5}$', code): + try: + num = int(code) + if num > max_num: + max_num = num + except (ValueError, TypeError): + pass + + # 当前用户最大号 +1 + next_num = max_num + 1 + + # 如果超过9999,使用5位;否则使用4位 + if next_num > 9999: + return str(next_num).zfill(5) + else: + return str(next_num).zfill(4) + + +@router.get("/next-code") +def get_next_code( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取下一个藏品编号""" + next_code = generate_code("2024 龙", current_user.f99_90_id, db) + return {"code": 200, "data": {"nextCode": next_code}} + + +@router.get("") +def get_collections( + category: Optional[str] = None, + status: Optional[str] = None, + search: Optional[str] = None, + specialMark: Optional[str] = Query(None, description="special mark filter"), + numberCategory: Optional[str] = Query(None, description="number category filter"), + gradingCompany: Optional[str] = Query(None, description="grading company filter"), + gradingScore: Optional[str] = Query(None, description="grading score filter"), + packaging: Optional[str] = Query(None, description="packaging filter"), + rarity: Optional[str] = Query(None, description="rarity filter"), + version: Optional[str] = Query(None, description="version filter"), + profitLoss: Optional[str] = Query(None, description="profit loss filter: profit or loss"), + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=500), + sortBy: str = Query('createdAt'), + sortOrder: str = Query('desc'), + all_users: bool = Query(False, description="return all users data for admin"), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取藏品列表""" + # admin 用户可以看到所有藏品,普通用户只能看到自己的 + # 如果指定 all_users=true,则返回所有用户藏品 + from sqlalchemy.orm import joinedload + + # 管理员默认查看全库,普通用户只看自己 + if current_user.role == "admin": + # 联表查询获取用户名 + query = db.query(Collection, User.f01_01_name.label('owner_name')).join( + User, Collection.f99_91_user_id == User.f99_90_id, isouter=True + ) + else: + query = db.query(Collection).filter(Collection.f99_91_user_id == current_user.f99_90_id) + + if category: + query = query.filter(Collection.f01_03_category == category) + if status: + query = query.filter(Collection.f01_04_status == status) + if specialMark: + query = query.filter(Collection.f04_30_special_mark == specialMark) + if gradingCompany: + query = query.filter(Collection.f03_21_grading_company.contains(gradingCompany)) + if gradingScore: + query = query.filter(Collection.f03_22_grading_score.contains(gradingScore)) + if numberCategory: + query = query.filter(Collection.f02_14_number_category.contains(numberCategory)) + if packaging: + query = query.filter(Collection.f02_12_packaging== packaging) + if rarity: + query = query.filter(Collection.f02_13_rarity.contains(rarity)) + if version: + query = query.filter(Collection.f02_11_version.contains(version)) + + # 盈亏筛选(只对已售藏品有效) + if profitLoss: + if profitLoss == 'profit': + # 盈利:售价 > 成本价 + query = query.filter( + Collection.f01_04_status == 'sold', + Collection.f05_42_goal_price > Collection.f05_40_cost_price + ) + elif profitLoss == 'loss': + # 亏损:售价 <= 成本价 + query = query.filter( + Collection.f01_04_status == 'sold', + Collection.f05_42_goal_price <= Collection.f05_40_cost_price + ) + + if search: + query = query.filter( + (Collection.f01_01_name.contains(search)) | + (Collection.f01_05_remark.contains(search)) + ) + + # 总数(应用筛选条件后的数量) + total = query.count() + + # 分页 + data = query.order_by(Collection.f99_92_created_at.desc()) \ + .offset((page - 1) * limit) \ + .limit(limit) \ + .all() + + # 转换为字典列表并转为 camelCase + data_list = [] + for item in data: + # 处理联表查询结果 + if current_user.role == "admin": + collection_item, owner_name = item + else: + collection_item = item + owner_name = None + + item_dict = { + 'f99_90_id': collection_item.f99_90_id, + 'f99_91_user_id': collection_item.f99_91_user_id, + 'owner_name': owner_name, # 所属用户名(仅管理员可见) + 'f01_01_name': collection_item.f01_01_name, + 'f01_02_code': collection_item.f01_02_code, + 'f01_03_category': collection_item.f01_03_category, + 'f01_04_status': collection_item.f01_04_status, + 'f01_05_remark': collection_item.f01_05_remark, + 'f02_10_prefix_serial': collection_item.f02_10_prefix_serial, + 'f02_11_version': collection_item.f02_11_version, + 'f02_12_packaging': collection_item.f02_12_packaging, + 'f02_13_rarity': collection_item.f02_13_rarity, + 'f02_14_number_category': collection_item.f02_14_number_category, + 'f03_20_is_graded': collection_item.f03_20_is_graded, + 'f03_21_grading_company': collection_item.f03_21_grading_company, + 'f03_22_grading_score': collection_item.f03_22_grading_score, + 'f03_23_three_star': collection_item.f03_23_three_star, + 'f04_30_special_mark': collection_item.f04_30_special_mark, + 'f04_31_serial_feature': collection_item.f04_31_serial_feature, + 'f04_32_issuer': collection_item.f04_32_issuer, + 'f04_33_issue_year': collection_item.f04_33_issue_year, + 'f04_34_material': collection_item.f04_34_material, + 'f04_35_denomination': collection_item.f04_35_denomination, + 'f04_36_issue_quantity': collection_item.f04_36_issue_quantity, + 'f05_40_cost_price': float(collection_item.f05_40_cost_price) if collection_item.f05_40_cost_price else None, + 'f05_41_target_price': float(collection_item.f05_41_target_price) if collection_item.f05_41_target_price else None, + 'f05_42_goal_price': float(collection_item.f05_42_goal_price) if collection_item.f05_42_goal_price else None, + 'f05_43_repair_fee': float(collection_item.f05_43_repair_fee) if collection_item.f05_43_repair_fee else None, + 'f05_44_grading_fee': float(collection_item.f05_44_grading_fee) if collection_item.f05_44_grading_fee else None, + 'f06_50_purpose': collection_item.f06_50_purpose, + 'f99_92_created_at': collection_item.f99_92_created_at.isoformat() if collection_item.f99_92_created_at else None, + 'images': [] + } + + # 加载图片数据 + from app.models.models import CollectionImage + images = db.query(CollectionImage).filter( + CollectionImage.collection_id == collection_item.f99_90_id + ).all() + + for img in images: + item_dict['images'].append({ + 'id': img.id, + 'filename': img.filename, + 'original_name': img.original_name, + 'path': img.path, + 'created_at': img.created_at.isoformat() if img.created_at else None + }) + + data_list.append(to_camel_case(item_dict)) + + return { + "data": data_list, + "pagination": { + "page": page, + "limit": limit, + "total": total, + "pages": (total + limit - 1) // limit + } + } + + +@router.get("/stats") +def get_stats( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取藏品统计""" + # 获取所有藏品 + if current_user.role == "admin": + all_collections = db.query(Collection).all() + else: + all_collections = db.query(Collection).filter( + Collection.f99_91_user_id == current_user.f99_90_id + ).all() + + # 总数 + total_count = len(all_collections) + + # 按分类统计 + from collections import Counter + by_category = Counter(c.f01_03_category for c in all_collections).items() + + # 按状态统计 + by_status = Counter(c.f01_04_status for c in all_collections).items() + + # 按是否评级统计 + by_graded = Counter(c.f03_20_is_graded for c in all_collections).items() + + # 新增:8 个分布统计 + by_packaging = Counter(c.f02_12_packaging for c in all_collections if c.f02_12_packaging).items() + by_rarity = Counter(c.f02_13_rarity for c in all_collections if c.f02_13_rarity).items() + by_version = Counter(c.f02_11_version for c in all_collections if c.f02_11_version).items() + by_grading_company = Counter(c.f03_21_grading_company for c in all_collections if c.f03_20_is_graded and c.f03_21_grading_company).items() + by_grading_score = Counter(c.f03_22_grading_score for c in all_collections if c.f03_20_is_graded and c.f03_22_grading_score).items() + by_special_mark = Counter(c.f04_30_special_mark for c in all_collections if c.f04_30_special_mark).items() + by_number_category = Counter(c.f02_14_number_category for c in all_collections if c.f02_14_number_category).items() + + # 总成本: SUM(cost_price + repair_fee + grading_fee) for ALL collections + total_cost = sum( + (c.f05_40_cost_price or 0) + (c.f05_43_repair_fee or 0) + (c.f05_44_grading_fee or 0) + for c in all_collections + ) + + # 预期利润: SUM(target_price - cost_price) for collections with target_price > 0 + expected_profit = sum( + (c.f05_41_target_price or 0) - (c.f05_40_cost_price or 0) + for c in all_collections + if c.f05_41_target_price and c.f05_41_target_price > 0 + ) + + # 已售商品:状态为 sold 且出售价 > 0 + sold_collections = [ + c for c in all_collections + if c.f01_04_status == 'sold' and c.f05_42_goal_price and c.f05_42_goal_price > 0 + ] + + # 总收入:SUM(出售价) for 已售商品(售价>0) + total_revenue = sum( + c.f05_42_goal_price or 0 + for c in sold_collections + ) + + # 总利润(已实现利润):SUM(出售价 - 成本价 - 修复费 - 评级费) for 已售商品 + # 单藏品总成本 = 成本价 + 修复费 + 评级费 + total_profit = sum( + (c.f05_42_goal_price or 0) - (c.f05_40_cost_price or 0) - (c.f05_43_repair_fee or 0) - (c.f05_44_grading_fee or 0) + for c in sold_collections + ) + + return { + "totalCount": total_count, + "byCategory": [{"category": c, "count": n} for c, n in by_category], + "byStatus": [{"status": s, "count": n} for s, n in by_status], + "byGrading": [{"isGraded": g, "count": n} for g, n in by_graded], + "byPackaging": [{"packaging": p, "count": n} for p, n in by_packaging], + "byRarity": [{"rarity": r, "count": n} for r, n in by_rarity], + "byVersion": [{"version": v, "count": n} for v, n in by_version], + "byGradingCompany": [{"company": c, "count": n} for c, n in by_grading_company], + "byGradingScore": [{"score": s, "count": n} for s, n in by_grading_score], + "bySpecialMark": [{"mark": m, "count": n} for m, n in by_special_mark], + "byNumberCategory": [{"numberCategory": n, "count": c} for n, c in by_number_category], + # 盈亏统计(只统计已售且有价格的藏品) + "byProfitLoss": [ + {"type": "profit", "label": "盈利", "count": sum(1 for c in sold_collections if c.f05_42_goal_price and c.f05_40_cost_price and c.f05_42_goal_price > c.f05_40_cost_price)}, + {"type": "loss", "label": "亏损", "count": sum(1 for c in sold_collections if c.f05_42_goal_price and c.f05_40_cost_price and c.f05_42_goal_price <= c.f05_40_cost_price)} + ], + "totalCost": total_cost, + "totalTarget": sum(c.f05_41_target_price or 0 for c in all_collections), + "expectedProfit": expected_profit, + "totalRevenue": total_revenue, + "totalProfit": total_profit + } + + +@router.get("/{collection_id}") +def get_collection( + collection_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取单个藏品详情""" + result = db.execute( + text("SELECT * FROM collections WHERE f99_90_id = :id"), + {"id": collection_id} + ).fetchone() + + if not result: + raise HTTPException(status_code=404, detail="E00033: 藏品不存在") + + collection = dict(result._mapping) + + # 非管理员只能查看自己的藏品 + if current_user.role != "admin" and collection.get('f99_91_user_id') != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="无权访问") + + result_dict = { + 'f99_90_id': collection.get('f99_90_id'), + 'f99_91_user_id': collection.get('f99_91_user_id'), + 'f01_01_name': collection.get('f01_01_name'), + 'f01_02_code': collection.get('f01_02_code'), + 'f01_03_category': collection.get('f01_03_category'), + 'f01_04_status': collection.get('f01_04_status'), + 'f01_05_remark': collection.get('f01_05_remark'), + 'f02_10_prefix_serial': collection.get('f02_10_prefix_serial'), + 'f02_11_version': collection.get('f02_11_version'), + 'f02_12_packaging': collection.get('f02_12_packaging'), + 'f02_13_rarity': collection.get('f02_13_rarity'), + 'f02_14_number_category': collection.get('f02_14_number_category'), + 'f03_20_is_graded': collection.get('f03_20_is_graded'), + 'f03_21_grading_company': collection.get('f03_21_grading_company'), + 'f03_22_grading_score': collection.get('f03_22_grading_score'), + 'f03_23_three_star': collection.get('f03_23_three_star'), + 'f04_30_special_mark': collection.get('f04_30_special_mark'), + 'f04_31_serial_feature': collection.get('f04_31_serial_feature'), + 'f04_32_issuer': collection.get('f04_32_issuer'), + 'f04_33_issue_year': collection.get('f04_33_issue_year'), + 'f04_34_material': collection.get('f04_34_material'), + 'f04_35_denomination': collection.get('f04_35_denomination'), + 'f04_36_issue_quantity': collection.get('f04_36_issue_quantity'), + 'f05_40_cost_price': float(collection.get('f05_40_cost_price')) if collection.get('f05_40_cost_price') else None, + 'f05_41_target_price': float(collection.get('f05_41_target_price')) if collection.get('f05_41_target_price') else None, + 'f05_42_goal_price': float(collection.get('f05_42_goal_price')) if collection.get('f05_42_goal_price') else None, + 'f05_43_repair_fee': float(collection.get('f05_43_repair_fee')) if collection.get('f05_43_repair_fee') else None, + 'f05_44_grading_fee': float(collection.get('f05_44_grading_fee')) if collection.get('f05_44_grading_fee') else None, + 'f06_50_purpose': collection.get('f06_50_purpose'), + 'f99_92_created_at': collection.get('f99_92_created_at').isoformat() if collection.get('f99_92_created_at') else None, + 'images': [] + } + + # 加载图片数据 + images = db.query(CollectionImage).filter( + CollectionImage.collection_id == collection_id + ).all() + + for img in images: + result_dict['images'].append({ + 'id': img.id, + 'filename': img.filename, + 'original_name': img.original_name, + 'path': img.path, + 'created_at': img.created_at.isoformat() if img.created_at else None + }) + + return to_camel_case(result_dict) + + +@router.post("") +def create_collection( + collection_data: CollectionCreate, + force: bool = False, # 是否强制保存(忽略重复警告) + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """创建藏品 - 支持冠字号查重""" + from app.core.logging_config import logger + + # 自动生成编码 + final_code = collection_data.f01_02_code or generate_code( + collection_data.f02_11_version or '2024 龙', + current_user.f99_90_id, + db + ) + + # 编号查重(如果提供了编号且不是强制保存) + if not force and final_code: + existing_code = db.query(Collection).filter( + Collection.f01_02_code == final_code, + Collection.f99_91_user_id == current_user.f99_90_id + ).first() + + if existing_code: + logger.warning(f"发现重复编号:{final_code}, 已存在藏品 ID: {existing_code.f99_90_id}") + return { + "error": { + "code": "DUPLICATE_CODE", + "message": f"藏品编号 {final_code} 已存在,请使用其他编号" + } + } + + # 冠字号查重(如果提供了冠字号且不是强制保存) + if not force and collection_data.f02_10_prefix_serial: + # 查询当前用户是否有相同冠字号的藏品 + existing = db.query(Collection).filter( + Collection.f02_10_prefix_serial == collection_data.f02_10_prefix_serial, + Collection.f99_91_user_id == current_user.f99_90_id + ).first() + + if existing: + logger.warning(f"发现重复冠字号:{collection_data.f02_10_prefix_serial}, 已存在藏品 ID: {existing.f99_90_id}") + # 返回警告信息,让前端询问用户是否继续 + return { + "warning": { + "code": "DUPLICATE_SERIAL", + "message": f"发现重复冠字号:{collection_data.f02_10_prefix_serial}", + "existing_collection": { + "id": existing.f99_90_id, + "name": existing.f01_01_name, + "code": existing.f01_02_code, + "prefix_serial": existing.f02_10_prefix_serial + } + }, + "data": { + "ask_continue": True + } + } + + collection = Collection( + f99_91_user_id=current_user.f99_90_id, + f01_01_name=collection_data.f01_01_name, + f01_02_code=final_code, + f01_03_category=collection_data.f01_03_category, + f01_04_status=collection_data.f01_04_status or "in_collection", + f01_05_remark=collection_data.f01_05_remark, + f02_10_prefix_serial=collection_data.f02_10_prefix_serial, + f02_11_version=collection_data.f02_11_version, + f02_12_packaging=collection_data.f02_12_packaging, + f02_13_rarity=collection_data.f02_13_rarity, + f02_14_number_category=collection_data.f02_14_number_category, + f03_20_is_graded=collection_data.f03_20_is_graded or False, + f03_21_grading_company=collection_data.f03_21_grading_company, + f03_22_grading_score=collection_data.f03_22_grading_score, + f03_23_three_star=collection_data.f03_23_three_star or False, + f04_30_special_mark=collection_data.f04_30_special_mark, + f04_31_serial_feature=collection_data.f04_31_serial_feature, + f04_32_issuer=collection_data.f04_32_issuer, + f04_33_issue_year=collection_data.f04_33_issue_year, + f04_34_material=collection_data.f04_34_material, + f04_35_denomination=collection_data.f04_35_denomination, + f04_36_issue_quantity=collection_data.f04_36_issue_quantity, + f05_40_cost_price=collection_data.f05_40_cost_price, + f05_41_target_price=collection_data.f05_41_target_price, + f05_42_goal_price=collection_data.f05_42_goal_price, + f05_43_repair_fee=collection_data.f05_43_repair_fee, + f05_44_grading_fee=collection_data.f05_44_grading_fee, + f06_50_purpose=collection_data.f06_50_purpose + ) + + db.add(collection) + db.commit() + db.refresh(collection) + + return { + 'f99_90_id': collection.f99_90_id, + 'f99_91_user_id': collection.f99_91_user_id, + 'f01_01_name': collection.f01_01_name, + 'f01_02_code': collection.f01_02_code, + 'f01_03_category': collection.f01_03_category, + 'f01_04_status': collection.f01_04_status, + 'f01_05_remark': collection.f01_05_remark, + 'f99_92_created_at': collection.f99_92_created_at.isoformat() if collection.f99_92_created_at else None, + 'message': '创建成功' + } + + +@router.put("/{collection_id}") +def update_collection( + collection_id: str, + collection_data: CollectionUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新藏品""" + collection = db.query(Collection).filter( + Collection.f99_90_id == collection_id, + Collection.f99_91_user_id == current_user.f99_90_id + ).first() + + if not collection: + raise HTTPException(status_code=404, detail="E00033: 藏品不存在") + + # 更新字段 - 使用 model_fields_set 检查哪些字段被设置 + for field_name in collection_data.model_fields_set: + value = getattr(collection_data, field_name) + if value is not None: + setattr(collection, field_name, value) + + db.commit() + db.refresh(collection) + + return { + "f99_90_id": collection.f99_90_id, + "message": "更新成功" + } + + +@router.delete("/{collection_id}") +def delete_collection( + collection_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除藏品""" + # 验证权限并检查是否存在 + collection = db.query(Collection).filter( + Collection.f99_90_id == collection_id, + Collection.f99_91_user_id == current_user.f99_90_id + ).first() + + if not collection: + raise HTTPException(status_code=404, detail="E00033: 藏品不存在") + + # 使用原生 SQL 删除(避免 ORM 级联查询字段不匹配问题) + from sqlalchemy import text + # 1. 删除关联的 operations(f99_91_user_id 关联到 collections.f99_90_id) + db.execute(text("DELETE FROM operations WHERE f99_91_user_id = :id"), {"id": collection_id}) + # 2. 删除关联的图片 + db.execute(text("DELETE FROM collection_images WHERE collection_id = :id"), {"id": collection_id}) + # 3. 删除藏品本身 + db.execute(text("DELETE FROM collections WHERE f99_90_id = :id"), {"id": collection_id}) + db.commit() + + return {"message": "删除成功"} + + +@router.post("/upload-image") +async def upload_image( + collection_id: str = None, + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """上传藏品图片 - 文件名格式:用户名 - 藏品编号 - 冠字号""" + try: + # 验证藏品是否存在 + collection = db.query(Collection).filter( + Collection.f99_90_id == collection_id + ).first() + + if not collection: + raise HTTPException(status_code=404, detail="E00033: 藏品不存在") + + # 获取用户信息(用于文件名) + owner = db.query(User).filter(User.f99_90_id == collection.f99_91_user_id).first() + username = owner.f01_01_name if owner else "unknown" + + # 获取藏品信息(用于文件名) + code = collection.f01_02_code or "0000" + prefix_serial = collection.f02_10_prefix_serial or "" + + # 检查文件类型 + if not file.content_type.startswith('image/'): + raise HTTPException(status_code=400, detail="E00038: 只能上传图片文件") + + # 检查文件大小(限制 10MB) + file_size = 0 + content = await file.read() + file_size = len(content) + if file_size > 10 * 1024 * 1024: # 10MB + raise HTTPException(status_code=400, detail=f"E00039: 图片大小不能超过 10MB(当前{file_size // 1024 // 1024}MB)") + + # 生成OSS存储路径 + user_id = collection.f99_91_user_id + file_extension = file.filename.split('.')[-1] if '.' in file.filename else 'jpg' + + # 清理特殊字符 + clean_username = re.sub(r'[^\w\u4e00-\u9fff\-]', '', username) + clean_serial = re.sub(r'[^\w\u4e00-\u9fff\-]', '', prefix_serial) + + # 文件名格式:用户名-藏品编号-冠字号.jpg + if clean_serial: + filename = f"{clean_username}-{code}-{clean_serial}.{file_extension}" + else: + filename = f"{clean_username}-{code}.{file_extension}" + + # 生成OSS key + oss_key, unique_name = get_oss_path("collections", user_id=user_id, filename=filename) + + # 上传到OSS + image_url = upload_to_oss(content, oss_key) + + # 创建图片记录(保存OSS URL) + image = CollectionImage( + id=str(uuid.uuid4()), + collection_id=collection_id, + filename=unique_name, + original_name=file.filename, + path=image_url # 保存OSS URL + ) + + db.add(image) + db.commit() + db.refresh(image) + + logger.info(f"图片上传成功:{image_url}, collection_id={collection_id}") + + return { + "message": "上传成功", + "image_id": image.id, + "filename": unique_name, + "url": image_url + } + except HTTPException: + raise + except Exception as e: + logger.error(f"图片上传失败:{str(e)}") + raise HTTPException(status_code=500, detail="上传失败") + + +@router.delete("/images/{image_id}") +async def delete_image( + image_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除藏品图片""" + try: + # 查找图片记录 + image = db.query(CollectionImage).filter( + CollectionImage.id == image_id + ).first() + + if not image: + raise HTTPException(status_code=404, detail="E00033: 图片不存在") + + # 检查权限 + collection = db.query(Collection).filter( + Collection.f99_90_id == image.collection_id + ).first() + + if collection and current_user.role != "admin" and collection.f99_91_user_id != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="E00014: 无权删除此图片") + + # 删除OSS文件(如果path是OSS URL) + if image.path and image.path.startswith("https://"): + # 从OSS URL提取key + try: + oss_key = image.path.replace("https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com/", "") + delete_from_oss(oss_key) + except Exception as e: + logger.warning(f"OSS文件删除失败: {e}") + elif image.path and os.path.exists(image.path): + # 兼容旧的本地上传 + os.remove(image.path) + + # 删除数据库记录 + db.delete(image) + db.commit() + + return {"message": "删除成功"} + except HTTPException: + raise + except Exception as e: + logger.error(f"图片删除失败:{str(e)}") + raise HTTPException(status_code=500, detail="删除失败") diff --git a/backend_src/app/routers/ocr.py b/backend_src/app/routers/ocr.py new file mode 100644 index 0000000..c49155d --- /dev/null +++ b/backend_src/app/routers/ocr.py @@ -0,0 +1,376 @@ +# OCR 识别路由 - 专业人民币生肖纪念钞鉴定 +import os +import uuid +import base64 +import httpx +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.auth import get_current_user +from app.models.models import User + +router = APIRouter(prefix="/api/ocr", tags=["OCR 识别"]) + +# 阿里云 DashScope API 配置 +DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "sk-9389024a37da4f7bb455ac9a6b28776f") + +# 阿里云 OSS 配置 +OSS_CONFIG = { + "access_key_id": os.getenv("OSS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"), + "access_key_secret": os.getenv("OSS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"), + "bucket_name": "jiachenlong-oss", + "endpoint": "oss-cn-hangzhou.aliyuncs.com", + "public_url": "https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com" +} + +# 临时上传目录(用于OCR识别本地备选) +UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "temp") +os.makedirs(UPLOAD_DIR, exist_ok=True) + + +def get_oss_path(file_type: str, user_id: str = None, collection_id: str = None, filename: str = None): + """生成OSS路径 - 按年/月/日分类""" + from datetime import datetime + now = datetime.now() + year = now.strftime("%Y") + month = now.strftime("%m") + day = now.strftime("%d") + + if file_type == "temp": + # 临时文件: temp/{year}/{month}/{day}/{uuid}.{ext} + import uuid + unique_id = str(uuid.uuid4()) + ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg' + return f"temp/{year}/{month}/{day}/{unique_id}.{ext}", unique_id + + elif file_type == "collection": + # 藏品文件: collections/{user_id}/{year}/{collection_id}/{filename} + if not user_id or not collection_id: + raise ValueError("user_id and collection_id required for collection") + return f"collections/{user_id}/{year}/{collection_id}/{filename}" + + elif file_type == "avatar": + # 头像: avatars/{user_id}/avatar.{ext} + if not user_id: + raise ValueError("user_id required for avatar") + ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg' + return f"avatars/{user_id}/avatar.{ext}" + + return None + + +# 上传图片到OSS - 使用服务层(带压缩) +from app.services.oss import upload_to_oss as oss_upload + +def upload_to_oss(file_data, oss_key): + """上传文件到阿里云OSS(带自动压缩)""" + return oss_upload(file_data, oss_key) + +# 专业提示词 +PROFESSIONAL_PROMPT = """你是一名专业的人民币生肖纪念钞鉴定专家。请严格按照以下步骤分析这张图片,并提取准确信息。 + +【识别流程】 +1. 判断类型是否评级钞:首先确认是否为裸钞还是评级钞(有封装盒和标签) +2. 验证纪念钞特征:对照生肖纪念钞特征进行确认 +3. 验证评级类型:有'标十'字眼的为标十,有'百连'字眼的为标百,其他为单张 +4. 提取信息:仔细阅读标签上的所有文字内容 + +【版别格式要求】 +只需要:年份 + 属相,例如: +- 2024 龙 +- 2025 蛇 +- 2026 马 + +【输出要求】 +严格按照以下格式输出,每个字段必须填写具体值: +✅ 1 发行机构:中国人民银行 +✅ 2 发行版别:2024 龙 +✅ 3 面额:贰拾圆 +✅ 4 是否评级:是/否 +✅ 5 封装类型:裸钞/单张/标十/标百 +✅ 6 冠字序号:J0xxxxxxxx +✅ 7 评级机构:ACG/PCGS/PMG +✅ 8 评级分数:67/68/69 +✅ 9 是否三星:是/否 +✅ 10 特殊标识:金山标/天马标/红绳版等 +✅ 11 号码特征:金山号 2 张,天马号 3 张等 + +现在请仔细分析提供的图片,按上述格式输出结果。""" + + +@router.post("/recognize") +async def recognize_image( + image: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """OCR 图片识别 - 识别后自动保存图片到OSS临时目录""" + try: + # 读取图片数据 + image_data = await image.read() + image_base64 = base64.b64encode(image_data).decode('utf-8') + + # 生成OSS临时路径: temp/{year}/{month}/{day}/{uuid}.{ext} + oss_key, temp_id = get_oss_path("temp", filename=image.filename) + + # 上传到OSS + try: + image_url = upload_to_oss(image_data, oss_key) + except Exception as oss_err: + # OSS失败时保存到本地作为备选 + temp_path = os.path.join(UPLOAD_DIR, oss_key.split('/')[-1]) + os.makedirs(os.path.dirname(temp_path), exist_ok=True) + with open(temp_path, 'wb') as f: + f.write(image_data) + image_url = f"/uploads/temp/{oss_key.split('/')[-1]}" + + headers = { + "Authorization": f"Bearer {DASHSCOPE_API_KEY}", + "Content-Type": "application/json" + } + + # 阿里云 DashScope API 格式 (qwen-vl-plus 视觉模型) + payload = { + "model": "qwen-vl-plus", + "input": { + "messages": [{ + "role": "user", + "content": [ + { + "image": f"data:{image.content_type};base64,{image_base64}" + }, + { + "text": PROFESSIONAL_PROMPT + } + ] + }] + }, + "parameters": { + "max_tokens": 1000 + } + } + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + json=payload, + headers=headers + ) + + if response.status_code != 200: + # 识别失败,删除临时文件 + if os.path.exists(temp_path): + os.remove(temp_path) + raise HTTPException(status_code=500, detail=f"OCR API 调用失败:{response.text[:200]}") + + ocr_result = response.json() + text_content = "" + # 新版API返回格式 + if "output" in ocr_result and "choices" in ocr_result["output"]: + choices = ocr_result["output"]["choices"] + if choices and len(choices) > 0: + content = choices[0].get("message", {}).get("content", []) + if content and len(content) > 0: + text_content = content[0].get("text", "") + + fields = extract_fields(text_content) + + # 返回识别结果和临时图片路径 + return { + "success": True, + "text": text_content, + "fields": fields, + "temp_image": { + "id": temp_id, + "filename": oss_key.split('/')[-1], + "path": image_url, + "original_name": image.filename, + "is_oss": image_url.startswith("https://") + } + } + + except Exception as e: + import traceback + error_detail = f"识别失败:{str(e)}\n{traceback.format_exc()}" + raise HTTPException(status_code=500, detail=error_detail) + + +def extract_fields(text: str) -> dict: + """从 OCR 文本中提取字段 - 直接返回 AI 识别结果""" + import re + fields = {} + + # 解析结构化输出 + patterns = { + 'issuer': r'✅.*?1.*?发行机构.*?[::]\s*(.+?)(?:\n|$)', + 'version': r'✅.*?2.*?发行版别.*?[::]\s*(.+?)(?:\n|$)', + 'denomination': r'✅.*?3.*?面额.*?[::]\s*(.+?)(?:\n|$)', + 'is_graded_text': r'✅.*?4.*?是否评级.*?[::]\s*(.+?)(?:\n|$)', + 'packaging': r'✅.*?5.*?封装类型.*?[::]\s*(.+?)(?:\n|$)', + 'prefix_serial': r'✅.*?6.*?冠字序号.*?[::]\s*(.+?)(?:\n|$)', + 'grading_company': r'✅.*?7.*?评级机构.*?[::]\s*(.+?)(?:\n|$)', + 'grading_score': r'✅.*?8.*?评级分数.*?[::]\s*(.+?)(?:\n|$)', + 'three_star_text': r'✅.*?9.*?是否三星.*?[::]\s*(.+?)(?:\n|$)', + 'special_mark': r'✅.*?10.*?特殊标识.*?[::]\s*(.+?)(?:\n|$)', + 'serial_feature': r'✅.*?11.*?号码特征.*?[::]\s*(.+?)(?:\n|$)' + } + + for field, pattern in patterns.items(): + match = re.search(pattern, text, re.IGNORECASE | re.DOTALL) + if match: + value = match.group(1).strip() + # 保留所有值,包括"无"和"未识别",让前端处理 + fields[field] = value + + # 处理是否评级 + if 'is_graded_text' in fields: + fields['is_graded'] = '是' in fields.pop('is_graded_text') + + # 处理是否三星 + if 'three_star_text' in fields: + fields['three_star'] = '是' in fields.pop('three_star_text') + + # 简化版别字段(2024 龙年贺岁纪念钞(标十) → 2024 龙) + if 'version' in fields: + version = fields['version'] + # 提取年份和生肖 + year_match = re.search(r'(20\d{2})', version) + animal = '' + if '龙' in version: + animal = '龙' + elif '蛇' in version: + animal = '蛇' + elif '马' in version: + animal = '马' + elif '羊' in version: + animal = '羊' + elif '猴' in version: + animal = '猴' + elif '鸡' in version: + animal = '鸡' + elif '狗' in version: + animal = '狗' + elif '猪' in version: + animal = '猪' + elif '鼠' in version: + animal = '鼠' + elif '牛' in version: + animal = '牛' + elif '虎' in version: + animal = '虎' + elif '兔' in version: + animal = '兔' + + if year_match and animal: + fields['version'] = f"{year_match.group(1)}{animal}" + + return fields + + +@router.post("/claim-temp-image") +async def claim_temp_image( + temp_id: str, + collection_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """将临时图片移动到正式藏品目录 - 按用户/年/藏品ID分类""" + from app.models.models import Collection, CollectionImage + from datetime import datetime + + # 验证藏品是否存在 + collection = db.query(Collection).filter( + Collection.f99_90_id == collection_id, + Collection.f99_91_user_id == current_user.f99_90_id + ).first() + + if not collection: + raise HTTPException(status_code=404, detail="藏品不存在") + + # 生成正式文件名和OSS路径: collections/{user_id}/{year}/{collection_id}/{filename} + code = collection.f01_02_code or "0000" + prefix = collection.f02_10_prefix_serial or "" + username = current_user.f01_01_name + import time + final_filename = f"{username}-{code}-{prefix}-{int(time.time())}.jpg" + oss_key = get_oss_path("collection", user_id=current_user.f99_90_id, collection_id=collection_id, filename=final_filename) + + # 尝试从OSS获取临时图片 - 尝试多种扩展名和日期路径 + temp_extensions = ['jpg', 'jpeg', 'png', 'gif', 'JPG', 'JPEG', 'PNG', 'GIF'] + temp_content = None + found_key = None + + # 尝试最近7天的路径 + from datetime import timedelta + for i in range(7): + date = datetime.now() - timedelta(days=i) + year = date.strftime("%Y") + month = date.strftime("%m") + day = date.strftime("%d") + + for ext in temp_extensions: + try: + temp_oss_key = f"temp/{year}/{month}/{day}/{temp_id}.{ext}" + import oss2 + auth = oss2.Auth(OSS_CONFIG["access_key_id"], OSS_CONFIG["access_key_secret"]) + bucket = oss2.Bucket(auth, OSS_CONFIG["endpoint"], OSS_CONFIG["bucket_name"]) + temp_content = bucket.get_object(temp_oss_key).read() + found_key = temp_oss_key + break + except: + continue + if temp_content: + break + + if temp_content: + # 上传到正式目录 + bucket.put_object(oss_key, temp_content) + + # 删除临时图片 + try: + bucket.delete_object(found_key) + except: + pass + + # OSS URL + image_path = f"{OSS_CONFIG['public_url']}/{oss_key}" + + else: + # OSS失败,使用本地文件 + temp_path = None + for ext in temp_extensions: + temp_path = os.path.join(UPLOAD_DIR, f"{temp_id}.{ext}") + if os.path.exists(temp_path): + break + + if not temp_path or not os.path.exists(temp_path): + raise HTTPException(status_code=404, detail="临时图片不存在或已过期") + + # 保存到本地 + collection_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "collections") + os.makedirs(collection_dir, exist_ok=True) + + new_path = os.path.join(collection_dir, final_filename) + import shutil + shutil.move(temp_path, new_path) + image_path = f"uploads/collections/{final_filename}" + + # 创建图片记录 + image_record = CollectionImage( + id=str(uuid.uuid4()), + collection_id=collection.f99_90_id, + filename=final_filename, + original_name=temp_id, + path=image_path + ) + db.add(image_record) + db.commit() + + return { + "success": True, + "image": { + "id": image_record.id, + "filename": image_record.filename, + "path": image_record.path + } + } diff --git a/backend_src/app/routers/operations.py b/backend_src/app/routers/operations.py new file mode 100644 index 0000000..0881599 --- /dev/null +++ b/backend_src/app/routers/operations.py @@ -0,0 +1,104 @@ +# 操作路由 +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.auth import get_current_user +from app.models.models import User, Collection, Operation +from app.schemas.schemas import OperationCreate, OperationResponse + +router = APIRouter(prefix="/api", tags=["操作"]) + + +@router.get("/operations", response_model=List[OperationResponse]) +def get_operations( + collection_id: Optional[str] = None, + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取操作历史""" + query = db.query(Operation).filter(Operation.user_id == current_user.id) + + if collection_id: + query = query.filter(Operation.collection_id == collection_id) + + operations = query.order_by(Operation.created_at.desc()) \ + .offset((page - 1) * limit) \ + .limit(limit) \ + .all() + + return operations + + +@router.get("/operations/history") +def get_operation_history( + collection_id: Optional[str] = None, + type: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取操作历史(带统计)""" + query = db.query(Operation).filter(Operation.user_id == current_user.id) + + if collection_id: + query = query.filter(Operation.collection_id == collection_id) + if type: + query = query.filter(Operation.type == type) + if start_date: + query = query.filter(Operation.created_at >= start_date) + if end_date: + query = query.filter(Operation.created_at <= end_date) + + total = query.count() + + data = query.order_by(Operation.created_at.desc()) \ + .offset((page - 1) * limit) \ + .limit(limit) \ + .all() + + return { + "data": data, + "pagination": { + "page": page, + "limit": limit, + "total": total, + "pages": (total + limit - 1) // limit + } + } + + +@router.post("/operations", response_model=OperationResponse) +def create_operation( + operation_data: OperationCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """创建操作记录""" + # 验证藏品存在 + collection = db.query(Collection).filter( + Collection.id == operation_data.collection_id, + Collection.user_id == current_user.id + ).first() + + if not collection: + raise HTTPException(status_code=404, detail="藏品不存在") + + operation = Operation( + collection_id=operation_data.collection_id, + user_id=current_user.id, + type=operation_data.type, + price=operation_data.price, + note=operation_data.note + ) + + db.add(operation) + db.commit() + db.refresh(operation) + + return operation diff --git a/backend_src/app/routers/users.py b/backend_src/app/routers/users.py new file mode 100644 index 0000000..6928b85 --- /dev/null +++ b/backend_src/app/routers/users.py @@ -0,0 +1,249 @@ +# 用户管理路由 +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query, Body +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.auth import get_current_user +from app.models.models import User, Collection +from app.schemas.schemas import UserResponse, UserUpdate + +router = APIRouter(prefix="/api", tags=["用户"]) + +# ============ 当前用户接口 ============ + +@router.get("/users/me", response_model=UserResponse) +def get_current_user_info( + current_user: User = Depends(get_current_user) +): + """获取当前登录用户信息""" + return { + "f99_90_id": current_user.f99_90_id, + "f01_01_name": current_user.f01_01_name, + "email": current_user.email, + "phone": current_user.phone, + "avatar": current_user.avatar, + "address": current_user.address, + "bio": current_user.bio, + "role": current_user.role, + "f99_92_created_at": current_user.f99_92_created_at.isoformat() if current_user.f99_92_created_at else None, + "f99_93_updated_at": current_user.f99_93_updated_at.isoformat() if current_user.f99_93_updated_at else None + } + +@router.put("/users/me", response_model=UserResponse) +def update_current_user( + user_update: UserUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新当前用户信息""" + import logging + logger = logging.getLogger(__name__) + + # 获取用户ID + user_id = current_user.f99_90_id + logger.info(f"Updating user {user_id}, data={user_update.model_dump()}") + + # 在当前session中重新查询用户 + user = db.query(User).filter(User.f99_90_id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + # 更新字段 + update_data = user_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + if field == 'f01_01_name': + user.f01_01_name = value + elif field == 'username': + pass # skip, already handled as f01_01_name + elif hasattr(user, field): + setattr(user, field, value) + + # 强制刷新以确保更新被提交 + db.flush() + db.commit() + db.refresh(user) + logger.info(f"After commit, user email={user.email}") + + return user + +# ============ 管理员用户管理 ============ + +admin_router = APIRouter(prefix="/api/admin/users", tags=["用户管理"]) + + +@admin_router.get("") +def get_users( + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取用户列表(仅管理员)""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="无权访问") + + total = db.query(User).count() + from sqlalchemy import case + users = db.query(User).order_by( + case( + (User.user_code == None, 1), + else_=0 + ), + User.user_code.asc() + ).offset((page-1)*limit).limit(limit).all() + + user_list = [] + for u in users: + # 统计每个用户的藏品数量 + count = db.query(Collection).filter(Collection.f99_91_user_id == u.f99_90_id).count() + user_list.append({ + "id": u.f99_90_id, + "username": u.f01_01_name, + "email": u.email, + "phone": u.phone, + "role": u.role, + "user_code": u.user_code, + "created_at": u.f99_92_created_at.isoformat() if u.f99_92_created_at else None, + "collection_count": count + }) + + return user_list + + +@admin_router.get("/{user_id}") +def get_user( + user_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取单个用户信息""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="无权访问") + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + return { + "id": user.id, + "username": user.username, + "email": user.email, + "phone": user.phone, + "role": user.role, + "created_at": user.created_at.isoformat() if user.created_at else None + } + + +@admin_router.get("/{user_id}/collections") +def get_user_collections( + user_id: str, + limit: int = Query(100, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取指定用户的藏品列表""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="无权访问") + + collections = db.query(Collection).filter( + Collection.user_id == user_id + ).limit(limit).all() + + return [c.code for c in collections] + + +@admin_router.get("/{user_id}/count") +def get_user_collection_count( + user_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取指定用户的藏品数量""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问") + + count = db.query(Collection).filter(Collection.user_id == user_id).count() + return {"count": count} + + +@admin_router.put("/{user_id}") +def update_user( + user_id: str, + username: Optional[str] = Body(None), + email: Optional[str] = Body(None), + role: Optional[str] = Body(None), + password: Optional[str] = Body(None), + user_code: Optional[str] = Body(None), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新用户信息(仅管理员)""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问") + + user = db.query(User).filter(User.f99_90_id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="E00051: 用户不存在") + + # 更新基本信息 + if username: + user.f01_01_name = username + if email: + user.email = email + if role is not None: + user.role = role + + # 更新用户编码 + if user_code is not None: + # 只有非空字符串才检查唯一性 + user_code_str = user_code.strip() if user_code else '' + if user_code_str: + existing = db.query(User).filter( + User.user_code == user_code_str, + User.f99_90_id != user_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="E00052: 该用户编码已被其他用户使用") + user.user_code = user_code_str + else: + user.user_code = None + + # 更新密码 + if password and password.strip(): + from app.core.auth import get_password_hash + user.password = get_password_hash(password) + + db.commit() + db.refresh(user) + + return { + "id": user.f99_90_id, + "username": user.f01_01_name, + "email": user.email, + "role": user.role, + "message": "更新成功" + } + + +@admin_router.delete("/{user_id}") +def delete_user( + user_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除用户(仅管理员)""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问") + + # 不能删除自己 + if user_id == str(current_user.f99_90_id): + raise HTTPException(status_code=400, detail="E00052: 不能删除自己") + + user = db.query(User).filter(User.f99_90_id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="E00051: 用户不存在") + + db.delete(user) + db.commit() + + return {"message": "删除成功"} diff --git a/backend_src/app/schemas/__init__.py b/backend_src/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend_src/app/schemas/schemas.py b/backend_src/app/schemas/schemas.py new file mode 100644 index 0000000..9ef13c3 --- /dev/null +++ b/backend_src/app/schemas/schemas.py @@ -0,0 +1,213 @@ +# Pydantic Schema - 使用字段编码并支持 camelCase +from typing import Optional, List +from pydantic import BaseModel, EmailStr, Field, ConfigDict +from datetime import datetime + + +# ============ 用户相关 ============ + +class UserBase(BaseModel): + f01_01_name: str = Field(..., min_length=3, max_length=255, alias="username") + email: Optional[EmailStr] = None + phone: Optional[str] = None + avatar: Optional[str] = None + address: Optional[str] = None + bio: Optional[str] = None + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class UserCreate(UserBase): + password: str = Field(..., min_length=6) + + +class UserUpdate(BaseModel): + f01_01_name: Optional[str] = Field(None, alias="username") + email: Optional[EmailStr] = None + phone: Optional[str] = None + avatar: Optional[str] = None + address: Optional[str] = None + bio: Optional[str] = None + password: Optional[str] = None + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class UserResponse(UserBase): + f99_90_id: str = Field(..., alias="id") + f01_01_name: str = Field(..., alias="username") + role: str + user_code: Optional[str] = None + f99_92_created_at: Optional[datetime] = Field(None, alias="created_at") + f99_93_updated_at: Optional[datetime] = Field(None, alias="updated_at") + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +# ============ 藏品相关 ============ + +class CollectionBase(BaseModel): + # f01 基本信息 + f01_01_name: str = Field(..., min_length=2, max_length=255, alias="name") + f01_02_code: Optional[str] = Field(None, max_length=50, alias="code") + f01_03_category: str = Field(..., max_length=100, alias="category") + f01_04_status: Optional[str] = Field("in_collection", alias="status") + f01_05_remark: Optional[str] = Field(None, alias="remark") + + # f02 详细字段 + f02_10_prefix_serial: Optional[str] = Field(None, alias="prefixSerial") + f02_11_version: Optional[str] = Field(None, alias="version") + f02_12_packaging: Optional[str] = Field(None, alias="packaging") + f02_13_rarity: Optional[str] = Field(None, alias="rarity") + f02_14_number_category: Optional[str] = Field(None, alias="numberCategory") + + # f03 评级信息 + f03_20_is_graded: Optional[bool] = Field(False, alias="isGraded") + f03_21_grading_company: Optional[str] = Field(None, alias="gradingCompany") + f03_22_grading_score: Optional[str] = Field(None, alias="gradingScore") + f03_23_three_star: Optional[bool] = Field(False, alias="threeStar") + + # f04 特殊信息 + f04_30_special_mark: Optional[str] = Field(None, alias="specialMark") + f04_31_serial_feature: Optional[str] = Field(None, alias="serialFeature") + f04_32_issuer: Optional[str] = Field(None, alias="issuer") + f04_33_issue_year: Optional[str] = Field(None, alias="issueYear") + f04_34_material: Optional[str] = Field(None, alias="material") + f04_35_denomination: Optional[str] = Field(None, alias="denomination") + f04_36_issue_quantity: Optional[str] = Field(None, alias="issueQuantity") + + # f05 价格信息 + f05_40_cost_price: Optional[float] = Field(None, ge=0, alias="costPrice") + f05_41_target_price: Optional[float] = Field(None, ge=0, alias="targetPrice") + f05_42_goal_price: Optional[float] = Field(None, ge=0, alias="goalPrice") + f05_43_repair_fee: Optional[float] = Field(None, ge=0, alias="repairFee") + f05_44_grading_fee: Optional[float] = Field(None, ge=0, alias="gradingFee") + + # f06 其他信息 + f06_50_purpose: Optional[str] = Field(None, alias="purpose") + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class CollectionCreate(CollectionBase): + pass + + +class CollectionUpdate(BaseModel): + # f01 基本信息 + f01_01_name: Optional[str] = Field(None, alias="name") + f01_02_code: Optional[str] = Field(None, alias="code") + f01_03_category: Optional[str] = Field(None, alias="category") + f01_04_status: Optional[str] = Field(None, alias="status") + f01_05_remark: Optional[str] = Field(None, alias="remark") + + # f02 详细字段 + f02_10_prefix_serial: Optional[str] = Field(None, alias="prefixSerial") + f02_11_version: Optional[str] = Field(None, alias="version") + f02_12_packaging: Optional[str] = Field(None, alias="packaging") + f02_13_rarity: Optional[str] = Field(None, alias="rarity") + f02_14_number_category: Optional[str] = Field(None, alias="numberCategory") + + # f03 评级信息 + f03_20_is_graded: Optional[bool] = Field(None, alias="isGraded") + f03_21_grading_company: Optional[str] = Field(None, alias="gradingCompany") + f03_22_grading_score: Optional[str] = Field(None, alias="gradingScore") + f03_23_three_star: Optional[bool] = Field(None, alias="threeStar") + + # f04 特殊信息 + f04_30_special_mark: Optional[str] = Field(None, alias="specialMark") + f04_31_serial_feature: Optional[str] = Field(None, alias="serialFeature") + f04_32_issuer: Optional[str] = Field(None, alias="issuer") + f04_33_issue_year: Optional[str] = Field(None, alias="issueYear") + f04_34_material: Optional[str] = Field(None, alias="material") + f04_35_denomination: Optional[str] = Field(None, alias="denomination") + f04_36_issue_quantity: Optional[str] = Field(None, alias="issueQuantity") + + # f05 价格信息 + f05_40_cost_price: Optional[float] = Field(None, ge=0, alias="costPrice") + f05_41_target_price: Optional[float] = Field(None, ge=0, alias="targetPrice") + f05_42_goal_price: Optional[float] = Field(None, ge=0, alias="goalPrice") + f05_43_repair_fee: Optional[float] = Field(None, ge=0, alias="repairFee") + f05_44_grading_fee: Optional[float] = Field(None, ge=0, alias="gradingFee") + + # f06 其他信息 + f06_50_purpose: Optional[str] = Field(None, alias="purpose") + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class CollectionImageResponse(BaseModel): + f99_90_id: str + filename: str + original_name: Optional[str] = None + path: Optional[str] = None + f99_92_created_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class CollectionResponse(CollectionBase): + f99_90_id: str = Field(..., alias="id") + f99_91_user_id: str = Field(..., alias="userId") + f99_92_created_at: Optional[datetime] = Field(None, alias="createdAt") + f99_93_updated_at: Optional[datetime] = Field(None, alias="updatedAt") + images: List[CollectionImageResponse] = [] + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class CollectionListResponse(BaseModel): + data: List[CollectionResponse] + pagination: dict + + +# ============ 操作日志相关 ============ + +class OperationBase(BaseModel): + type: str = Field(..., max_length=50) + price: Optional[float] = None + note: Optional[str] = None + + +class OperationCreate(OperationBase): + f99_91_user_id: str + + +class OperationResponse(OperationBase): + f99_90_id: str + f99_91_user_id: str + f99_93_created_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +# ============ OCR 相关 ============ + +class OCRRequest(BaseModel): + image: str + ocr_provider: Optional[str] = "aliyun" + + +class OCRResponse(BaseModel): + text: str + confidence: float + fields: Optional[dict] = None + + +# ============ 通用响应 ============ + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + + +class TokenData(BaseModel): + f99_90_user_id: Optional[str] = None + + +class MessageResponse(BaseModel): + message: str + + +class ErrorResponse(BaseModel): + detail: str diff --git a/backend_src/app/services/oss.py b/backend_src/app/services/oss.py new file mode 100644 index 0000000..6c52c87 --- /dev/null +++ b/backend_src/app/services/oss.py @@ -0,0 +1,126 @@ +# 阿里云OSS服务 +import os +import uuid +import datetime +from typing import Optional +import oss2 +from PIL import Image +import io + +# OSS配置 +OSS_CONFIG = { + "access_key_id": os.getenv("OSS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"), + "access_key_secret": os.getenv("OSS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"), + "bucket_name": "jiachenlong-oss", + "endpoint": "oss-cn-hangzhou.aliyuncs.com", + "public_url": "https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com" +} + +# 图片压缩配置 +IMAGE_CONFIG = { + "max_size": 1024 * 1024, # 1MB + "max_width": 2048, + "max_height": 2048, + "quality": 85, + "format": "JPEG" +} + +# 初始化OSS +auth = oss2.Auth(OSS_CONFIG["access_key_id"], OSS_CONFIG["access_key_secret"]) +bucket = oss2.Bucket(auth, OSS_CONFIG["endpoint"], OSS_CONFIG["bucket_name"]) + + +def get_oss_path(prefix: str, user_id: str = None, filename: str = None) -> str: + """生成OSS存储路径""" + now = datetime.datetime.now() + year = now.strftime("%Y") + month = now.strftime("%m") + day = now.strftime("%d") + + if filename: + ext = filename.split('.')[-1] if '.' in filename else 'jpg' + unique_name = f"{uuid.uuid4().hex}.{ext}" + else: + unique_name = f"{uuid.uuid4().hex}.jpg" + + if user_id: + path = f"{prefix}/{user_id}/{year}/{month}/{unique_name}" + else: + path = f"{prefix}/{year}/{month}/{day}/{unique_name}" + + return path, unique_name + + +def upload_to_oss(file_data: bytes, oss_key: str, compress: bool = True) -> str: + """上传文件到OSS,返回公网URL""" + try: + # 如果是图片,进行压缩 + if compress and any(oss_key.lower().endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp']): + file_data = compress_image(file_data) + + # 上传文件 + result = bucket.put_object(oss_key, file_data) + + if result.status == 200: + # 返回公网URL + return f"{OSS_CONFIG['public_url']}/{oss_key}" + else: + raise Exception(f"OSS上传失败: {result.status}") + + except Exception as e: + raise Exception(f"OSS上传失败: {str(e)}") + + +def delete_from_oss(oss_key: str) -> bool: + """从OSS删除文件""" + try: + result = bucket.delete_object(oss_key) + return result.status == 204 + except Exception as e: + print(f"OSS删除失败: {str(e)}") + return False + + +def get_public_url(oss_key: str) -> str: + """获取公网URL""" + return f"{OSS_CONFIG['public_url']}/{oss_key}" + + +def compress_image(image_data: bytes, max_size: int = None) -> bytes: + """压缩图片到指定大小以内""" + if max_size is None: + max_size = IMAGE_CONFIG["max_size"] + + # 如果已经小于限制,直接返回 + if len(image_data) <= max_size: + return image_data + + # 打开图片 + img = Image.open(io.BytesIO(image_data)) + + # 如果是PNG且有透明通道,转换为RGB + if img.mode in ('RGBA', 'P'): + img = img.convert('RGB') + + # 逐步降低质量直到达到目标大小 + quality = 95 + compressed_data = image_data + + while quality > 30 and len(compressed_data) > max_size: + output = io.BytesIO() + img.save(output, format=IMAGE_CONFIG["format"], quality=quality, optimize=True) + compressed_data = output.getvalue() + quality -= 10 + + # 如果还是太大,缩小尺寸 + if len(compressed_data) > max_size: + width, height = img.size + while len(compressed_data) > max_size and width > 400: + width = int(width * 0.8) + height = int(height * 0.8) + img_resized = img.resize((width, height), Image.Resampling.LANCZOS) + output = io.BytesIO() + img_resized.save(output, format=IMAGE_CONFIG["format"], quality=80, optimize=True) + compressed_data = output.getvalue() + + return compressed_data diff --git a/backend_src/app/services/sms.py b/backend_src/app/services/sms.py new file mode 100644 index 0000000..e8a0a7b --- /dev/null +++ b/backend_src/app/services/sms.py @@ -0,0 +1,106 @@ +# 阿里云短信服务 +import os +import random +import string +import time +from datetime import datetime, timedelta +from typing import Optional + +# 阿里云短信配置 +SMS_CONFIG = { + "access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"), + "access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"), + "sign_name": "阿里云", + "template_code": "100001", +} + +# 验证码缓存(生产环境建议用Redis) +# 格式: { phone: { code: "123456", expire: 1234567890 } } +VERIFICATION_CODES = {} + + +def generate_code(length: int = 6) -> str: + """生成6位数字验证码""" + return ''.join(random.choices(string.digits, k=length)) + + +def send_verification_code(phone: str) -> dict: + """发送短信验证码""" + from alibabacloud_dysmsapi20170525 import models + from alibabacloud_dysmsapi20170525.client import Client + from alibabacloud_tea_openapi import models as open_models + + try: + # 生成验证码 + code = generate_code(6) + + # 配置客户端 + config = open_models.Config( + access_key_id=SMS_CONFIG["access_key_id"], + access_key_secret=SMS_CONFIG["access_key_secret"], + ) + config.endpoint = "dysmsapi.aliyuncs.com" + config.region_id = "cn-hangzhou" + + client = Client(config) + + # 构造请求 + request = models.SendSmsRequest( + phone_numbers=phone, + sign_name=SMS_CONFIG["sign_name"], + template_code=SMS_CONFIG["template_code"], + template_param=f'{{"code":"{code}"}}' + ) + + # 发送 + response = client.send_sms(request) + + # 检查结果 + if response.body.code == "OK": + # 保存验证码 + VERIFICATION_CODES[phone] = { + "code": code, + "expire": int(time.time()) + 300 # 5分钟有效 + } + return { + "success": True, + "message": "验证码已发送", + "expire": 300 + } + else: + return { + "success": False, + "message": f"发送失败: {response.body.message}" + } + + except Exception as e: + return { + "success": False, + "message": f"发送失败: {str(e)}" + } + + +def verify_code(phone: str, code: str) -> bool: + """验证验证码""" + if phone not in VERIFICATION_CODES: + return False + + stored = VERIFICATION_CODES[phone] + + # 检查是否过期 + if int(time.time()) > stored["expire"]: + del VERIFICATION_CODES[phone] + return False + + # 验证码匹配 + if stored["code"] == code: + # 验证成功,删除验证码 + del VERIFICATION_CODES[phone] + return True + + return False + + +def check_code_exists(phone: str) -> bool: + """检查是否已发送过验证码""" + return phone in VERIFICATION_CODES diff --git a/backend_src/requirements.txt b/backend_src/requirements.txt new file mode 100644 index 0000000..bda1a26 --- /dev/null +++ b/backend_src/requirements.txt @@ -0,0 +1,13 @@ +# 甲辰藏品管理系统后端 - Python 依赖 +# 版本:v1.0.0 + +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy==2.0.25 +psycopg2-binary==2.9.9 +pydantic==2.5.3 +python-jose[cryptography]==3.3.0 +bcrypt==4.1.2 +python-multipart==0.0.6 +pillow==10.2.0 +dashscope==1.14.1 diff --git a/backend_src/uploads/.gitkeep b/backend_src/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config_src/VERSION b/config_src/VERSION new file mode 100644 index 0000000..dedc56c --- /dev/null +++ b/config_src/VERSION @@ -0,0 +1 @@ +VERSION=1.2.12 diff --git a/config_src/docker-compose-test.yml b/config_src/docker-compose-test.yml new file mode 100644 index 0000000..d43ead2 --- /dev/null +++ b/config_src/docker-compose-test.yml @@ -0,0 +1,50 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + container_name: jiachenlong-db-test + restart: unless-stopped + environment: + POSTGRES_DB: zodiac + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + image: python:3.11-slim + container_name: jiachenlong-backend-test + restart: unless-stopped + working_dir: /app + command: > + bash -c "pip install fastapi uvicorn sqlalchemy psycopg2-binary pydantic python-jose bcrypt python-multipart pillow dashscope alibabacloud-dysmsapi20170525 -q && uvicorn app.main:app --host 0.0.0.0 --port 3000" + ports: + - "3000:3000" + volumes: + - /root/jiachenlong/backend:/app + environment: + - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/zodiac + - SECRET_KEY=test-secret-key-for-sms + - ACCESS_TOKEN_EXPIRE_MINUTES=60 + - OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG + - OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1 + - OSS_BUCKET=jiachenlong-oss + - OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com + - SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE + - SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1 + - SMS_SIGN_NAME=苏州算力 + - SMS_TEMPLATE_CODE=SMS_501590956 + depends_on: + postgres: + condition: service_healthy + +volumes: + postgres_data: diff --git a/config_src/docker-compose.yml b/config_src/docker-compose.yml new file mode 100644 index 0000000..b171727 --- /dev/null +++ b/config_src/docker-compose.yml @@ -0,0 +1,73 @@ +version: '3.8' + +# 甲辰藏品管理系统 v1.0.0 - Docker 配置 +# 使用方式:docker-compose up -d + +services: + # PostgreSQL 数据库 + postgres: + image: postgres:15-alpine + container_name: jiachenlong-db + restart: unless-stopped + environment: + POSTGRES_DB: zodiac + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + # FastAPI 后端服务 + backend: + build: + context: ../backend + dockerfile: Dockerfile + container_name: jiachenlong-backend + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - ../backend/uploads:/app/uploads + - ../static:/app/static + environment: + - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/zodiac + - SECRET_KEY=production-secret-key-change-me + - ACCESS_TOKEN_EXPIRE_MINUTES=60 + - PORT=3000 + - HOST=0.0.0.0 + - DASHSCOPE_API_KEY=sk-your-api-key + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + + # Nginx 前端服务 + frontend: + image: nginx:alpine + container_name: jiachenlong-frontend + restart: unless-stopped + ports: + - "80:80" + volumes: + - ../frontend/dist:/usr/share/nginx/html:ro + - ../static:/usr/share/nginx/html/static:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + +volumes: + postgres_data: + +networks: + default: + name: jiachenlong-network diff --git a/config_src/nginx.conf b/config_src/nginx.conf new file mode 100644 index 0000000..f070809 --- /dev/null +++ b/config_src/nginx.conf @@ -0,0 +1,60 @@ +# Nginx 配置 - 甲辰藏品管理系统 v1.0.0 + +server { + listen 80; + server_name localhost; + + root /var/www/html; + index index.html; + + # 允许上传最大 20MB 的文件 + client_max_body_size 20M; + + # 前端静态文件(SPA 路由) + location / { + try_files $uri $uri/ /index.html; + } + + # 静态资源目录(图片、图标、字体) + location /static { + alias /var/www/html/static; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # API 代理到后端 + location /api { + proxy_pass http://47.110.37.129:3000/api; + proxy_set_header Host $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; + client_max_body_size 20M; + } + + # 缓存静态资源(必须在 /uploads 之前,否则图片会被代理) + location ~* \.(js|css|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # 图片上传文件代理(必须在图片扩展名 location 之前) + location /uploads { + proxy_pass http://47.110.37.129:3000/uploads; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + client_max_body_size 20M; + } + + # 前端静态图片缓存 + location ~* ^/static/.*\.(png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # 禁止访问隐藏文件 + location ~ /\. { + deny all; + } +} diff --git a/frontend/src/App.jsx b/frontend/frontend_src/App.jsx similarity index 100% rename from frontend/src/App.jsx rename to frontend/frontend_src/App.jsx diff --git a/frontend/src/config/version.js b/frontend/frontend_src/config/version.js similarity index 100% rename from frontend/src/config/version.js rename to frontend/frontend_src/config/version.js diff --git a/frontend/src/index.css b/frontend/frontend_src/index.css similarity index 100% rename from frontend/src/index.css rename to frontend/frontend_src/index.css diff --git a/frontend/src/main.jsx b/frontend/frontend_src/main.jsx similarity index 100% rename from frontend/src/main.jsx rename to frontend/frontend_src/main.jsx diff --git a/frontend/src/pages/Add.jsx b/frontend/frontend_src/pages/Add.jsx similarity index 100% rename from frontend/src/pages/Add.jsx rename to frontend/frontend_src/pages/Add.jsx diff --git a/frontend/src/pages/Admin.jsx b/frontend/frontend_src/pages/Admin.jsx similarity index 100% rename from frontend/src/pages/Admin.jsx rename to frontend/frontend_src/pages/Admin.jsx diff --git a/frontend/src/pages/BatchMode.jsx b/frontend/frontend_src/pages/BatchMode.jsx similarity index 100% rename from frontend/src/pages/BatchMode.jsx rename to frontend/frontend_src/pages/BatchMode.jsx diff --git a/frontend/src/pages/Detail.jsx b/frontend/frontend_src/pages/Detail.jsx similarity index 100% rename from frontend/src/pages/Detail.jsx rename to frontend/frontend_src/pages/Detail.jsx diff --git a/frontend/src/pages/Edit.jsx b/frontend/frontend_src/pages/Edit.jsx similarity index 100% rename from frontend/src/pages/Edit.jsx rename to frontend/frontend_src/pages/Edit.jsx diff --git a/frontend/src/pages/Edit.jsx.dual_column_bak b/frontend/frontend_src/pages/Edit.jsx.dual_column_bak similarity index 100% rename from frontend/src/pages/Edit.jsx.dual_column_bak rename to frontend/frontend_src/pages/Edit.jsx.dual_column_bak diff --git a/frontend/src/pages/Home.jsx b/frontend/frontend_src/pages/Home.jsx similarity index 100% rename from frontend/src/pages/Home.jsx rename to frontend/frontend_src/pages/Home.jsx diff --git a/frontend/frontend_src/pages/List.jsx b/frontend/frontend_src/pages/List.jsx new file mode 100644 index 0000000..0078943 --- /dev/null +++ b/frontend/frontend_src/pages/List.jsx @@ -0,0 +1,603 @@ +import React, { useState, useEffect } from 'react' +import { APP_VERSION } from '../config/version' + +export default function List() { + const [collections, setCollections] = useState([]) + const [loading, setLoading] = useState(true) + const [filter, setFilter] = useState('') + const [filterType, setFilterType] = useState('') + const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || '') + const urlUserId = urlParams.get('userId') || '' + const [userIdFilter, setUserIdFilter] = useState(urlUserId) + const [sortField, setSortField] = useState('createdAt') + const [sortOrder, setSortOrder] = useState('desc') + const [viewMode, setViewMode] = useState('list') + const [key, setKey] = useState(0) + const [search, setSearch] = useState('') + const [isAdmin, setIsAdmin] = useState(false) + const [page, setPage] = useState(1) + const [pagination, setPagination] = useState({ total: 0, pages: 1 }) + + useEffect(() => { + // 检查是否管理员 + const userStr = localStorage.getItem('user') + if (userStr) { + try { + const user = JSON.parse(userStr) + setIsAdmin(user.role === 'admin') + } catch (e) {} + } + + // 监听hash变化,重新读取筛选参数 + const handleHashChange = () => { + const params = new URLSearchParams(window.location.hash.split('?')[1] || '') + // 支持两种格式:filter=category&value=自持 或 filter=category=自持 + let filterTypeParam = params.get('filter') || '' + let valueParam = params.get('value') || '' + + if (filterTypeParam && valueParam) { + // 新格式:filter=category&value=自持 + setFilterType(filterTypeParam) + setFilter(decodeURIComponent(valueParam)) + } else if (filterTypeParam && filterTypeParam.includes('=')) { + // 旧格式:filter=category=自持 + const [type, value] = filterTypeParam.split('=') + setFilterType(type) + setFilter(decodeURIComponent(value)) + } else { + setFilter('') + setFilterType('') + } + fetchCollections() + } + + handleHashChange() + window.addEventListener('hashchange', handleHashChange) + window.addEventListener('focus', fetchCollections) + + return () => { + window.removeEventListener('hashchange', handleHashChange) + window.removeEventListener('focus', fetchCollections) + } + }, []) + + const fetchCollections = async () => { + setLoading(true) + const token = localStorage.getItem('token') + try { + // 从URL获取筛选参数 + const params = new URLSearchParams(window.location.hash.split('?')[1] || '') + const urlFilterType = params.get('filter') || '' + const urlFilterValue = params.get('value') ? decodeURIComponent(params.get('value')) : '' + + let api = '/api/collections?page=' + page + '&limit=100&sortBy=' + sortField + '&sortOrder=' + sortOrder + if (urlFilterType && urlFilterValue) { + api += '&' + urlFilterType + '=' + encodeURIComponent(urlFilterValue) + } + const res = await fetch(api, { + headers: token ? { 'Authorization': 'Bearer ' + token } : {} + }) + + // 处理 401 未授权错误 + if (res.status === 401) { + localStorage.removeItem('token') + localStorage.removeItem('user') + window.location.hash = '#/login' + return + } + + const data = await res.json() + // 新格式直接返回数组或 {data: [], pagination: {}} + let list = data.data || data + if (!Array.isArray(list) && list && Array.isArray(list.items)) { + list = list.items + } + + // 应用筛选条件 + // 用户ID筛选(从URL获取) + if (userIdFilter) { + list = list.filter(item => item.userId === userIdFilter || item.userId === userIdFilter.replace(/-/g, '')) + } + + if (filterType && filter) { + const fieldMap = { + status: 'status', + category: 'category', + packaging: 'packaging', + rarity: 'rarity', + numberCategory: 'numberCategory', + version: 'version', + gradingCompany: 'gradingCompany', + gradingScore: 'gradingScore', + specialMark: 'specialMark', + profitLoss: 'profitLoss' + } + const field = fieldMap[filterType] || filterType + + if (filterType === 'profitLoss') { + // 盈亏筛选:需要计算出售价和总成本 + list = list.filter(item => { + if (item.status !== 'sold') return false // 只筛选已售 + const totalCost = (item.costPrice || 0) + (item.repairFee || 0) + (item.gradingFee || 0) + const isProfit = item.goalPrice > totalCost + return filter === 'profit' ? isProfit : !isProfit + }) + } else { + list = list.filter(item => { + const value = item[field] || item[filterType] + return value === filter + }) + } + console.log(`筛选:${filterType} = ${filter}, 结果:${list.length}条`) + } + + setCollections(list || []) + + // 获取分页信息 + if (data.pagination) { + setPagination({ + total: data.pagination.total || 0, + pages: data.pagination.pages || 1 + }) + } + } catch (e) { + console.error('Fetch collections error:', e) + // 网络错误也跳转到登录页 + localStorage.removeItem('token') + localStorage.removeItem('user') + window.location.hash = '#/login' + } + setLoading(false) + } + + + // 监听页码变化,重新获取数据 + useEffect(() => { + if (page > 1) { + fetchCollections() + } + }, [page]) + + const refresh = () => { + setKey(k => k + 1) + } + + useEffect(() => { + window.refreshList = refresh + return () => { delete window.refreshList } + }, []) + + const goDetail = (id) => { + // 保存当前列表的URL(包含筛选条件),用于返回时恢复 + sessionStorage.setItem('lastListUrl', window.location.hash.substring(1)) + window.location.hash = '#/detail?id=' + id + } + + // 获取筛选字段的中文标签 + const getFilterLabel = (type) => { + const labels = { + status: '状态', + category: '持仓类型', + packaging: '包装', + rarity: '珍惜度', + version: '版别', + gradingCompany: '评级公司', + gradingScore: '评级分数', + specialMark: '特殊标识', + profitLoss: '盈亏' + } + return labels[type] || type + } + + // 获取筛选条件值的中文显示 + const getFilterValueLabel = (type, value) => { + const valueLabels = { + status: { + in_collection: '收藏中', + selling: '出售中', + sold: '已售', + grading: '送评中', + repairing: '修复中', + transit: '在途中', + seeking: '寻号中', + other: '其他' + }, + category: { + 自持: '自持', + 寄存: '寄存', + 寄售: '寄售', + 共有: '共有', + 寻号: '寻号', + 其他: '其他' + }, + packaging: { + 标十: '标十', + 标百: '标百', + 单张: '单张', + 裸钞: '裸钞' + }, + rarity: { + 通货: '通货', + 特色: '特色', + 少见: '少见', + 稀有: '稀有', + 珍品: '珍品', + 孤品: '孤品' + }, + profitLoss: { + profit: '盈利', + loss: '亏损' + }, + isGraded: { + true: '已评级', + false: '未评级' + } + } + const typeLabels = valueLabels[type] + if (typeLabels) { + return typeLabels[value] || value + } + return value + } + + const versions = collections && collections.length ? [...new Set(collections.map(c => c.version).filter(v => v))] : [] + + // 筛选和排序 + const filteredCollections = collections.filter(c => { + // 筛选条件 + if (filter && filterType) { + if (filterType === 'profitLoss') { + if (c.status !== 'sold') return false + if (filter === 'profit') return c.goalPrice > c.costPrice + return c.goalPrice <= c.costPrice + } else if (filterType === 'isGraded') { + if (c.isGraded !== (filter === 'true')) return false + } else if (c[filterType] !== filter) { + return false + } + } + // 搜索 - 全字段搜索 + if (search) { + const s = search.toLowerCase().trim() + // 收集所有可搜索字段 + const allFields = [ + // 基本信息 + c.name, c.code, c.prefixSerial, c.version, + c.status, c.category, c.packaging, c.rarity, + // 评级信息 + c.gradingCompany, c.gradingScore, c.specialMark, + c.isGraded ? '已评级' : '未评级', + c.threeStar ? '三星' : '', + // 价格信息 + c.targetPrice?.toString(), c.costPrice?.toString(), c.goalPrice?.toString(), + c.repairFee?.toString(), c.gradingFee?.toString(), + // 其他 + c.remark, c.purpose, c.material, c.denomination, + c.issueYear, c.issueQuantity, c.serialFeature, c.issuer, + // 用户信息 + c.username || '', c.userId || '' + ].filter(v => v !== undefined && v !== null).map(v => v.toString().toLowerCase()) + + if (!allFields.some(f => f.includes(s))) { + return false + } + } + return true + }).sort((a, b) => { + let aVal = a[sortField] + let bVal = b[sortField] + if (sortField === 'createdAt') { + aVal = new Date(a.createdAt || 0).getTime() + bVal = new Date(b.createdAt || 0).getTime() + } else if (sortField === 'code') { + // 编号按数字排序,提取数字部分 + aVal = parseInt(a.code?.replace(/\D/g, '') || '0', 10) + bVal = parseInt(b.code?.replace(/\D/g, '') || '0', 10) + } else if (sortField === 'prefixSerial') { + // 冠字号按字母排序 + aVal = a.prefixSerial || '' + bVal = b.prefixSerial || '' + } else if (['costPrice', 'targetPrice', 'goalPrice', 'gradingScore'].includes(sortField)) { + // 价格和分数按数字排序 + aVal = parseFloat(aVal) || 0 + bVal = parseFloat(bVal) || 0 + } else if (sortField === 'rarity') { + // 珍惜度按等级排序 + const rarityOrder = { '通货': 1, '特色': 2, '少见': 3, '稀有': 4, '珍品': 5, '孤品': 6 } + aVal = rarityOrder[aVal] || 0 + bVal = rarityOrder[bVal] || 0 + } else if (sortField === 'numberCategory') { + // 号码分类按自定义顺序排序 + const numberCategoryOrder = { '无2347': 1, '无347': 2, '无247': 3, '无47': 4, '无4': 5, '带4': 6, '其他': 7 } + aVal = numberCategoryOrder[aVal] || 99 + bVal = numberCategoryOrder[bVal] || 99 + } + if (aVal == null) return 1 + if (bVal == null) return -1 + if (sortOrder === 'asc') { + return aVal > bVal ? 1 : -1 + } + return aVal < bVal ? 1 : -1 + }) + + const clearFilter = () => { + setFilter('') + setFilterType('') + window.location.hash = '#/stats' + } + + const getStatusText = (status) => { + const map = { + 'in_collection': '收藏中', + 'selling': '出售中', + 'sold': '已售', + 'grading': '送评中', + 'repairing': '修复中', + 'transit': '在途中', + 'seeking': '寻号中', + 'other': '其他' + } + return map[status] || status || '-' + } + + const getCategoryText = (category) => { + const map = { '自持': '自持', '寄存': '寄存', '寄售': '寄售', '共有': '共有', '其他': '其他' } + return map[category] || category || '自持' + } + + const getCategoryColor = (category) => { + const colors = { '自持': '#22c55e', '寄存': '#3b82f6', '寄售': '#f59e0b', '共有': '#8b5cf6', '其他': '#64748b' } + return colors[category] || '#64748b' + } + + const getNumberCategoryColor = (cat) => { + const colors = { '无2347': '#8b5cf6', '无347': '#ef4444', '无247': '#fbbf24', '无47': '#3b82f6', '无4': '#06b6d4', '带4': '#22c55e', '其他': '#64748b' }; + return colors[cat] || '#64748b'; + }; + + const getRarityColor = (rarity) => { + const colors = { '通货': '#22c55e', '特色': '#06b6d4', '少见': '#3b82f6', '稀有': '#ec4899', '珍品': '#ef4444', '孤品': '#8b5cf6' } + return colors[rarity] || '#64748b' + } + + const formatPrefixSerial = (serial) => { + if (!serial) return '-' + // 提取J开头的10位(1位J + 9位数字) + const match = serial.match(/J(\d{9})/) + if (match) return 'J' + match[1] + // 如果没有J开头,取前10位 + return serial.substring(0, 10) + } + + const getPackagingColor = (packaging) => { + const colors = { '裸钞': '#22c55e', '单张': '#3b82f6', '标十': '#fbbf24', '标百': '#8b5cf6' } + return colors[packaging] || '#64748b' + } + + const getStatusColor = (status) => { + const colors = { + 'in_collection': '#22c55e', + 'selling': '#f59e0b', + 'sold': '#ef4444', + 'grading': '#8b5cf6', + 'repairing': '#f97316', + 'transit': '#06b6d4', + 'seeking': '#ec4899', + 'other': '#64748b' + } + return colors[status] || '#64748b' + } + + const getVersionColor = (version) => { + if (!version) return { bg: 'rgba(255,255,255,0.08)', color: '#94a3b8' } + const v = version.toLowerCase() + if (v.includes('龙')) return { bg: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%)', color: '#1e293b' } + if (v.includes('蛇')) return { bg: 'linear-gradient(135deg, #ec4899 0%, #db2777 100%)', color: '#fff' } + if (v.includes('马')) return { bg: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)', color: '#fff' } + if (v.includes('羊')) return { bg: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)', color: '#fff' } + if (v.includes('猴')) return { bg: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)', color: '#fff' } + if (v.includes('鸡')) return { bg: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', color: '#1e293b' } + if (v.includes('狗')) return { bg: 'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)', color: '#fff' } + if (v.includes('猪')) return { bg: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)', color: '#fff' } + if (v.includes('鼠')) return { bg: 'linear-gradient(135deg, #64748b 0%, #475569 100%)', color: '#fff' } + if (v.includes('牛')) return { bg: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)', color: '#fff' } + if (v.includes('虎')) return { bg: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)', color: '#fff' } + if (v.includes('兔')) return { bg: 'linear-gradient(135deg, #f43f5e 0%, #e11d48 100%)', color: '#fff' } + return { bg: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%)', color: '#1e293b' } + } + + const ListItem = ({ item }) => ( +
goDetail(item.id)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '12px', padding: '10px', marginBottom: '8px', cursor: 'pointer' }}> + {/* 第1行:编号 + 冠字号 + 包装(蓝色) | 状态 + 持仓类型 + 珍惜度(紫色) */} +
+
+ {item.code || '-'} + {formatPrefixSerial(item.prefixSerial)} + {item.packaging && {item.packaging}} + {item.numberCategory && {item.numberCategory}} +
+
+ {item.status && {getStatusText(item.status)}} + {item.category && {getCategoryText(item.category)}} +
+
+ {/* 第2行:版本(彩色) + 已评级 + 评级公司 + 评级分数 + 三星 + 特殊标识 | 备注 */} +
+
+ {item.version && {item.version}} + {item.isGraded && 已评级} + {item.gradingCompany && {item.gradingCompany.substring(0,4)}} + {item.gradingScore && {item.gradingScore}} + {item.threeStar && 三星} + {item.specialMark && {item.specialMark}} +
+
+ {item.rarity && {item.rarity}} + {item.remark && {item.remark}} +
+
+ {/* 第3行:成本 + 修复 + 评级 | 目标 + 出售 */} +
+
+ {item.costPrice && 成本: ¥{item.costPrice}} + {item.repairFee && 修复: ¥{item.repairFee}} + {item.gradingFee && 评级: ¥{item.gradingFee}} +
+
+ {item.targetPrice && 目标: ¥{item.targetPrice}} + {item.goalPrice && 出售: ¥{item.goalPrice}} +
+
+
+ ) + + return ( +
+
+
+
我的藏品 ({filteredCollections.length})
+
+ {filter && filterType && ( + + )} +
v{APP_VERSION}
+
+
+ + {filter && filterType && ( +
+
当前筛选:
+
+ {getFilterLabel(filterType)} = {getFilterValueLabel(filterType, filter)} +
+
+ )} + + {/* 搜索框 - 全字段搜索 */} +
+ setSearch(e.target.value)} + style={{ + background: 'rgba(255,255,255,0.05)', + border: search ? '1px solid rgba(59, 130, 246, 0.5)' : '1px solid rgba(255,255,255,0.1)', + color: '#fff', + width: '100%', + boxSizing: 'border-box', + padding: '4px 40px 4px 8px', + borderRadius: '12px', + fontSize: '14px', + outline: 'none', + transition: 'border-color 0.2s' + }} + /> + {search && ( + + )} +
+ + {/* 排序表头按钮 */} +
+ 排序: + {[ + { key: 'code', label: '编号' }, + { key: 'rarity', label: '珍惜度' }, + { key: 'numberCategory', label: '号码分类' }, + { key: 'packaging', label: '包装类型' }, + ].map(item => ( +
{ + if (sortField === item.key) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc') + } else { + setSortField(item.key) + setSortOrder('desc') + } + }} style={{ + padding: '4px 8px', + borderRadius: '8px', + fontSize: '12px', + cursor: 'pointer', + background: sortField === item.key ? (sortOrder === 'asc' ? '#22c55e' : '#fbbf24') : 'rgba(255,255,255,0.08)', + color: sortField === item.key ? '#fff' : '#94a3b8', + fontWeight: sortField === item.key ? 'bold' : 'normal', + border: sortField === item.key ? 'none' : '1px solid rgba(255,255,255,0.1)' + }}> + {item.label} {sortField === item.key && (sortOrder === 'asc' ? '↑' : '↓')} +
+ ))} +
+ + {/* 分页组件 */} + {pagination.pages > 1 && ( +
+ + + {Array.from({ length: Math.min(5, pagination.pages) }, (_, i) => { + let startPage = Math.max(1, page - 2) + return + })} + + + + 共{pagination.total}条 +
+ )} +
+ +
+ {loading ? ( +
加载中...
+ ) : filteredCollections.length === 0 ? ( +
📭
{filter ? '暂无符合筛选条件的藏品' : '暂无藏品'}
+ ) : viewMode === 'grid' ? ( +
+ {filteredCollections.map(item => ( +
goDetail(item.id)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '12px', padding: '12px', cursor: 'pointer' }}> +
🐉
+
{item.code || '-'}
+
{formatPrefixSerial(item.prefixSerial)}
+
+ {item.gradingScore && {item.gradingScore}} + {item.threeStar && ⭐⭐⭐} +
+
+ ))} +
+ ) : ( + filteredCollections.map(item => ) + )} +
+
+ ) +} diff --git a/frontend/src/pages/Login.jsx b/frontend/frontend_src/pages/Login.jsx similarity index 100% rename from frontend/src/pages/Login.jsx rename to frontend/frontend_src/pages/Login.jsx diff --git a/frontend/src/pages/OCR.jsx b/frontend/frontend_src/pages/OCR.jsx similarity index 100% rename from frontend/src/pages/OCR.jsx rename to frontend/frontend_src/pages/OCR.jsx diff --git a/frontend/src/pages/Settings.jsx b/frontend/frontend_src/pages/Settings.jsx similarity index 100% rename from frontend/src/pages/Settings.jsx rename to frontend/frontend_src/pages/Settings.jsx diff --git a/frontend/frontend_src/pages/Stats.jsx b/frontend/frontend_src/pages/Stats.jsx new file mode 100644 index 0000000..97ee369 --- /dev/null +++ b/frontend/frontend_src/pages/Stats.jsx @@ -0,0 +1,292 @@ +// 统计分析页面 - 支持点击跳转 +import React, { useState, useEffect } from 'react' +import { APP_VERSION } from '../config/version' + +export default function Stats() { + const [stats, setStats] = useState({ + totalCount: 0, + byCategory: [], + byStatus: [], + byGrading: [], + byPackaging: [], + byRarity: [], + byNumberCategory: [], + byVersion: [], + byGradingCompany: [], + byGradingScore: [], + bySpecialMark: [], + byProfitLoss: [], + totalCost: 0, + totalTarget: 0, + expectedProfit: 0, + totalRevenue: 0, + totalProfit: 0 + }) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchStats() + }, []) + + const fetchStats = async () => { + setLoading(true) + const token = localStorage.getItem('token') + try { + const statsRes = await fetch('/api/collections/stats', { + headers: { 'Authorization': 'Bearer ' + token } + }) + + if (!statsRes.ok) { + throw new Error(`HTTP ${statsRes.status}`) + } + + const data = await statsRes.json() + console.log('统计数据:', data) + + setStats({ + totalCount: data.totalCount || 0, + byCategory: data.byCategory || [], + byStatus: data.byStatus || [], + byGrading: data.byGrading || [], + byPackaging: data.byPackaging || [], + byRarity: data.byRarity || [], + byNumberCategory: data.byNumberCategory || [], + byVersion: data.byVersion || [], + byGradingCompany: data.byGradingCompany || [], + byGradingScore: data.byGradingScore || [], + bySpecialMark: data.bySpecialMark || [], + byProfitLoss: data.byProfitLoss || [], + totalCost: data.totalCost || 0, + totalTarget: data.totalTarget || 0, + expectedProfit: data.expectedProfit || 0, + totalRevenue: data.totalRevenue || 0, + totalProfit: data.totalProfit || 0 + }) + } catch (e) { + console.error('统计加载失败:', e) + alert('加载失败:' + e.message) + } finally { + setLoading(false) + } + } + + // 点击统计项跳转到列表页 + const handleItemClick = (type, value) => { + const filterKey = getFilterKey(type) + // 跳转到列表页并带上筛选条件 + window.location.hash = `#/list?filter=${filterKey}&value=${encodeURIComponent(value)}` + } + + // 根据统计类型获取对应的筛选字段名 + const getFilterKey = (type) => { + const map = { + status: 'status', + category: 'category', + packaging: 'packaging', + rarity: 'rarity', + version: 'version', + gradingCompany: 'gradingCompany', + gradingScore: 'gradingScore', + specialMark: 'specialMark', + numberCategory: 'numberCategory', + gradingCompany: 'gradingCompany', + gradingScore: 'gradingScore', + profitLoss: 'profitLoss' + } + return map[type] || type + } + + // 颜色配置 + const colors = { + packaging: { '标十': '#22c55e', '标百': '#3b82f6', '单张': '#f59e0b', '裸钞': '#64748b' }, + rarity: { '通货': '#64748b', '特色': '#22c55e', '少见': '#3b82f6', '稀有': '#8b5cf6', '珍品': '#ef4444', '孤品': '#fbbf24' }, + status: { 'in_collection': '#22c55e', 'selling': '#f59e0b', 'sold': '#ef4444', 'grading': '#8b5cf6', 'repairing': '#f97316', 'transit': '#06b6d4', 'seeking': '#ec4899' }, + category: { '自持': '#22c55e', '寄存': '#3b82f6', '寄售': '#f59e0b', '共有': '#8b5cf6', '寻号': '#06b6d4', '生肖钞': '#22c55e', '其他': '#64748b' }, + profitLoss: { 'profit': '#22c55e', 'loss': '#ef4444' }, + numberCategory: { '无2347': '#8b5cf6', '无347': '#ef4444', '无247': '#fbbf24', '无47': '#3b82f6', '无4': '#06b6d4', '带4': '#22c55e', '其他': '#64748b' }, + version: {}, + gradingCompany: {}, + gradingScore: {}, + specialMark: {} + } + + // 标签映射 + const labels = { + status: { + 'in_collection': '收藏中', + 'selling': '出售中', + 'sold': '已售', + 'grading': '送评中', + 'repairing': '修复中', + 'transit': '在途中', + 'seeking': '寻号中' + }, + profitLoss: { + 'profit': '盈利', + 'loss': '亏损' + } + } + + const colorPalette = ['#22c55e', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#f97316', '#14b8a6', '#a855f7'] + const getColor = (type, value) => { + if (colors[type]?.[value]) return colors[type][value] + // 动态生成颜色 + const key = String(value) + let hash = 0 + for (let i = 0; i < key.length; i++) hash = key.charCodeAt(i) + ((hash << 5) - hash) + return colorPalette[Math.abs(hash) % colorPalette.length] + } + + const getLabel = (type, value) => { + return labels[type]?.[value] || value + } + + const formatMoney = (val) => { + if (val === null || val === undefined) return '0' + return Number(val).toLocaleString('zh-CN') + } + + const numberCategoryOrder = ['无2347', '无347', '无247', '无47', '无4', '带4', '其他'] + const getSortedData = (data, type) => { + if (type === 'numberCategory') { + return [...data].sort((a, b) => { + const order = numberCategoryOrder.indexOf(a.numberCategory) + const order2 = numberCategoryOrder.indexOf(b.numberCategory) + return order - order2 + }) + } + return data + } + + const DistributionCard = ({ title, data, type, valueKey, labelKey }) => ( +
+
{title}
+
+ {getSortedData(data, type).map((item, index) => { + const value = item[valueKey] + const label = getLabel(type, item[labelKey] || value) + const color = getColor(type, value) + return ( +
handleItemClick(type, value)} + style={{ + background: 'rgba(255,255,255,0.05)', + borderRadius: '8px', + padding: '10px', + cursor: 'pointer', + border: '1px solid rgba(255,255,255,0.05)', + transition: 'all 0.2s' + }} + onMouseEnter={(e) => { + e.currentTarget.style.background = 'rgba(255,255,255,0.1)' + e.currentTarget.style.borderColor = 'rgba(251, 191, 36, 0.3)' + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'rgba(255,255,255,0.05)' + e.currentTarget.style.borderColor = 'rgba(255,255,255,0.05)' + }} + > +
+
+
+
+ {label} +
+
+
+ {item.count} +
+
+
+ ) + })} +
+ {data.length > 6 && ( +
+ 共{data.length}项,显示前 6 项 +
+ )} +
+ ) + + if (loading) { + return ( +
+
加载中...
+
+ ) + } + + const gradedCount = stats.byGrading.find(g => g.isGraded === true)?.count || 0 + const ungradedCount = stats.byGrading.find(g => g.isGraded === false)?.count || 0 + + return ( +
+ {/* 顶部 */} +
+
+
统计分析
+
点击统计项查看明细
+
+
v{APP_VERSION}
+
+ +
+ {/* 总统计 */} +
+
📊 总统计
+
+
+
总数量
+
{stats.totalCount}
+
+
+
已评级
+
{gradedCount}
+
+
+
未评级
+
{ungradedCount}
+
+
+
+ + {/* 财务统计 */} +
+
💰 财务统计
+
+
+
总成本
+
¥{formatMoney(stats.totalCost)}
+
+
+
总收入
+
¥{formatMoney(stats.totalRevenue)}
+
+
+
预期利润
+
+¥{formatMoney(stats.expectedProfit)}
+
+
+
已实现利润
+
+¥{formatMoney(stats.totalProfit)}
+
+
+
+ + {/* 盈亏统计移到顶部 */} + + + + + + + + + + +
+
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/frontend_src/utils/api.js similarity index 100% rename from frontend/src/utils/api.js rename to frontend/frontend_src/utils/api.js diff --git a/frontend/src/utils/errorCodes.js b/frontend/frontend_src/utils/errorCodes.js similarity index 100% rename from frontend/src/utils/errorCodes.js rename to frontend/frontend_src/utils/errorCodes.js diff --git a/frontend/src.bak/App.jsx b/frontend/src.bak/App.jsx new file mode 100644 index 0000000..375b78d --- /dev/null +++ b/frontend/src.bak/App.jsx @@ -0,0 +1,110 @@ +import React, { useState, useEffect } from 'react' +import Home from './pages/Home' +import Settings from './pages/Settings' +import List from './pages/List' +import Add from './pages/Add' +import Stats from './pages/Stats' +import Login from './pages/Login' +import Detail from './pages/Detail' +import Edit from './pages/Edit' +import Admin from './pages/Admin' + +export default function App() { + const [path, setPath] = useState(window.location.hash.slice(1) || '/') + + useEffect(() => { + const handleHashChange = () => { + setPath(window.location.hash.slice(1) || '/') + } + window.addEventListener('hashchange', handleHashChange) + return () => window.removeEventListener('hashchange', handleHashChange) + }, []) + + const handleNavigate = (newPath) => { + window.location.hash = '#' + newPath + setPath(newPath) + } + + const getComponent = () => { + const basePath = path.split('?')[0] + if (basePath === '/') return + if (basePath === '/stats') return + if (basePath === '/list') return + if (basePath === '/add') return + if (basePath === '/login') return + if (basePath === '/settings') return + if (basePath === '/admin') return + if (basePath.startsWith('/edit')) return + if (basePath.startsWith('/detail')) return + return + } + + const token = localStorage.getItem('token') + const userStr = localStorage.getItem('user') + let user = null + try { + user = userStr ? JSON.parse(userStr) : null + } catch (e) { + console.error('Parse user error:', e) + } + const isAdmin = user && user.role === 'admin' + + // 登录页面独立渲染,不显示底部导航 + if (!token) { + // 强制刷新页面,确保状态同步 + if (path !== '/login') { + window.location.hash = '#/login' + } + return + } + + // 如果已登录且在登录页,强制跳转到首页 + if (path === '/login') { + // 使用 href 强制刷新页面 + window.location.href = window.location.origin + window.location.pathname + '#/' + return null + } + + return ( +
+ {getComponent()} + +
+ {[ + { path: '/', icon: '🏠', label: '首页' }, + { path: '/stats', icon: '📊', label: '统计' }, + { path: '/list', icon: '📚', label: '藏品' }, + { path: '/add', icon: '🎯', label: '添加' }, + ...(isAdmin ? [{ path: '/admin', icon: '⚙️', label: '管理' }] : []) + ].map(tab => ( +
handleNavigate(tab.path)} + style={{ + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + color: path === tab.path ? '#fbbf24' : '#94a3b8', + cursor: 'pointer' + }} + > +
{tab.icon}
+
{tab.label}
+
+ ))} +
+
+ ) +} diff --git a/frontend/src.bak/config/version.js b/frontend/src.bak/config/version.js new file mode 100644 index 0000000..cf4abcc --- /dev/null +++ b/frontend/src.bak/config/version.js @@ -0,0 +1,24 @@ +// 版本号配置文件 +// ⚠️ 注意:版本号现在统一在根目录 VERSION 文件中管理 +// 修改版本号只需修改 ../../VERSION 文件中的 VERSION=xxx + +// 从环境变量读取(vite.config.js 注入) +export const APP_VERSION = import.meta.env.APP_VERSION || '1.0.0' + +// 版本信息 +export const VERSION_INFO = { + version: APP_VERSION, + buildDate: new Date().toISOString().split('T')[0], + name: '甲辰收藏' +} + +// 获取完整标题 +export const getAppTitle = () => { + return `${VERSION_INFO.name} v${VERSION_INFO.version}` +} + +export default { + APP_VERSION, + VERSION_INFO, + getAppTitle +} diff --git a/frontend/src.bak/index.css b/frontend/src.bak/index.css new file mode 100644 index 0000000..5c1e0df --- /dev/null +++ b/frontend/src.bak/index.css @@ -0,0 +1 @@ +/* 全局样式 */ diff --git a/frontend/src.bak/main.jsx b/frontend/src.bak/main.jsx new file mode 100644 index 0000000..ef33be1 --- /dev/null +++ b/frontend/src.bak/main.jsx @@ -0,0 +1,23 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { HashRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +// 渲染应用 +const root = document.getElementById('root') +try { + ReactDOM.createRoot(root).render( + + + + + + ) + console.log('App rendered successfully') +} catch (e) { + console.error('Render error:', e) + root.innerHTML = '
Error: ' + e.message + '
' +} +// v2.7.2 build +// Force rebuild v2.7.2 - 1773392274 diff --git a/frontend/src.bak/pages/Add.jsx b/frontend/src.bak/pages/Add.jsx new file mode 100644 index 0000000..f09e0cb --- /dev/null +++ b/frontend/src.bak/pages/Add.jsx @@ -0,0 +1,673 @@ +// 添加藏品页面 - 支持 AI 识别/手工录入/批量录入 +import React, { useState, useRef } from 'react' +import { APP_VERSION } from '../config/version' +const numberCategoryOptions = [{value:'无2347',label:'无2347'},{value:'无347',label:'无347'},{value:'无247',label:'无247'},{value:'无47',label:'无47'},{value:'无4',label:'无4'},{value:'带4',label:'带4'},{value:'其他',label:'其他'}]; + +// 字段转换函数 +const convertField = (obj) => { + const map = { + id: 'f99_90_id', userId: 'f99_91_user_id', + name: 'f01_01_name', code: 'f01_02_code', category: 'f01_03_category', + status: 'f01_04_status', remark: 'f01_05_remark', + prefixSerial: 'f02_10_prefix_serial', version: 'f02_11_version', + packaging: 'f02_12_packaging', rarity: 'f02_13_rarity', + isGraded: 'f03_20_is_graded', gradingCompany: 'f03_21_grading_company', + gradingScore: 'f03_22_grading_score', threeStar: 'f03_23_three_star', + specialMark: 'f04_30_special_mark', serialFeature: 'f04_31_serial_feature', numberCategory: 'f02_14_number_category', + issuer: 'f04_32_issuer', issueYear: 'f04_33_issue_year', + material: 'f04_34_material', denomination: 'f04_35_denomination', + issueQuantity: 'f04_36_issue_quantity', + costPrice: 'f05_40_cost_price', targetPrice: 'f05_41_target_price', + goalPrice: 'f05_42_goal_price', repairFee: 'f05_43_repair_fee', + gradingFee: 'f05_44_grading_fee', purpose: 'f06_50_purpose' + } + const result = {} + const numberFields = ['costPrice', 'targetPrice', 'goalPrice', 'repairFee', 'gradingFee'] + for (const key in obj) { + let value = obj[key] + if (value === '' || value === null) value = null + else if (numberFields.includes(key)) { + value = parseFloat(value) + if (isNaN(value)) value = null + } + result[map[key] || key] = value + } + return result +} + +// 通用 Input 组件 +const Input = ({ form, handleChange, label, field, type = 'text', options = null }) => { + const onChange = (e) => { + const value = e.target.value + if (type === 'number' && value !== '') { + const num = parseFloat(value) + handleChange(field, isNaN(num) ? '' : num) + } else handleChange(field, value) + } + return ( +
+
{label}
+ {options ? ( + + ) : ( + + )} +
+ ) +} + +const getDefaultForm = () => ({ + name: '藏品', code: '', category: '自持', rarity: '通货', prefixSerial: '', + version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张', + material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false, + gradingCompany: '', gradingScore: '', threeStar: false, specialMark: '', + serialFeature: '', numberCategory: '', issuer: '中国人民银行', issueYear: '2024', + costPrice: '', targetPrice: '', goalPrice: '', repairFee: '', gradingFee: '', remark: '' +}) + +export default function Add() { + // 根据 URL 参数确定默认标签 + const params = new URLSearchParams(window.location.hash.split('?')[1] || '') + const modeParam = params.get('mode') + const defaultTab = modeParam === 'manual' ? 'manual' : 'ai' + + const [activeTab, setActiveTab] = useState(defaultTab) // ai, manual, batch + const [form, setForm] = useState(getDefaultForm()) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [recognizing, setRecognizing] = useState(false) + const [selectedImage, setSelectedImage] = useState(null) + const [imagePreview, setImagePreview] = useState(null) + const [recognizedImage, setRecognizedImage] = useState(null) + const fileInputRef = useRef(null) + const imageFileInputRef = useRef(null) + const [uploadImages, setUploadImages] = useState([]) + const [tempImage, setTempImage] = useState(null) // OCR识别后的临时图片 + const maxImages = 1 + + const handleUploadImage = (e) => { + const files = Array.from(e.target.files) + if (files.length === 0) return + if (files.length + uploadImages.length > maxImages) { + alert(`最多只能上传${maxImages}张图片`) + return + } + const newImages = files.map(file => ({ + file, + preview: URL.createObjectURL(file), + name: file.name, + size: file.size + })) + setUploadImages([...uploadImages, ...newImages]) + } + + const statusOptions = [ + { value: 'in_collection', label: '收藏中' }, + { value: 'selling', label: '出售中' }, + { value: 'sold', label: '已售' }, + { value: 'grading', label: '送评中' }, + { value: 'repairing', label: '修复中' }, + { value: 'transit', label: '在途中' }, + { value: 'seeking', label: '寻号中' }, + { value: 'other', label: '其他' } + ] + + const categoryOptions = [ + { value: '自持', label: '自持' }, + { value: '寄存', label: '寄存' }, + { value: '寄售', label: '寄售' }, + { value: '共有', label: '共有' }, + { value: '其他', label: '其他' } + ] + + const rarityOptions = [ + { value: '通货', label: '通货' }, + { value: '特色', label: '特色' }, + { value: '少见', label: '少见' }, + { value: '稀有', label: '稀有' }, + { value: '珍品', label: '珍品' }, + { value: '孤品', label: '孤品' } + ] + + const packagingOptions = [ + { value: '标十', label: '标十' }, + { value: '标百', label: '标百' }, + { value: '单张', label: '单张' }, + { value: '裸钞', label: '裸钞' } + ] + + const handleChange = (key, value) => { + setForm({ ...form, [key]: value }) + // 只有设置了出售价(goalPrice)才自动设置为"已售" + if (key === 'goalPrice' && value) { + setForm(prev => ({ ...prev, status: 'sold' })) + } + // 冠字号变化时自动分类 + if (key === 'prefixSerial') { + const serial = value + let cat = '' + const match = serial.match(/J(\d{9})/) + const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9) + if (digits) { + if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347' + else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347' + else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247' + else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47' + else if (digits.includes('7') && !digits.includes('4')) cat = '无4' + else if (digits.includes('4')) cat = '带4' + else cat = '其他' + } + setForm(prev => ({ ...prev, numberCategory: cat })) + } + // 版别变化时同步发行年份 + if (key === 'version') { + const yearMatch = value.match(/(20\d{2})/) + if (yearMatch) { + setForm(prev => ({ ...prev, issueYear: yearMatch[1] })) + } + } + } + + const handleSelectImage = (e) => { + const file = e.target.files[0] + if (!file) return + setSelectedImage(file) + const reader = new FileReader() + reader.onload = (e) => setImagePreview(e.target.result) + reader.readAsDataURL(file) + } + + const handleRecognize = async () => { + if (!selectedImage) { setError('请先选择图片'); return } + setRecognizing(true) + setError('') + const token = localStorage.getItem('token') + const formData = new FormData() + formData.append('image', selectedImage) + + // 使用 XMLHttpRequest 替代 fetch + const data = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('POST', '/api/ocr/recognize') + xhr.setRequestHeader('Authorization', 'Bearer ' + token) + + xhr.onload = function() { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const data = JSON.parse(xhr.responseText) + resolve(data) + } catch (e) { + reject(new Error('JSON解析失败: ' + xhr.responseText.substring(0, 100))) + } + } else { + try { + const data = JSON.parse(xhr.responseText) + reject(new Error(data.error?.message || data.detail || '识别失败')) + } catch (e) { + reject(new Error('请求失败: ' + xhr.status)) + } + } + } + + xhr.onerror = function() { + reject(new Error('网络错误')) + } + + xhr.send(formData) + }) + try { + if (data.fields) { + const recognizedForm = { ...getDefaultForm() } + // 直接对应 AI 返回的字段,不需要二次解析 + if (data.fields.issuer) recognizedForm.issuer = data.fields.issuer + if (data.fields.version) recognizedForm.version = data.fields.version + if (data.fields.denomination) recognizedForm.denomination = data.fields.denomination + if (data.fields.prefix_serial) recognizedForm.prefixSerial = data.fields.prefix_serial + if (data.fields.packaging) recognizedForm.packaging = data.fields.packaging + if (data.fields.grading_company) recognizedForm.gradingCompany = data.fields.grading_company + if (data.fields.grading_score) recognizedForm.gradingScore = data.fields.grading_score + if (data.fields.special_mark && data.fields.special_mark !== '无') recognizedForm.specialMark = data.fields.special_mark + if (data.fields.serial_feature && data.fields.serial_feature !== '无') recognizedForm.serialFeature = data.fields.serial_feature + // 布尔字段 + if (data.fields.is_graded !== undefined) recognizedForm.isGraded = data.fields.is_graded + if (data.fields.three_star !== undefined) recognizedForm.threeStar = data.fields.three_star + + // 保存识别的图片信息(包含服务器临时路径) + const newImage = { + file: selectedImage, + preview: URL.createObjectURL(selectedImage), + name: selectedImage.name, + size: selectedImage.size, + temp_image: data.temp_image // 保存临时图片信息 + } + setUploadImages([newImage]) + setTempImage(data.temp_image) // 保存临时图片信息 + + setForm(recognizedForm) + setActiveTab('manual') + console.log('AI 识别结果:', recognizedForm) + console.log('识别图片:', newImage) + } else setError('识别结果为空') + } catch (e) { setError('识别失败:' + e.message) } + finally { setRecognizing(false) } + } + + const handleSave = async () => { + if (!form.name || !form.version) { setError('名称和版别为必填项'); return } + setSaving(true) + setError('') + const token = localStorage.getItem('token') + // 保存前自动根据冠字号设置号码分类 + const formWithCategory = { ...form } + if (formWithCategory.prefixSerial && !formWithCategory.numberCategory) { + const serial = formWithCategory.prefixSerial + const match = serial.match(/J(\d{9})/) + const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9) + if (digits) { + let cat = '' + if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347' + else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347' + else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247' + else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47' + else if (digits.includes('7') && !digits.includes('4')) cat = '无4' + else if (digits.includes('4')) cat = '带4' + else cat = '其他' + formWithCategory.numberCategory = cat + } + } + + const formData = convertField(formWithCategory) + try { + // 1. 保存藏品信息(先检查是否重复) + const res = await fetch('/api/collections', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: JSON.stringify(formData) + }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.error?.message || data.detail || '保存失败') + } + const result = await res.json() + let collectionId = result.f99_90_id || result.id + + // 检查是否有错误(重复编号等) + if (result.error) { + throw new Error(result.error.message || '保存失败') + } + + // 检查是否有重复警告 + if (result.warning && result.warning.code === 'DUPLICATE_SERIAL') { + const { warning } = result + const confirmed = window.confirm( + `⚠️ 发现重复冠字号!\n\n` + + `冠字号:${warning.existing_collection.prefix_serial}\n` + + `已存在于:${warning.existing_collection.name} (编号:${warning.existing_collection.code})\n\n` + + `是否继续保存?` + ) + + if (!confirmed) { + setSaving(false) + return + } + + // 用户确认继续,再次调用 API(添加 force=true 参数) + const forceRes = await fetch('/api/collections?force=true', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: JSON.stringify(formData) + }) + + if (!forceRes.ok) { + const forceData = await forceRes.json() + throw new Error(forceData.error?.message || '保存失败') + } + + const forceResult = await forceRes.json() + collectionId = forceResult.f99_90_id || forceResult.id + console.log('藏品保存成功(确认重复),ID:', collectionId) + } else if (collectionId) { + console.log('藏品保存成功,ID:', collectionId) + } else { + throw new Error('保存失败:未返回藏品 ID') + } + + // 上传图片(无论是否重复都执行) + let totalUploadCount = 0 + + // 2. 处理识别的图片(如果有临时图片,直接认领;否则上传) + if (uploadImages.length > 0 && collectionId) { + console.log('处理图片,数量:', uploadImages.length) + + for (const img of uploadImages) { + // 检查是否有临时图片(OCR识别后保存的) + if (img.temp_image && img.temp_image.id) { + // 认领临时图片 + try { + const claimRes = await fetch(`/api/ocr/claim-temp-image?temp_id=${img.temp_image.id}&collection_id=${collectionId}`, { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token } + }) + if (claimRes.ok) { + totalUploadCount++ + console.log('✅ 临时图片认领成功') + } else { + console.error('临时图片认领失败:', await claimRes.text()) + } + } catch (claimErr) { + console.error('临时图片认领异常:', claimErr) + } + } else { + // 普通上传 + const imgFormData = new FormData() + imgFormData.append('file', img.file) + try { + const uploadRes = await fetch(`/api/collections/upload-image?collection_id=${collectionId}`, { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token }, + body: imgFormData + }) + if (uploadRes.ok) { + totalUploadCount++ + console.log(`图片上传成功`) + } else { + console.error('图片上传失败:', await uploadRes.text()) + } + } catch (uploadErr) { + console.error('图片上传异常:', uploadErr) + } + } + } + + console.log(`✅ 共处理 ${totalUploadCount} 张图片`) + } + + alert('保存成功!' + (totalUploadCount > 0 ? `已上传${totalUploadCount}张图片` : '')) + window.location.hash = '#/list' + window.refreshList?.() + window.refreshHome?.() + } catch (e) { + console.error('保存失败:', e) + alert('保存失败:' + e.message) + } + finally { setSaving(false) } + } + + return ( +
+ {/* 顶部标签切换 */} +
+
+ + + +
+
+ + {error && ( +
⚠️ {error}
+ )} + + {/* AI 识别模式 */} + {activeTab === 'ai' && ( +
+
+ + {imagePreview ? ( +
+ 已选择图片 +
+ + +
+
+ ) : ( +
+
{ fileInputRef.current.setAttribute('capture', 'environment'); fileInputRef.current.click(); }} + style={{ padding: '24px', background: 'rgba(59, 130, 246, 0.1)', border: '2px dashed rgba(59, 130, 246, 0.5)', borderRadius: '12px', cursor: 'pointer', marginBottom: '16px', textAlign: 'center' }}> +
📷
+
拍照识别
+
使用相机拍照并识别
+
+
{ fileInputRef.current.removeAttribute('capture'); fileInputRef.current.click(); }} + style={{ padding: '24px', background: 'rgba(34, 197, 94, 0.1)', border: '2px dashed rgba(34, 197, 94, 0.5)', borderRadius: '12px', cursor: 'pointer', textAlign: 'center' }}> +
🖼️
+
从相册选择
+
从相册选择已有图片
+
+
+ )} +
+
+
💡 识别说明
+
    +
  • 支持拍照或从相册选择图片
  • +
  • 自动识别名称、版别、冠字序号等字段
  • +
  • 识别结果可手动修改完善
  • +
  • 建议拍摄清晰、光线充足的正面照片
  • +
+
+
+ )} + + {/* 手工录入模式 - 完整表单 */} + {activeTab === 'manual' && ( +
+ {/* 图片上传区域 */} +
+
+
藏品图片 ({uploadImages.length}/1)
+
+ + + {uploadImages.length > 0 ? ( +
+ {uploadImages.map((img, index) => ( +
+ {img.name} +
{img.name}
+ +
{index + 1}/{uploadImages.length}
+
+ ))} + {uploadImages.length < 1 && ( +
imageFileInputRef.current?.click()} style={{ + aspectRatio: '1.5', + background: 'rgba(255,255,255,0.05)', + border: '2px dashed rgba(255,255,255,0.3)', + borderRadius: '12px', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + color: '#94a3b8', + fontSize: '13px' + }}> +
📷
+
添加图片
+
最多可上传 1 张
+
+ )} +
+ ) : ( +
imageFileInputRef.current?.click()} style={{ + aspectRatio: '1.5', + background: 'rgba(255,255,255,0.05)', + border: '2px dashed #fbbf24', + borderRadius: '12px', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + color: '#fbbf24', + fontSize: '13px' + }}> +
📷
+
点击上传图片
+
最多可上传 1 张
+
+ )} +
+ + {/* 基本信息 */} +
+
基本信息
+
+ + + + + + + + + +
+
+ + {/* 评级信息 */} +
+
评级信息
+
+
+ handleChange('isGraded', e.target.checked)} + style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: '#22c55e' }} /> + +
+
+ handleChange('threeStar', e.target.checked)} + style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: '#22c55e' }} /> + +
+
+
+ + +
+
+ + {/* 特殊信息 */} +
+
特殊信息
+
+ + + + + + + +
+
+ + {/* 价格信息 */} +
+
价格信息
+
+ + + + + + +
+
+ + {/* 备注 */} +
+
备注
+