diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..73f416f
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,9 @@
+__pycache__/
+*.pyc
+*.pyo
+.git
+.env
+uploads/*
+!uploads/.gitkeep
+logs/*
+*.log
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..9f5443e
--- /dev/null
+++ b/backend/.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/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..f4c2a67
--- /dev/null
+++ b/backend/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/README.md b/backend/README.md
new file mode 100644
index 0000000..834be5c
--- /dev/null
+++ b/backend/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/app/core/__init__.py b/backend/app/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py
new file mode 100644
index 0000000..3702b7e
--- /dev/null
+++ b/backend/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/app/core/database.py b/backend/app/core/database.py
new file mode 100644
index 0000000..3690a30
--- /dev/null
+++ b/backend/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/app/core/error_handler.py b/backend/app/core/error_handler.py
new file mode 100644
index 0000000..c3d29ca
--- /dev/null
+++ b/backend/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/app/core/logging_config.py b/backend/app/core/logging_config.py
new file mode 100644
index 0000000..d5b2960
--- /dev/null
+++ b/backend/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/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..3dc746d
--- /dev/null
+++ b/backend/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/app/middleware/logging.py b/backend/app/middleware/logging.py
new file mode 100644
index 0000000..684a55a
--- /dev/null
+++ b/backend/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/app/models/__init__.py b/backend/app/models/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/models/models.py b/backend/app/models/models.py
new file mode 100644
index 0000000..4d96a70
--- /dev/null
+++ b/backend/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/app/routers/__init__.py b/backend/app/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
new file mode 100644
index 0000000..6d49d97
--- /dev/null
+++ b/backend/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/app/routers/collections.py b/backend/app/routers/collections.py
new file mode 100644
index 0000000..ea44f59
--- /dev/null
+++ b/backend/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/app/routers/ocr.py b/backend/app/routers/ocr.py
new file mode 100644
index 0000000..c49155d
--- /dev/null
+++ b/backend/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/app/routers/operations.py b/backend/app/routers/operations.py
new file mode 100644
index 0000000..0881599
--- /dev/null
+++ b/backend/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/app/routers/users.py b/backend/app/routers/users.py
new file mode 100644
index 0000000..6928b85
--- /dev/null
+++ b/backend/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/app/schemas/__init__.py b/backend/app/schemas/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py
new file mode 100644
index 0000000..9ef13c3
--- /dev/null
+++ b/backend/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/app/services/oss.py b/backend/app/services/oss.py
new file mode 100644
index 0000000..6c52c87
--- /dev/null
+++ b/backend/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/app/services/sms.py b/backend/app/services/sms.py
new file mode 100644
index 0000000..e8a0a7b
--- /dev/null
+++ b/backend/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/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..bda1a26
--- /dev/null
+++ b/backend/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/uploads/.gitkeep b/backend/uploads/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/frontend/package.json b/frontend/package.json
index d401af3..ad0ec71 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "jiachenlong-frontend",
- "version": "1.0.1",
+ "version": "1.2.12",
"private": true,
"description": "甲辰藏品管理系统 - 移动端前端",
"scripts": {
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000..375b78d
--- /dev/null
+++ b/frontend/src/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/config/version.js b/frontend/src/config/version.js
new file mode 100644
index 0000000..cf4abcc
--- /dev/null
+++ b/frontend/src/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/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..5c1e0df
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1 @@
+/* 全局样式 */
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..ef33be1
--- /dev/null
+++ b/frontend/src/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/pages/Add.jsx b/frontend/src/pages/Add.jsx
new file mode 100644
index 0000000..f09e0cb
--- /dev/null
+++ b/frontend/src/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}
+
+
{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 张
+
+ )}
+
+
+ {/* 基本信息 */}
+
+
+ {/* 评级信息 */}
+
+
+ {/* 特殊信息 */}
+
+
+ {/* 价格信息 */}
+
+
+ {/* 备注 */}
+
+
+ {/* 保存按钮 */}
+
+
+
+
+ )}
+
+ {/* 批量录入模式 */}
+ {activeTab === 'batch' && (
+
+
🚧
+
批量录入开发中
+
敬请期待后续版本
+
+ )}
+
+ {/* 版本号 */}
+
v{APP_VERSION}
+
+ )
+}
diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx
new file mode 100644
index 0000000..653f65f
--- /dev/null
+++ b/frontend/src/pages/Admin.jsx
@@ -0,0 +1,418 @@
+import React, { useState, useEffect } from 'react'
+import { APP_VERSION } from '../config/version'
+
+export default function Admin() {
+ const [users, setUsers] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [showAddModal, setShowAddModal] = useState(false)
+ const [editingUser, setEditingUser] = useState(null)
+ const [newUser, setNewUser] = useState({ username: '', password: '', email: '', role: 'user' })
+ const token = localStorage.getItem('token')
+
+ // 检查权限
+ const userStr = localStorage.getItem('user')
+ let user = null
+ try {
+ user = userStr ? JSON.parse(userStr) : null
+ } catch (e) {}
+
+ if (!user || user.role !== 'admin') {
+ return (
+
+
+
🚫
+
无权访问
+
仅管理员可以访问用户管理界面
+
+
+ )
+ }
+
+ useEffect(() => {
+ fetchUsers()
+ }, [])
+
+ const fetchUsers = async () => {
+ try {
+ const res = await fetch('/api/admin/users?page=1&limit=100', {
+ headers: { 'Authorization': `Bearer ${token}` }
+ })
+ if (!res.ok) throw new Error('获取用户列表失败')
+ const data = await res.json()
+ setUsers(data)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleDeleteUser = async (userId, username) => {
+ if (!confirm(`确定要删除用户 "${username}" 吗?`)) return
+
+ try {
+ const res = await fetch(`/api/admin/users/${userId}`, {
+ method: 'DELETE',
+ headers: { 'Authorization': `Bearer ${token}` }
+ })
+ if (!res.ok) throw new Error('删除失败')
+ fetchUsers()
+ } catch (err) {
+ alert(err.message)
+ }
+ }
+
+ const handleAddUser = async () => {
+ if (!newUser.username || !newUser.password) {
+ alert('用户名和密码为必填项')
+ return
+ }
+
+ // 如果 email 为空,不传这个字段
+ const payload = { ...newUser }
+ if (!payload.email) {
+ delete payload.email
+ }
+
+ try {
+ const res = await fetch('/api/auth/register', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ },
+ body: JSON.stringify(payload)
+ })
+
+ if (!res.ok) {
+ const data = await res.json()
+ throw new Error(data.detail || '添加失败')
+ }
+
+ alert('添加成功!')
+ setShowAddModal(false)
+ setNewUser({ username: '', password: '', email: '', role: 'user' })
+ fetchUsers()
+ } catch (err) {
+ alert(err.message)
+ }
+ }
+
+ const handleEditUser = async () => {
+ if (!editingUser.username) {
+ alert('用户名不能为空')
+ return
+ }
+
+ // 验证密码
+ if (editingUser.newPassword || editingUser.confirmPassword) {
+ if (!editingUser.newPassword || !editingUser.confirmPassword) {
+ alert('请填写完整密码信息')
+ return
+ }
+ if (editingUser.newPassword !== editingUser.confirmPassword) {
+ alert('两次输入的密码不一致')
+ return
+ }
+ if (editingUser.newPassword.length < 6) {
+ alert('密码至少 6 个字符')
+ return
+ }
+ }
+
+ try {
+ // 准备更新数据
+ const updateData = {
+ username: editingUser.username,
+ email: editingUser.email,
+ role: editingUser.role
+ }
+
+ // 如果有新密码,添加到更新数据
+ if (editingUser.newPassword && editingUser.newPassword.trim()) {
+ updateData.password = editingUser.newPassword
+ }
+
+ const res = await fetch(`/api/admin/users/${editingUser.id}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ },
+ body: JSON.stringify(updateData)
+ })
+
+ if (!res.ok) {
+ const data = await res.json()
+ throw new Error(data.detail || '更新失败')
+ }
+
+ alert('更新成功!')
+ setEditingUser(null)
+ fetchUsers()
+ } catch (err) {
+ alert(err.message)
+ }
+ }
+
+ if (loading) return 加载中...
+ if (error) return ⚠️ {error}
+
+ return (
+
+
+
+ ⚙️ 用户管理
+
+
+
+
+ {/* 用户列表 - 卡片式布局 */}
+
+ {users.map((user) => (
+
+
+
+
+
{user.username}
+
+ {user.role === 'admin' ? '👑 管理员' : '👤 用户'}
+
+
+
+ 📧 {user.email || '未设置'} | 📱 {user.phone || '未设置'}
+
+
+
+
藏品数
+
window.location.hash = '#/list?userId=' + user.id} title='点击查看'>{user.collection_count || 0}
+
+
+
+
+ 注册时间:{user.created_at ? new Date(user.created_at).toLocaleDateString('zh-CN') : '-'}
+
+
+
+ {user.role !== 'admin' && (
+
+ )}
+
+
+
+ ))}
+
+
+ {users.length === 0 && (
+
+ 暂无用户数据
+
+ )}
+
+ {/* 添加用户模态框 */}
+ {showAddModal && (
+
+
+
添加用户
+
+
+
+ setNewUser({ ...newUser, username: e.target.value })}
+ style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }}
+ />
+
+
+
+
+ setNewUser({ ...newUser, password: e.target.value })}
+ style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }}
+ />
+
+
+
+
+ setNewUser({ ...newUser, email: e.target.value })}
+ style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* 编辑用户模态框 */}
+ {editingUser && (
+
+
+
编辑用户
+
+ {/* 第一部分:基本信息 */}
+
+
📋 基本信息
+
+
+
+ setEditingUser({ ...editingUser, username: e.target.value })}
+ style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }}
+ />
+
+
+
+
+ setEditingUser({ ...editingUser, email: e.target.value })}
+ style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }}
+ />
+
+
+
+
+
+
+
+
+ {/* 第二部分:修改密码 */}
+
+
🔐 修改密码(可选)
+
+
+
+ setEditingUser({ ...editingUser, newPassword: e.target.value })}
+ placeholder="请输入新密码(至少 6 位)"
+ style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }}
+ />
+
+
+
+
+ setEditingUser({ ...editingUser, confirmPassword: e.target.value })}
+ placeholder="请再次输入新密码"
+ style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }}
+ />
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* 版本号 - 移到表格下方,避免被底部导航遮挡 */}
+
+ v{APP_VERSION}
+
+
+ )
+}
diff --git a/frontend/src/pages/BatchMode.jsx b/frontend/src/pages/BatchMode.jsx
new file mode 100644
index 0000000..a8fcef7
--- /dev/null
+++ b/frontend/src/pages/BatchMode.jsx
@@ -0,0 +1,513 @@
+import React, { useState, useRef } from 'react'
+
+// 字段名转换函数:驼峰转下划线
+const convertField = (obj) => {
+ const map = {
+ prefixSerial: 'prefix_serial',
+ isGraded: 'is_graded',
+ gradingCompany: 'grading_company',
+ gradingScore: 'grading_score',
+ threeStar: 'three_star',
+ specialMark: 'special_mark',
+ serialFeature: 'serial_feature',
+ issueYear: 'issue_year',
+ issueQuantity: 'issue_quantity',
+ costPrice: 'cost_price',
+ targetPrice: 'target_price',
+ goalPrice: 'goal_price',
+ repairFee: 'repair_fee',
+ gradingFee: 'grading_fee'
+ }
+ const result = {}
+ for (const key in obj) {
+ result[map[key] || key] = obj[key]
+ }
+ return result
+}
+
+export default function BatchMode() {
+ const [images, setImages] = useState([])
+ const [currentIndex, setCurrentIndex] = useState(0)
+ const [result, setResult] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [savedCount, setSavedCount] = useState(0)
+ const fileInputRef = useRef(null)
+
+ const statusOptions = [
+ { value: 'in_collection', label: '收藏中' },
+ { value: 'selling', label: '在售' },
+ { value: 'sold', label: '已售' },
+ { value: 'grading', label: '送评' },
+ { value: 'transit', label: '在途' },
+ { value: 'other', label: '其他' }
+ ]
+
+ const packagingOptions = [
+ { value: '单张', label: '单张' },
+ { value: '标十', label: '标十' },
+ { value: '标百', label: '标百' },
+ { value: '裸钞', label: '裸钞' }
+ ]
+
+ const categoryOptions = [
+ { value: '自持', label: '自持' },
+ { 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 handleChooseImages = (e) => {
+ const files = Array.from(e.target.files || [])
+ if (files.length > 0) {
+ setImages(files.slice(0, 10))
+ setCurrentIndex(0)
+ setResult(null)
+ setSavedCount(0)
+ // Auto OCR first image
+ processOCR(files[0])
+ }
+ }
+
+ const processOCR = async (file) => {
+ setLoading(true)
+ try {
+ const formData = new FormData()
+ formData.append('image', file)
+
+ const res = await fetch('/api/ocr/recognize', { method: 'POST', body: formData })
+ const data = await res.json()
+ const text = data.result || ''
+
+ const parsed = {
+ name: '生肖纪念钞',
+ ownership_type: '自持',
+ rarity: '通货',
+ status: 'in_collection',
+ version: '',
+ prefixSerial: '',
+ packaging: '单张',
+ isGraded: false,
+ gradingCompany: '',
+ gradingScore: '',
+ threeStar: false,
+ specialMark: '',
+ serialFeature: '',
+ denomination: '贰拾圆',
+ issuer: '中国人民银行',
+ issueYear: '2024',
+ material: '塑料',
+ issueQuantity: '一亿',
+ costPrice: '',
+ goalPrice: '',
+ targetPrice: '',
+ remark: ''
+ }
+
+ const v = text.match(/发行版别[::]\s*([^\n]+)/)
+ if (v) {
+ parsed.version = v[1].trim()
+ const yearMatch = v[1].match(/20\d{2}/)
+ if (yearMatch) parsed.issueYear = yearMatch[0]
+ }
+ // 自动识别发行机构
+ const issuerMatch = text.match(/发行机构[::]\s*([^\n]+)/)
+ if (issuerMatch) parsed.issuer = issuerMatch[1].trim()
+ if (text.includes('三星') || text.includes('3星') || text.includes('★★★')) parsed.threeStar = true
+ const d = text.match(/面额[::]\s*([^\n]+)/)
+ if (d) parsed.denomination = d[1].trim()
+ const p = text.match(/冠字序号[::]\s*([^\n]+)/)
+ if (p) parsed.prefixSerial = p[1].trim()
+ // 识别编号
+ const codeMatch = text.match(/编号[::]\s*([^\n]+)/)
+ if (codeMatch) parsed.code = codeMatch[1].trim()
+ const pk = text.match(/封装类型[::]\s*([^\n]+)/)
+ if (pk) {
+ const pv = pk[1].trim()
+ if (pv.includes('百')) parsed.packaging = '标百'
+ else if (pv.includes('十')) parsed.packaging = '标十'
+ else if (pv.includes('单')) parsed.packaging = '单张'
+ else parsed.packaging = '裸钞'
+ }
+ const g = text.match(/是否评级[::]\s*([^\n]+)/)
+ if (g) parsed.isGraded = g[1].trim() === '是'
+ const gc = text.match(/评级机构[::]\s*([^\n]+)/)
+ if (gc) parsed.gradingCompany = gc[1].trim()
+ const gs = text.match(/评级分数[::]\s*([^\n]+)/)
+ if (gs) parsed.gradingScore = gs[1].trim()
+ const sm = text.match(/特殊标识[::]\s*([^\n]+)/)
+ if (sm) parsed.specialMark = sm[1].trim()
+ const sf = text.match(/号码特征[::]\s*([^\n]+)/)
+ if (sf) parsed.serialFeature = sf[1].trim()
+
+ setResult(parsed)
+ } catch (e) {
+ console.error(e)
+ alert('识别失败,请重试')
+ }
+ setLoading(false)
+ }
+
+ const updateResult = (field, value) => {
+ setResult(prev => ({ ...prev, [field]: value }))
+ }
+
+ const handleSave = async (force = false) => {
+ const token = localStorage.getItem('token')
+ if (!token) {
+ alert('请先登录')
+ return
+ }
+
+ try {
+ const saveData = convertField({ ...result, _forceSave: force })
+ const res = await fetch('/api/collections', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+ body: JSON.stringify(saveData)
+ })
+
+ if (res.ok) {
+ setSavedCount(prev => prev + 1)
+ alert('保存成功!')
+ } else {
+ const data = await res.json()
+ const errMsg = data.error || data.message || data.detail || '保存失败'
+ // 检查重复
+ if (errMsg.includes('E002') || errMsg.includes('已存在') || errMsg.includes('禁止重复') || errMsg.includes('重复')) {
+ const confirmed = confirm('发现重复:冠字号 ' + (result.prefixSerial || '') + ' 已存在,是否继续保存?')
+ if (confirmed) {
+ await handleSave(true) // 强制保存
+ return
+ }
+ }
+ alert(errMsg)
+ }
+ } catch (e) {
+ alert('保存失败: ' + e.message)
+ }
+ }
+
+ const handleNext = () => {
+ if (currentIndex < images.length - 1) {
+ setCurrentIndex(currentIndex + 1)
+ setResult(null)
+ processOCR(images[currentIndex + 1])
+ }
+ }
+
+ const handlePrev = () => {
+ if (currentIndex > 0) {
+ setCurrentIndex(currentIndex - 1)
+ setResult(null)
+ processOCR(images[currentIndex - 1])
+ }
+ }
+
+ const handleReOCR = () => {
+ processOCR(images[currentIndex])
+ }
+
+ const handleReUpload = () => {
+ setImages([])
+ setCurrentIndex(0)
+ setResult(null)
+ setSavedCount(0)
+ fileInputRef.current?.click()
+ }
+
+ // No images selected
+ if (images.length === 0) {
+ return (
+
+
fileInputRef.current?.click()}
+ style={{
+ minHeight: '50vh',
+ background: 'rgba(255,255,255,0.03)',
+ border: '2px dashed rgba(255,255,255,0.15)',
+ borderRadius: '16px',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ cursor: 'pointer'
+ }}
+ >
+
📦
+
点击选择多张图片
+
最多10张
+
+
+
+ )
+ }
+
+ const isSaved = savedCount > currentIndex
+
+ return (
+
+ {/* Sticky Header */}
+
+
+ 已保存: {savedCount} / {images.length} | 当前第 {currentIndex + 1} 张
+
+
+ {images.map((_, i) => (
+
+ {i + 1}
+
+ ))}
+
+ {currentIndex === images.length - 1 && savedCount > 0 && (
+
+ )}
+
+
+ {/* Image */}
+
+
})
+
+
+ {/* Buttons - moved here */}
+
+
+
+ {currentIndex < images.length - 1 && (
+
+ )}
+
+
+
+
+
+ {loading && (
+
🤖 AI识别中...
+ )}
+
+ {result && !loading && (
+ <>
+ {/* 基本信息 */}
+
+
基本信息
+
+
+
名称
+
updateResult('name', e.target.value)}
+ style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
+
+
+
+
编号
+
updateResult('code', e.target.value)}
+ style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
+
+
+
+
持仓类型
+
+
+
+
+
珍惜度
+
+
+
+
+
冠字序号
+
updateResult('prefixSerial', e.target.value)}
+ style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '13px', fontFamily: 'monospace' }} />
+
+
+
+
版别
+
updateResult('version', e.target.value)}
+ style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
+
+
+
+
状态
+
+
+
+
+
封装
+
+
+
+
+ {/* 评级信息 */}
+
+
updateResult('isGraded', !result.isGraded)}>
+
+ {result.isGraded && ✓}
+
+
已评级
+
+
+ {result.isGraded && (
+ <>
+
+
评级公司
+
updateResult('gradingCompany', e.target.value)}
+ style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
+
+
+
评级分数
+
updateResult('gradingScore', e.target.value)}
+ style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '13px' }} />
+
+ >
+ )}
+
+
updateResult('threeStar', !result.threeStar)}>
+
+ {result.threeStar && ✓}
+
+
三星
+
+
+
+ {/* 特殊信息 */}
+
+
特殊信息
+
+
+
发行机构
+
updateResult('issuer', e.target.value)}
+ style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
+
+
+
+
年份
+
updateResult('issueYear', e.target.value)}
+ style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
+
+
+
+
特殊标识
+
updateResult('specialMark', e.target.value)}
+ style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
+
+
+
+
号码特征
+
updateResult('serialFeature', e.target.value)}
+ style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
+
+
+
+
面额
+
updateResult('denomination', e.target.value)}
+ style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
+
+
+
+ {/* 价格信息 */}
+
+
+
成本价
+
updateResult('costPrice', e.target.value)}
+ style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
+
+
+
+
出售价
+
updateResult('targetPrice', e.target.value)}
+ style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '13px' }} />
+
+
+
+ {/* 备注 */}
+
+ >
+ )}
+
+ {/* Footer */}
+ {savedCount > 0 && currentIndex === images.length - 1 && (
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Detail.jsx b/frontend/src/pages/Detail.jsx
new file mode 100644
index 0000000..7df0297
--- /dev/null
+++ b/frontend/src/pages/Detail.jsx
@@ -0,0 +1,388 @@
+import React, { useState, useEffect } from 'react'
+
+export default function Detail() {
+ const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
+ const id = params.get('id')
+
+ const [collection, setCollection] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [showDelete, setShowDelete] = useState(false)
+ const [showImage, setShowImage] = useState(false)
+ const [currentImageIndex, setCurrentImageIndex] = useState(0)
+
+ useEffect(() => {
+ if (id) {
+ fetchDetail()
+ }
+ }, [id])
+
+ const fetchDetail = async () => {
+ setLoading(true)
+ const token = localStorage.getItem('token')
+ try {
+ const res = await fetch(`/api/collections/${id}`, {
+ headers: token ? { 'Authorization': 'Bearer ' + token } : {}
+ })
+
+ if (!res.ok) {
+ const errorData = await res.json()
+ const errorCode = errorData.error?.code || 'E00000'
+ const errorMessage = errorData.error?.message || '加载失败'
+
+ // 标准错误码处理
+ if (res.status === 404) {
+ setError('E00033: 藏品不存在')
+ } else if (res.status === 401) {
+ setError('E00010: 未登录或登录已过期')
+ } else if (res.status === 403) {
+ setError('E00014: 无权访问此藏品')
+ } else {
+ setError(`${errorCode}: ${errorMessage}`)
+ }
+ setLoading(false)
+ return
+ }
+
+ const data = await res.json()
+ // 后端已返回 camelCase 格式,直接使用
+ const collectionData = data.data || data
+
+ if (collectionData && collectionData.id) {
+ setCollection(collectionData)
+ } else {
+ setError('E00000: 数据格式错误')
+ }
+ } catch (e) {
+ console.error(e)
+ setError('E00001: 网络连接失败')
+ }
+ setLoading(false)
+ }
+
+ const handleEdit = () => {
+ window.location.hash = '#/edit?id=' + id
+ }
+
+ const goBack = () => {
+ // 尝试返回到上次筛选的列表页面
+ const lastUrl = sessionStorage.getItem('lastListUrl')
+ if (lastUrl) {
+ sessionStorage.removeItem('lastListUrl')
+ window.location.hash = '#' + lastUrl
+ } else {
+ window.location.hash = '#/list'
+ }
+ }
+
+ const doDelete = async () => {
+ const token = localStorage.getItem('token')
+ try {
+ const res = await fetch(`/api/collections/${id}`, {
+ method: 'DELETE',
+ headers: { 'Authorization': 'Bearer ' + token }
+ })
+ if (res.ok) {
+ alert('删除成功!')
+ if (window.refreshList) window.refreshList()
+ window.location.hash = '#/list'
+ } else {
+ alert('删除失败')
+ }
+ } catch (e) {
+ alert('删除失败')
+ }
+ setShowDelete(false)
+ }
+
+ const getStatusText = (status) => {
+ const map = { 'in_collection': '收藏中', 'selling': '出售中', 'sold': '已售', 'grading': '送评中', 'repairing': '修复中', 'transit': '在途中', 'other': '其他' }
+ return map[status] || status || '-'
+ }
+
+ const getCategoryText = (category) => {
+ const map = { '自持': '自持', '寄存': '寄存', '寄售': '寄售', '共有': '共有', '寻号': '寻号', '其他': '其他' }
+ return map[category] || category || '-'
+ }
+
+ const Field = ({ label, value, green }) => (
+
+ {label}
+ {value || '-'}
+
+ )
+
+ const Section = ({ title, children }) => (
+
+ )
+
+ if (loading) {
+ return 加载中...
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ if (!collection) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {/* 顶部 */}
+
+
藏品详情
+
+
+
+
+
+
+
+ {/* 主信息 */}
+
+ {/* 图片显示 */}
+ {collection.images && collection.images.length > 0 && (
+
+
+ {collection.images.map((img, index) => (
+
+
)
{ setCurrentImageIndex(index); setShowImage(true); }}
+ style={{
+ width: '100%',
+ aspectRatio: '1.5',
+ objectFit: 'contain',
+ borderRadius: '12px',
+ border: '2px solid #10b981',
+ cursor: 'pointer',
+ boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
+ background: '#0f172a'
+ }}
+ onError={(e) => {
+ // 图片加载失败时显示灰色占位符,不显示 logo
+ e.target.style.display = 'none';
+ e.target.parentElement.innerHTML = '
无图片
';
+ }}
+ />
+ {collection.images.length > 1 && (
+
+ {index + 1}/{collection.images.length}
+
+ )}
+
+ ))}
+
+
+ )}
+
+
{collection.name}
+
{collection.prefixSerial || '-'}
+
+
+ {/* 信息区块 */}
+
+
+
+
+
+
+
+
+
+ {collection.remark && (
+
+ )}
+
+
+ {/* 删除确认弹窗 */}
+ {showDelete && (
+
+
+
确定删除此藏品?
+
+
+
+
+
+
+ )}
+
+ {/* 图片查看器 */}
+ {showImage && collection.images && collection.images.length > 0 && (
+
setShowImage(false)}
+ >
+ {/* 左侧切换按钮 */}
+ {collection.images.length > 1 && (
+
+ )}
+
+ {/* 图片 */}
+
![{collection.images[currentImageIndex].originalName]({collection.images[currentImageIndex].path?.startsWith('http'))
e.stopPropagation()}
+ style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
+ />
+
+ {/* 右侧切换按钮 */}
+ {collection.images.length > 1 && (
+
+ )}
+
+ {/* 图片指示器 */}
+ {collection.images.length > 1 && (
+
e.stopPropagation()}
+ >
+ {collection.images.map((_, index) => (
+
setCurrentImageIndex(index)}
+ style={{
+ width: '12px',
+ height: '12px',
+ borderRadius: '50%',
+ background: index === currentImageIndex ? '#fbbf24' : 'rgba(255,255,255,0.3)',
+ cursor: 'pointer',
+ border: '2px solid #fff'
+ }}
+ />
+ ))}
+
+ )}
+
+ {/* 关闭提示 */}
+
setShowImage(false)}
+ >
+ ×
+
+
+ )}
+
+ )
+}
+// v2.7.1
diff --git a/frontend/src/pages/Edit.jsx b/frontend/src/pages/Edit.jsx
new file mode 100644
index 0000000..802fc66
--- /dev/null
+++ b/frontend/src/pages/Edit.jsx
@@ -0,0 +1,491 @@
+import React, { useState, useRef, useEffect } from 'react'
+
+// Input 组件(复用 Add.jsx 的定义)
+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 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 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 rarityOptions = [
+ { value: '通货', label: '通货' },
+ { value: '特色', label: '特色' },
+ { value: '少见', label: '少见' },
+ { value: '稀有', label: '稀有' },
+ { value: '珍品', label: '珍品' },
+ { value: '孤品', label: '孤品' }
+]
+
+const packagingOptions = [
+ { value: '标十', label: '标十' },
+ { value: '标百', label: '标百' },
+ { value: '单张', label: '单张' },
+ { value: '裸钞', label: '裸钞' }
+]
+
+export default function Edit() {
+ // 从 URL 获取藏品 ID
+ const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
+ const editId = params.get('id')
+
+ const [form, setForm] = useState({
+ name: '', code: '', category: '自持', rarity: '通货', numberCategory: '', prefixSerial: '',
+ version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张',
+ material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false,
+ gradingCompany: '', gradingScore: '', threeStar: false, specialMark: '',
+ serialFeature: '', issuer: '中国人民银行', issueYear: '2024',
+ costPrice: '', targetPrice: '', goalPrice: '', repairFee: '', gradingFee: '', remark: ''
+ })
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+ const [uploadImages, setUploadImages] = useState([])
+ const fileInputRef = useRef(null)
+
+ // 加载现有藏品数据
+ useEffect(() => {
+ if (editId) {
+ const token = localStorage.getItem('token')
+ fetch(`/api/collections/${editId}`, {
+ headers: { 'Authorization': 'Bearer ' + token }
+ })
+ .then(res => res.json())
+ .then(data => {
+ // 填充表单
+ setForm({
+ name: data.name || '',
+ code: data.code || '',
+ category: data.category || '自持',
+ rarity: data.rarity || '通货',
+ numberCategory: data.numberCategory || '',
+ prefixSerial: data.prefixSerial || '',
+ version: data.version || '2024 龙',
+ denomination: data.denomination || '',
+ status: data.status || 'in_collection',
+ packaging: data.packaging || '单张',
+ isGraded: data.isGraded || false,
+ gradingCompany: data.gradingCompany || '',
+ gradingScore: data.gradingScore || '',
+ threeStar: data.threeStar || false,
+ specialMark: data.specialMark || '',
+ serialFeature: data.serialFeature || '',
+ issuer: data.issuer || '中国人民银行',
+ issueYear: data.issueYear || '2024',
+ material: data.material || '塑料钞',
+ issueQuantity: data.issueQuantity || '1 亿',
+ costPrice: data.costPrice || '',
+ targetPrice: data.targetPrice || '',
+ goalPrice: data.goalPrice || '',
+ repairFee: data.repairFee || '',
+ gradingFee: data.gradingFee || '',
+ purpose: data.purpose || '收藏',
+ remark: data.remark || ''
+ })
+ // 加载图片
+ if (data.images && Array.isArray(data.images)) {
+ const images = data.images.map(img => ({
+ file: null,
+ preview: img.path?.startsWith('http') ? img.path : `/${img.path || `uploads/collections/${img.filename}`}`,
+ name: img.original_name || img.filename,
+ size: 0,
+ id: img.id
+ }))
+ setUploadImages(images)
+ }
+ setLoading(false)
+ })
+ .catch(err => {
+ console.error('加载藏品失败:', err)
+ setError('加载失败:' + err.message)
+ setLoading(false)
+ })
+ } else {
+ setError('未指定藏品 ID')
+ setLoading(false)
+ }
+ }, [editId])
+
+ const handleChange = (key, value) => {
+ setForm({ ...form, [key]: value })
+ if (key === 'targetPrice' && 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('4')) cat = '带4'
+ else if (digits.includes('7')) cat = '无4'
+ else if (!digits.includes('4') && !digits.includes('7')) cat = '无47'
+ else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247'
+ 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 handleImageUpload = async (e) => {
+ const files = Array.from(e.target.files)
+ if (files.length === 0 || !editId) return
+
+ const token = localStorage.getItem('token')
+ const file = files[0] // 只允许上传 1 张
+
+ const imgFormData = new FormData()
+ imgFormData.append('file', file)
+
+ try {
+ const res = await fetch(`/api/collections/upload-image?collection_id=${editId}`, {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + token },
+ body: imgFormData
+ })
+
+ if (res.ok) {
+ // 重新加载藏品数据
+ const dataRes = await fetch(`/api/collections/${editId}`, {
+ headers: { 'Authorization': 'Bearer ' + token }
+ })
+ const data = await dataRes.json()
+ if (data.images && Array.isArray(data.images)) {
+ const images = data.images.map(img => ({
+ file: null,
+ preview: img.path?.startsWith('http') ? img.path : `/${img.path || `uploads/collections/${img.filename}`}`,
+ name: img.original_name || img.filename,
+ size: 0,
+ id: img.id
+ }))
+ setUploadImages(images)
+ }
+ alert('图片上传成功!')
+ } else {
+ alert('图片上传失败')
+ }
+ } catch (err) {
+ console.error('图片上传失败:', err)
+ alert('图片上传失败:' + err.message)
+ }
+ }
+
+ const handleSave = async () => {
+ if (!form.name || !form.version) { setError('名称和版别为必填项'); return }
+ setSaving(true)
+ setError('')
+ const token = localStorage.getItem('token')
+ const formData = convertField(form)
+ try {
+ // 1. 更新藏品信息
+ const res = await fetch(`/api/collections/${editId}`, {
+ method: 'PUT',
+ 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 || '更新失败')
+ }
+
+ alert('更新成功!')
+ window.location.hash = '#/list'
+ window.refreshList?.()
+ } catch (e) {
+ console.error('更新失败:', e)
+ alert('更新失败:' + e.message)
+ }
+ finally { setSaving(false) }
+ }
+
+ // 字段转换函数
+ 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',
+ numberCategory: 'f02_14_number_category',
+ 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',
+ 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
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {/* 顶部标题 */}
+
+
+
window.history.back()} style={{ color: '#60a5fa', fontSize: '24px', cursor: 'pointer', padding: '8px' }}>←
+
+
+ {error && (
+
+ ⚠️ {error}
+
+ )}
+
+
+ {/* 藏品图片 */}
+
+
+
藏品图片 ({uploadImages.length}/1)
+ {uploadImages.length === 0 && (
+
+ )}
+
+
+ {/* 隐藏的文件输入框 */}
+
+ {uploadImages.length > 0 ? (
+
+ {uploadImages.map((img, index) => (
+
+

+
{index + 1}/{uploadImages.length}
+
+ ))}
+
+ ) : (
+
+ 📷 暂无图片
+
+ )}
+
+
+ {/* 基本信息 */}
+
+
+ {/* 评级信息 */}
+
+
+ {/* 特殊信息 */}
+
+
+ {/* 价格信息 */}
+
+
+ {/* 备注 */}
+
+
+ {/* 按钮 */}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Edit.jsx.dual_column_bak b/frontend/src/pages/Edit.jsx.dual_column_bak
new file mode 100644
index 0000000..337cba3
--- /dev/null
+++ b/frontend/src/pages/Edit.jsx.dual_column_bak
@@ -0,0 +1,575 @@
+import React, { useState, useEffect, useRef } from 'react'
+
+
+// 通用 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 ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export default function Edit() {
+ const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
+ const id = params.get('id')
+
+ const [collection, setCollection] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [form, setForm] = useState({})
+ const [showDelete, setShowDelete] = useState(false)
+ const [collectionImages, setCollectionImages] = useState([])
+ const fileInputRef = useRef(null)
+ const [editingImageIndex, setEditingImageIndex] = useState(-1)
+
+ useEffect(() => {
+ if (id) {
+ fetchDetail()
+ }
+ }, [id])
+
+ // 图片上传处理
+ const handleImageUpload = async (e) => {
+ const files = Array.from(e.target.files)
+ if (files.length === 0) return
+
+ const token = localStorage.getItem('token')
+ const maxImages = 3
+
+ // 检查是否超过限制
+ const currentCount = collection.images ? collection.images.length : 0
+ if (editingImageIndex === -1 && currentCount + files.length > maxImages) {
+ alert(`最多只能上传${maxImages}张图片`)
+ return
+ }
+
+ // 更换图片(点击已有图片)
+ if (editingImageIndex >= 0) {
+ const file = files[0]
+ const oldImage = collection.images[editingImageIndex]
+
+ const imgFormData = new FormData()
+ imgFormData.append('file', file)
+
+ try {
+ // 先上传新图片
+ const uploadRes = await fetch(`/api/collections/upload-image?collection_id=${id}`, {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + token },
+ body: imgFormData
+ })
+
+ if (uploadRes.ok) {
+ // 删除旧图片
+ await fetch(`/api/collections/images/${oldImage.id}`, {
+ method: 'DELETE',
+ headers: { 'Authorization': 'Bearer ' + token }
+ })
+
+ // 重新加载详情
+ await fetchDetail()
+ alert('图片已更换')
+ }
+ } catch (err) {
+ console.error('图片更换失败:', err)
+ alert('更换失败,请重试')
+ }
+
+ setEditingImageIndex(-1)
+ return
+ }
+
+ // 添加新图片
+ for (const file of files) {
+ const imgFormData = new FormData()
+ imgFormData.append('file', file)
+
+ try {
+ const res = await fetch(`/api/collections/upload-image?collection_id=${id}`, {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + token },
+ body: imgFormData
+ })
+
+ if (res.ok) {
+ // 重新加载藏品详情
+ await fetchDetail()
+ }
+ } catch (err) {
+ console.error('图片上传失败:', err)
+ }
+ }
+ }
+
+ const handleDeleteImage = async (imageId) => {
+ if (!confirm('确定删除这张图片吗?')) return
+
+ const token = localStorage.getItem('token')
+
+ try {
+ const res = await fetch(`/api/collections/images/${imageId}`, {
+ method: 'DELETE',
+ headers: { 'Authorization': 'Bearer ' + token }
+ })
+
+ if (res.ok) {
+ await fetchDetail()
+ }
+ } catch (err) {
+ console.error('图片删除失败:', err)
+ }
+ }
+
+ const fetchDetail = async () => {
+ setLoading(true)
+ const token = localStorage.getItem('token')
+ try {
+ const res = await fetch(`/api/collections/${id}`, {
+ headers: token ? { 'Authorization': 'Bearer ' + token } : {}
+ })
+ const data = await res.json()
+ const item = (data.code === 200 ? data.data : data)
+ if (item && item.id) {
+ setCollection(item)
+ // 设置特殊信息默认值
+ const yearMatch = item.version ? item.version.match(/(20\d{2})/) : null
+ const defaultForm = {
+ ...item,
+ issuer: item.issuer || '中国人民银行',
+ issueYear: item.issueYear || (yearMatch ? yearMatch[1] : '2024'),
+ material: item.material || '塑料钞',
+ issueQuantity: item.issueQuantity || '1 亿'
+ }
+ setForm(defaultForm)
+ // 加载图片
+ if (item.images && Array.isArray(item.images)) {
+ setCollectionImages(item.images)
+ }
+ }
+ } catch (e) {
+ console.error('加载详情失败:', e)
+ }
+ setLoading(false)
+ }
+
+ const handleChange = (key, value) => {
+ setForm({ ...form, [key]: value })
+ // 填入出售价时自动把状态改为"已售"
+ if (key === 'targetPrice' && value) {
+ setForm(prev => ({ ...prev, status: 'sold' }))
+ }
+ // 版别变化时同步发行年份
+ if (key === 'version') {
+ const yearMatch = value.match(/(20\d{2})/)
+ if (yearMatch) {
+ setForm(prev => ({ ...prev, issueYear: yearMatch[1] }))
+ }
+ }
+ }
+
+ const handleSave = async () => {
+ setSaving(true)
+ setError('')
+ const token = localStorage.getItem('token')
+
+ // 1. 先检查是否重复(同版别、同冠字号)
+ if (form.prefixSerial) {
+ const checkRes = await fetch(`/api/collections?search=${encodeURIComponent(form.prefixSerial)}`, {
+ headers: { 'Authorization': 'Bearer ' + token }
+ })
+ const checkData = await checkRes.json()
+ const collections = checkData.data || checkData || []
+ const duplicate = collections.find(c =>
+ c.version === form.version &&
+ c.prefixSerial === form.prefixSerial &&
+ c.id !== id // 排除自己
+ )
+ if (duplicate) {
+ setError('已经录入过同版同号藏品!')
+ setSaving(false)
+ return
+ }
+ }
+
+ // 转换字段名:camelCase 转字段编码
+ const convertField = (obj) => {
+ const map = {
+ // f99 系统字段
+ id: 'f99_90_id',
+ userId: 'f99_91_user_id',
+ createdAt: 'f99_92_created_at',
+ updatedAt: 'f99_93_updated_at',
+ // f01 基本信息
+ name: 'f01_01_name',
+ code: 'f01_02_code',
+ category: 'f01_03_category',
+ status: 'f01_04_status',
+ remark: 'f01_05_remark',
+ // f02 详细字段
+ prefixSerial: 'f02_10_prefix_serial',
+ version: 'f02_11_version',
+ packaging: 'f02_12_packaging',
+ rarity: 'f02_13_rarity',
+ // f03 评级信息
+ isGraded: 'f03_20_is_graded',
+ gradingCompany: 'f03_21_grading_company',
+ gradingScore: 'f03_22_grading_score',
+ threeStar: 'f03_23_three_star',
+ // f04 特殊信息
+ specialMark: 'f04_30_special_mark',
+ serialFeature: 'f04_31_serial_feature',
+ issuer: 'f04_32_issuer',
+ issueYear: 'f04_33_issue_year',
+ material: 'f04_34_material',
+ denomination: 'f04_35_denomination',
+ issueQuantity: 'f04_36_issue_quantity',
+ // f05 价格信息
+ 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',
+ // f06 其他信息
+ purpose: 'f06_50_purpose'
+ }
+ const result = {}
+ // 数字字段列表
+ const numberFields = ['costPrice', 'targetPrice', 'goalPrice', 'repairFee', 'gradingFee']
+ for (const key in obj) {
+ let value = obj[key]
+ // 过滤空字符串为 null
+ 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
+ }
+ try {
+ const res = await fetch(`/api/collections/${id}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer ' + token
+ },
+ body: JSON.stringify(convertField(form))
+ })
+ if (res.ok) {
+ alert('保存成功!')
+ // 刷新首页统计数据
+ if (window.refreshHome) {
+ window.refreshHome()
+ }
+ window.location.hash = '#/detail?id=' + id
+ } else {
+ const data = await res.json()
+ alert('保存失败: ' + (data.error || '未知错误'))
+ }
+ } catch (e) {
+ alert('保存失败: ' + e.message)
+ }
+ setSaving(false)
+ }
+
+ const handleDelete = () => {
+ setShowDelete(true)
+ }
+
+ const doDelete = async () => {
+ const token = localStorage.getItem('token')
+ try {
+ const res = await fetch(`/api/collections/${id}`, {
+ method: 'DELETE',
+ headers: {
+ 'Authorization': 'Bearer ' + token
+ }
+ })
+ if (res.ok) {
+ alert('删除成功!')
+ if (window.refreshList) window.refreshList()
+ window.location.hash = '#/list'
+ } else {
+ alert('删除失败')
+ }
+ } catch (e) {
+ alert('删除失败')
+ }
+ setShowDelete(false)
+ }
+
+ const goBack = () => {
+ window.location.hash = '#/detail?id=' + id
+ }
+
+ const statusOptions = [
+ { value: 'in_collection', label: '收藏中' },
+ { value: 'selling', label: '出售中' },
+ { value: 'sold', label: '已售' },
+ { value: 'grading', label: '送评中' },
+ { value: 'repairing', label: '修复中' },
+ { value: 'transit', label: '在途中' },
+ { value: 'other', label: '其他' }
+ ]
+
+ const categoryOptions = [
+ { value: '自持', label: '自持' },
+ { value: '寄存', label: '寄存' },
+ { value: '寄售', label: '寄售' },
+ { value: '共有', label: '共有' },
+ { value: '寻号', label: '寻号' },
+ { value: '其他', label: '其他' }
+ ]
+
+ const rarityOptions = [
+ { value: '通货', label: '通货' },
+ { value: '特色', label: '特色' },
+ { value: '少见', label: '少见' },
+ { value: '稀有', label: '稀有' },
+ { value: '珍品', label: '珍品' },
+ { value: '孤品', label: '孤品' }
+ ]
+
+ if (loading) {
+ return
加载中...
+ }
+
+ return (
+
+ {/* 顶部 */}
+
+
+ 编辑藏品
+
+
+
+ {/* 图片预览区域 */}
+
+
+
藏品图片
+ {collectionImages.length > 0 && (
+
+ )}
+
+
+
+
+ {collectionImages.length > 0 ? (
+
+ {collectionImages.map((img, index) => (
+
+

+
{index + 1}/{collectionImages.length}
+
+ ))}
+
+ ) : (
+
fileInputRef.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: '12px',
+ transition: 'all 0.2s'
+ }}
+ >
+
📷
+
点击上传图片
+
+ 最多可上传 1 张
+
+
+ )}
+
+
+ {/* 基本信息 */}
+
+
+ {/* 评级信息 */}
+
+
评级信息
+
+ handleChange('isGraded', e.target.checked)}
+ style={{ width: '22px', height: '22px', cursor: 'pointer', backgroundColor: form.isGraded ? '#22c55e' : 'rgba(255,255,255,0.1)', border: '2px solid #22c55e', borderRadius: '4px', appearance: 'none', WebkitAppearance: 'none' }}
+ />
+
+
+
+
+
+
+ handleChange('threeStar', e.target.checked)}
+ style={{ width: '22px', height: '22px', cursor: 'pointer', backgroundColor: form.threeStar ? '#22c55e' : 'rgba(255,255,255,0.1)', border: '2px solid #22c55e', borderRadius: '4px', appearance: 'none', WebkitAppearance: 'none' }}
+ />
+
+
+
+
+ {/* 特殊信息 */}
+
+
+ {/* 价格信息 */}
+
+
+ {/* 备注 */}
+
+
+ {/* 按钮 */}
+
+
+
+
+
+ {/* 删除确认弹窗 */}
+ {showDelete && (
+
+
+
确定删除此藏品?
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx
new file mode 100644
index 0000000..dc0262b
--- /dev/null
+++ b/frontend/src/pages/Home.jsx
@@ -0,0 +1,255 @@
+import React, { useState, useEffect } from 'react'
+import { APP_VERSION } from '../config/version'
+
+export default function Home() {
+ const [user, setUser] = useState(null)
+ const [stats, setStats] = useState({ totalCount: 0, totalCost: 0, totalRevenue: 0, expectedProfit: 0, totalProfit: 0, gradedCount: 0 })
+ const [recentCollections, setRecentCollections] = useState([])
+ const currentPath = window.location.hash.slice(1) || '/'
+
+ useEffect(() => {
+ const token = localStorage.getItem('token')
+ const userData = localStorage.getItem('user')
+
+ if (userData) {
+ try {
+ setUser(JSON.parse(userData))
+ } catch (e) {
+ console.error('Parse user error:', e)
+ }
+ }
+
+ if (token) {
+ fetch('/api/collections/stats', {
+ headers: { 'Authorization': 'Bearer ' + token }
+ }).then(res => {
+ if (!res.ok) throw new Error('Stats API failed')
+ return res.json()
+ }).then(data => {
+ // stats API直接返回对象,不需要data.data包装
+ const info = data.totalCount !== undefined ? data : (data.data || data)
+ if (info && info.totalCount !== undefined) {
+ // 从byGrading计算评级数
+ const gradedCount = info.byGrading ? (info.byGrading.find(x => x.isGraded === true)?.count || 0) : 0
+ setStats({
+ totalCount: info.totalCount || 0,
+ totalCost: info.totalCost || 0,
+ totalRevenue: info.totalRevenue || 0,
+ expectedProfit: info.expectedProfit || 0,
+ totalProfit: info.totalProfit || 0,
+ gradedCount: gradedCount
+ })
+ }
+ })
+
+ fetch('/api/collections?limit=5&sort=createdAt&order=desc', {
+ headers: { 'Authorization': 'Bearer ' + token }
+ }).then(res => res.json()).then(data => {
+ // 新格式 {data:[], pagination:{}} 或老格式 {items:[]}
+ const list = data.data || data.items || data
+ if (Array.isArray(list)) {
+ setRecentCollections(list)
+ }
+ })
+ }
+ }, [])
+
+ // 检查是否为管理员
+ const isAdmin = user && user.role === 'admin'
+
+ const handleLogout = () => {
+ localStorage.removeItem('token')
+ localStorage.removeItem('user')
+ window.location.hash = '#/login'
+ window.location.reload()
+ }
+
+ const formatMoney = (val) => {
+ if (!val || val === 0) return '0'
+ const v = val / 10000
+ return v.toFixed(4)
+ }
+
+ const statCards = [
+ { label: '藏品数', value: stats.totalCount, color: '#3b82f6', icon: '📦' },
+ { label: '总成本', value: formatMoney(stats.totalCost) + '万', color: '#22c55e', icon: '💰' },
+ { label: '总收入', value: formatMoney(stats.totalRevenue) + '万', color: '#06b6d4', icon: '📈' },
+ { label: '评级数', value: stats.gradedCount, color: '#8b5cf6', icon: '⭐' },
+ { label: '预期利润', value: formatMoney(stats.expectedProfit) + '万', color: stats.expectedProfit >= 0 ? '#f59e0b' : '#ef4444', icon: '🎯' },
+ { label: '总利润', value: formatMoney(stats.totalProfit) + '万', color: stats.totalProfit >= 0 ? '#10b981' : '#f43f5e', icon: '💵' }
+ ]
+
+ return (
+
+ {/* 顶部用户信息 */}
+
+
+

+
+
{user?.username || '用户'}
+
欢迎回来 👋
+
+
+
v{APP_VERSION}
+
window.location.hash = '#/settings'} style={{ color: 'rgba(255,255,255,0.5)', cursor: 'pointer', fontSize: '12px', padding: '4px 8px', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '4px' }}>设置
+
退出
+
+
+
+
+ {/* 统计卡片网格 */}
+
+ {statCards.map((card, idx) => (
+
{ e.currentTarget.style.transform = 'translateY(-2px)' }}
+ onMouseOut={e => { e.currentTarget.style.transform = 'translateY(0)' }}
+ onClick={() => window.location.hash = '#/stats'}
+ >
+
{card.icon}
+
{card.value}
+
{card.label}
+
+ ))}
+
+
+ {/* 快捷操作 */}
+
+
快捷操作
+
+
window.location.hash = '#/add?mode=ocr'} style={{
+ background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
+ borderRadius: '12px',
+ padding: '16px',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '12px'
+ }}>
+
📷
+
+
+
window.location.hash = '#/add?mode=manual'} style={{
+ background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
+ borderRadius: '12px',
+ padding: '16px',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '12px'
+ }}>
+
✏️
+
+
+
+
+
+ {/* 最近藏品 */}
+
+
+
最近藏品
+
window.location.hash = '#/list'} style={{ color: '#3b82f6', fontSize: '12px', cursor: 'pointer' }}>查看全部 →
+
+
+ {recentCollections.length === 0 ? (
+
暂无藏品
+ ) : (
+ recentCollections.map((item, idx) => (
+
window.location.hash = '#/detail?id=' + item.id} style={{
+ padding: '12px 16px',
+ borderBottom: idx < recentCollections.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none',
+ cursor: 'pointer',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center'
+ }}>
+
+
{item.code || '-'} {item.prefixSerial || ''}
+
{item.version || '-'} · {item.status === 'sold' ? '已售' : item.status === 'in_collection' ? '收藏中' : item.status}
+
+
+ {item.costPrice ? '¥' + item.costPrice : '-'}
+
+
+ ))
+ )}
+
+
+
+ {/* 底部导航 */}
+
+ {(isAdmin ? [
+ { icon: '🏠', label: '首页', hash: '#/', idx: 0 },
+ { icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
+ { icon: '➕', label: '添加', hash: '#/ocr', idx: 2 },
+ { icon: '📊', label: '统计', hash: '#/stats', idx: 3 },
+ { icon: '⚙️', label: '管理', hash: '#/admin', idx: 4 }
+ ] : [
+ { icon: '🏠', label: '首页', hash: '#/', idx: 0 },
+ { icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
+ { icon: '➕', label: '添加', hash: '#/ocr', idx: 2 },
+ { icon: '📊', label: '统计', hash: '#/stats', idx: 3 }
+ ]).map((item) => (
+
window.location.hash = item.hash} style={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ cursor: 'pointer',
+ color: currentPath === item.hash ? '#fbbf24' : '#64748b'
+ }}>
+
{item.icon}
+
{item.label}
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/List.jsx b/frontend/src/pages/List.jsx
new file mode 100644
index 0000000..0078943
--- /dev/null
+++ b/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/src/pages/Login.jsx
new file mode 100644
index 0000000..47f3198
--- /dev/null
+++ b/frontend/src/pages/Login.jsx
@@ -0,0 +1,671 @@
+import React, { useState, useEffect } from 'react'
+import { APP_VERSION } from '../config/version'
+import { api } from '../utils/api'
+import { ErrorCodes } from '../utils/errorCodes'
+
+export default function Login() {
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [captcha, setCaptcha] = useState({ num1: 0, num2: 0, answer: '' })
+
+ // 注册相关状态
+ const [showRegister, setShowRegister] = useState(false)
+ const [registerData, setRegisterData] = useState({ agreeTerms: false,
+ username: '',
+ password: '',
+ confirmPassword: '',
+ email: '',
+ phone: '',
+ verifyCode: ''
+ })
+ const [registerLoading, setRegisterLoading] = useState(false)
+ const [registerError, setRegisterError] = useState('')
+
+ // 短信获取相关
+ const [sendingCode, setSendingCode] = useState(false)
+ const [codeCountdown, setCodeCountdown] = useState(0)
+ const [codeSent, setCodeSent] = useState(false)
+
+ // 生成获取
+ useEffect(() => {
+ generateCaptcha()
+ }, [])
+
+ // 获取倒计时
+ useEffect(() => {
+ if (codeCountdown > 0) {
+ const timer = setTimeout(() => setCodeCountdown(codeCountdown - 1), 1000)
+ return () => clearTimeout(timer)
+ }
+ }, [codeCountdown])
+
+ const generateCaptcha = () => {
+ const num1 = Math.floor(Math.random() * 10)
+ const num2 = Math.floor(Math.random() * 10)
+ setCaptcha({ num1, num2, answer: '' })
+ }
+
+ // 发送短信获取
+ const handleSendCode = async () => {
+ if (!registerData.phone) {
+ setRegisterError('请先输入手机号')
+ return
+ }
+
+ // 验证手机号格式
+ const phoneRegex = /^1[3-9]\d{9}$/
+ if (!phoneRegex.test(registerData.phone)) {
+ setRegisterError('请输入正确的手机号')
+ return
+ }
+
+ setSendingCode(true)
+ setRegisterError('')
+
+ try {
+ const res = await fetch('/api/auth/send-verification-code', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ phone: registerData.phone })
+ })
+
+ const data = await res.json()
+
+ if (data.success) {
+ setCodeSent(true)
+ setCodeCountdown(60)
+ setRegisterError('')
+ } else {
+ setRegisterError(data.message || '发送失败')
+ }
+ } catch (err) {
+ setRegisterError('发送失败,请稍后重试')
+ } finally {
+ setSendingCode(false)
+ }
+ }
+
+ // 处理注册
+ const handleRegister = async () => {
+ // 验证输入
+ if (!registerData.username || !registerData.password) {
+ setRegisterError('用户名和密码为必填项')
+ return
+ }
+
+ if (!registerData.agreeTerms) {
+ setRegisterError('请先同意用户协议')
+ return
+ }
+
+ if (registerData.username.length < 2) {
+ setRegisterError('用户名至少 2 个字符')
+ return
+ }
+
+ if (registerData.password.length < 8 || !/[A-Z]/.test(registerData.password) || !/[a-z]/.test(registerData.password) || !/[0-9]/.test(registerData.password)) {
+ setRegisterError('密码至少8位,需包含大写、小写、数字')
+ return
+ }
+
+ if (registerData.password !== registerData.confirmPassword) {
+ setRegisterError('两次输入的密码不一致')
+ return
+ }
+
+ // 验证手机号
+ if (!registerData.phone) {
+ setRegisterError('手机号为必填项')
+ return
+ }
+
+ const phoneRegex = /^1[3-9]\d{9}$/
+ if (!phoneRegex.test(registerData.phone)) {
+ setRegisterError('请输入正确的手机号')
+ return
+ }
+
+ // 验证短信获取
+ if (!registerData.verifyCode) {
+ setRegisterError('请输入短信获取')
+ return
+ }
+
+ setRegisterLoading(true)
+ setRegisterError('')
+
+ try {
+ const payload = {
+ username: registerData.username,
+ password: registerData.password,
+ phone: registerData.phone,
+ verifyCode: registerData.verifyCode
+ }
+ if (registerData.email) {
+ payload.email = registerData.email
+ }
+
+ const res = await fetch('/api/auth/register', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(payload)
+ })
+
+ const data = await res.json()
+
+ if (!res.ok) {
+ throw new Error(data.detail || data.error?.message || '注册失败')
+ }
+
+ alert('注册成功!请登录')
+ setShowRegister(false)
+ setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '' })
+ setCodeSent(false)
+ setCodeCountdown(0)
+ generateCaptcha()
+ } catch (err) {
+ console.error('注册错误:', err)
+ setRegisterError(err.message || '注册失败')
+ } finally {
+ setRegisterLoading(false)
+ }
+ }
+
+ const handleLogin = async () => {
+ // 验证输入
+ if (!username || !password) {
+ setError(ErrorCodes.E00020.message)
+ return
+ }
+
+ if (username.length < 2) {
+ setError(ErrorCodes.E00021.message)
+ return
+ }
+
+
+ // 验证获取
+ const expectedAnswer = captcha.num1 + captcha.num2
+ if (!captcha.answer || Number(captcha.answer) !== expectedAnswer) {
+ setError(ErrorCodes.E00012.message)
+ return
+ }
+
+ setLoading(true)
+ setError('')
+
+ try {
+ console.log('开始登录...')
+ const loginData = await api.auth.login(username, password)
+ console.log('登录响应:', loginData)
+
+ if (!loginData.access_token) {
+ throw new Error('登录响应中没有 access_token')
+ }
+
+ localStorage.setItem('token', loginData.access_token)
+ console.log('Token 已保存')
+
+ const userData = await api.user.me()
+ console.log('用户信息:', userData)
+
+ localStorage.setItem('user', JSON.stringify(userData))
+ console.log('用户信息已保存')
+
+ // 强制刷新页面,确保 React 状态更新
+ window.location.href = '/'
+ console.log('跳转首页')
+ } catch (err) {
+ console.error('登录错误:', err)
+ const errorCode = err.code || 'E00000'
+ const errorMsg = err.message || '登录失败'
+ setError(`${errorCode}: ${errorMsg}`)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+ {/* Logo - 龙型 + 甲辰收藏文字 (橙色圆形设计) */}
+
+

+
生肖纪念钞管理系统
+
+
+ {/* 登录表单 */}
+
+
+ 欢迎回来 👋
+
+
+ {error && (
+
+ ⚠️ {error}
+
+ )}
+
+ {/* 用户名 */}
+
+ setUsername(e.target.value)}
+ placeholder="用户名"
+ style={{
+ width: '100%',
+ padding: '14px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255,255,255,0.2)',
+ background: 'rgba(255,255,255,0.05)',
+ color: '#fff',
+ fontSize: '16px',
+ outline: 'none'
+ }}
+ />
+
+
+ {/* 密码 */}
+
+ setPassword(e.target.value)}
+ placeholder="密码"
+ style={{
+ width: '100%',
+ padding: '14px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255,255,255,0.2)',
+ background: 'rgba(255,255,255,0.05)',
+ color: '#fff',
+ fontSize: '16px',
+ outline: 'none'
+ }}
+ />
+
+
+ {/* 获取 */}
+
+
+
+ {captcha.num1} + {captcha.num2} = ?
+
+
setCaptcha(prev => ({ ...prev, answer: e.target.value }))}
+ placeholder="?"
+ style={{
+ width: '80px',
+ padding: '14px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255,255,255,0.2)',
+ background: 'rgba(255,255,255,0.05)',
+ color: '#fff',
+ fontSize: '18px',
+ outline: 'none',
+ textAlign: 'center',
+ fontWeight: '700'
+ }}
+ />
+
+
+
+ {/* 登录按钮 */}
+
+
+ {/* 注册链接 */}
+
+ setShowRegister(true)}
+ style={{
+ color: '#fbbf24',
+ fontSize: '14px',
+ cursor: 'pointer',
+ textDecoration: 'underline'
+ }}
+ >
+ 注册新账号
+
+ |
+
+ 需要帮助?联系管理员
+
+
+
+
+ {/* 版本号 */}
+
+ v{APP_VERSION}
+
+
+ {/* 注册弹窗 */}
+ {showRegister && (
+
+
+
+ 注册新账号 🎉
+
+
+ {registerError && (
+
+ ⚠️ {registerError}
+
+ )}
+
+ {/* 用户名 */}
+
+ setRegisterData(prev => ({ ...prev, username: e.target.value }))}
+ placeholder="用户名(至少 2 个字符)"
+ style={{
+ width: '100%',
+ padding: '14px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255,255,255,0.2)',
+ background: 'rgba(255,255,255,0.05)',
+ color: '#fff',
+ fontSize: '16px',
+ outline: 'none'
+ }}
+ />
+
+
+ {/* 手机号 */}
+
+ setRegisterData(prev => ({ ...prev, phone: e.target.value }))}
+ placeholder="手机号(11位)"
+ maxLength={11}
+ style={{
+ width: '100%',
+ padding: '14px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255,255,255,0.2)',
+ background: 'rgba(255,255,255,0.05)',
+ color: '#fff',
+ fontSize: '16px',
+ outline: 'none'
+ }}
+ />
+
+
+ {/* 短信获取 */}
+
+
+ setRegisterData(prev => ({ ...prev, verifyCode: e.target.value }))}
+ placeholder="短信验证码"
+ maxLength={6}
+ style={{
+ flex: 1, minWidth: "90px", width: "auto",
+ padding: '14px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255,255,255,0.2)',
+ background: 'rgba(255,255,255,0.05)',
+ color: '#fff',
+ fontSize: '16px',
+ outline: 'none'
+ }}
+ />
+
+
+
+
+ {/* 邮箱(可选) */}
+
+ setRegisterData(prev => ({ ...prev, email: e.target.value }))}
+ placeholder="邮箱(可选)"
+ style={{
+ width: '100%',
+ padding: '14px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255,255,255,0.2)',
+ background: 'rgba(255,255,255,0.05)',
+ color: '#fff',
+ fontSize: '16px',
+ outline: 'none'
+ }}
+ />
+
+
+ {/* 密码 */}
+
+ setRegisterData(prev => ({ ...prev, password: e.target.value }))}
+ placeholder="密码(至少8位,需大写+小写+数字)"
+ style={{
+ width: '100%',
+ padding: '14px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255,255,255,0.2)',
+ background: 'rgba(255,255,255,0.05)',
+ color: '#fff',
+ fontSize: '16px',
+ outline: 'none'
+ }}
+ />
+
+
+ {/* 确认密码 */}
+
+ setRegisterData(prev => ({ ...prev, confirmPassword: e.target.value }))}
+ placeholder="确认密码"
+ style={{
+ width: '100%',
+ padding: '14px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255,255,255,0.2)',
+ background: 'rgba(255,255,255,0.05)',
+ color: '#fff',
+ fontSize: '16px',
+ outline: 'none'
+ }}
+ />
+
+
+
+ {/* 用户协议勾选 */}
+
+
setRegisterData(prev => ({ ...prev, agreeTerms: e.target.checked }))}
+ style={{ width: '18px', height: '18px', marginTop: '2px', cursor: 'pointer' }}
+ />
+
+ 我已阅读并同意
+ window.open('/user_agreement.html', '_blank')}
+ style={{ color: '#fbbf24', cursor: 'pointer', textDecoration: 'underline' }}
+ >《用户协议》
+
+
+
+ {/* 按钮 */}
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/OCR.jsx b/frontend/src/pages/OCR.jsx
new file mode 100644
index 0000000..0b1229f
--- /dev/null
+++ b/frontend/src/pages/OCR.jsx
@@ -0,0 +1,654 @@
+// AI OCR 识别页面 - 支持拍照识别和手工录入
+import React, { useState, useEffect, useRef } from 'react'
+
+// 字段转换函数:camelCase → 字段编码
+const convertField = (obj) => {
+ const map = {
+ // f99 系统字段
+ id: 'f99_90_id',
+ userId: 'f99_91_user_id',
+ // f01 基本信息
+ name: 'f01_01_name',
+ code: 'f01_02_code',
+ category: 'f01_03_category',
+ status: 'f01_04_status',
+ remark: 'f01_05_remark',
+ // f02 详细字段
+ prefixSerial: 'f02_10_prefix_serial',
+ version: 'f02_11_version',
+ packaging: 'f02_12_packaging',
+ rarity: 'f02_13_rarity',
+ // f03 评级信息
+ isGraded: 'f03_20_is_graded',
+ gradingCompany: 'f03_21_grading_company',
+ gradingScore: 'f03_22_grading_score',
+ threeStar: 'f03_23_three_star',
+ // f04 特殊信息
+ specialMark: 'f04_30_special_mark',
+ serialFeature: 'f04_31_serial_feature',
+ issuer: 'f04_32_issuer',
+ issueYear: 'f04_33_issue_year',
+ material: 'f04_34_material',
+ denomination: 'f04_35_denomination',
+ issueQuantity: 'f04_36_issue_quantity',
+ // f05 价格信息
+ 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',
+ // f06 其他信息
+ 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, disabled = false }) => {
+ const inputRef = useRef(null)
+ const handleFocus = () => {
+ setTimeout(() => {
+ inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ }, 100)
+ }
+
+ 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: '',
+ purpose: '收藏',
+ isGraded: false,
+ gradingCompany: '',
+ gradingScore: '',
+ threeStar: false,
+ specialMark: '',
+ serialFeature: '',
+ issuer: '中国人民银行',
+ issueYear: '',
+ costPrice: '',
+ targetPrice: '',
+ goalPrice: '',
+ repairFee: '',
+ gradingFee: '',
+ remark: ''
+})
+
+export default function OCR() {
+ const [activeTab, setActiveTab] = useState('ai') // ai, manual, batch
+ const [mode, setMode] = useState('camera') // camera, form
+ 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 fileInputRef = useRef(null)
+ const videoRef = useRef(null)
+ const canvasRef = useRef(null)
+
+ // 选项定义
+ const statusOptions = [
+ { value: 'in_collection', label: '收藏中' },
+ { value: 'selling', label: '出售中' },
+ { value: 'sold', label: '已售' },
+ { value: 'grading', label: '送评中' },
+ { value: 'repairing', label: '修复中' },
+ { value: 'transit', label: '在途中' },
+ { value: 'other', label: '其他' }
+ ]
+
+ const categoryOptions = [
+ { value: '自持', label: '自持' },
+ { 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 })
+ if (key === 'targetPrice' && value) {
+ setForm(prev => ({ ...prev, status: 'sold' }))
+ }
+ }
+
+ // 选择图片
+ 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)
+ }
+
+ // 调用 OCR 识别
+ 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() }
+
+ // 映射识别字段到表单字段
+ if (data.fields.name) recognizedForm.name = data.fields.name
+ if (data.fields.code) recognizedForm.code = data.fields.code
+ if (data.fields.version) recognizedForm.version = data.fields.version
+ if (data.fields.prefix_serial) recognizedForm.prefixSerial = data.fields.prefix_serial
+ if (data.fields.denomination) recognizedForm.denomination = data.fields.denomination
+ if (data.fields.material) recognizedForm.material = data.fields.material
+ if (data.fields.issue_year) recognizedForm.issueYear = data.fields.issue_year
+ if (data.fields.issuer) recognizedForm.issuer = data.fields.issuer
+ if (data.fields.cost_price) recognizedForm.costPrice = data.fields.cost_price
+ if (data.fields.remark) recognizedForm.remark = data.fields.remark
+
+ setForm(recognizedForm)
+ setMode('form')
+ alert('识别成功!请检查并完善信息')
+ } else {
+ setError('识别结果为空')
+ }
+ } catch (e) {
+ console.error('OCR 识别失败:', 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 formData = convertField(form)
+
+ try {
+ 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 || '保存失败')
+ }
+
+ alert('保存成功!')
+ window.location.hash = '#/list'
+ window.refreshList?.()
+ window.refreshHome?.()
+ } catch (e) {
+ alert('保存失败:' + e.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ // 拍照模式界面
+ if (mode === 'camera') {
+ return (
+
+ {/* 顶部标签切换 */}
+
+
+
+
+
+
+
+
+ {/* 内容区域 */}
+ {activeTab === 'ai' && (
+
+
+ {/* 图片选择区域 */}
+
+
+
+ {imagePreview ? (
+
+

+
+
+
+
+
+ ) : (
+
+
{
+ fileInputRef.current.setAttribute('capture', '');
+ fileInputRef.current.setAttribute('accept', 'image/*');
+ 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.setAttribute('accept', 'image/*');
+ 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'
+ }}
+ >
+
🖼️
+
+ 从相册选择
+
+
+ 从相册选择已有图片
+
+
+
+ )}
+
+
+ {/* 错误提示 */}
+ {error && (
+
+ ⚠️ {error}
+
+ )}
+
+ {/* 提示信息 */}
+
+
💡 识别说明
+
+ - 支持拍照或从相册选择图片
+ - 自动识别名称、版别、冠字序号等字段
+ - 识别结果可手动修改完善
+ - 建议拍摄清晰、光线充足的正面照片
+
+
+
+
+ )
+ }
+
+ // 表单模式界面
+ return (
+
+ {/* 顶部 */}
+
+
+
+
+
+ {error && (
+
+ ⚠️ {error}
+
+ )}
+
+
+ {/* 基本信息 */}
+
+
+ {/* 价格信息 */}
+
+
+ {/* 备注 */}
+
+
+ {/* 保存按钮 */}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
new file mode 100644
index 0000000..7857338
--- /dev/null
+++ b/frontend/src/pages/Settings.jsx
@@ -0,0 +1,434 @@
+import React, { useState, useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+
+export default function Settings() {
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+ const [success, setSuccess] = useState('')
+
+ const [form, setForm] = useState({
+ username: '',
+ email: '',
+ phone: '',
+ avatar: '',
+ address: '',
+ bio: ''
+ })
+
+ // 手机验证码相关状态
+ const [phoneChanged, setPhoneChanged] = useState(false)
+ const [newPhone, setNewPhone] = useState('')
+ const [verifyCode, setVerifyCode] = useState('')
+ const [sendingCode, setSendingCode] = useState(false)
+ const [codeCountdown, setCodeCountdown] = useState(0)
+
+ const [passwordForm, setPasswordForm] = useState({
+ oldPassword: '',
+ newPassword: '',
+ confirmPassword: ''
+ })
+
+ const navigate = useNavigate()
+ const token = localStorage.getItem('token')
+
+ useEffect(() => {
+ fetchUserInfo()
+ }, [])
+
+ // 验证码倒计时
+ useEffect(() => {
+ if (codeCountdown > 0) {
+ const timer = setTimeout(() => setCodeCountdown(codeCountdown - 1), 1000)
+ return () => clearTimeout(timer)
+ }
+ }, [codeCountdown])
+
+ const fetchUserInfo = async () => {
+ try {
+ const res = await fetch('/api/users/me', {
+ headers: { 'Authorization': 'Bearer ' + token }
+ })
+ if (res.ok) {
+ const data = await res.json()
+ console.log('用户信息:', data)
+ setForm({
+ username: data.username || '',
+ email: data.email || '',
+ phone: data.phone || '',
+ avatar: data.avatar || '',
+ address: data.address || '',
+ bio: data.bio || ''
+ })
+ setNewPhone(data.phone || '')
+ }
+ } catch (e) {
+ console.error('获取用户信息异常:', e)
+ const userStr = localStorage.getItem('user')
+ if (userStr) {
+ try {
+ const user = JSON.parse(userStr)
+ setForm({
+ username: user.username || '',
+ email: user.email || '',
+ phone: user.phone || '',
+ avatar: user.avatar || '',
+ address: user.address || '',
+ bio: user.bio || ''
+ })
+ setNewPhone(user.phone || '')
+ } catch (e2) {
+ setError('获取用户信息失败')
+ }
+ }
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // 发送手机验证码
+ const handleSendCode = async () => {
+ if (!newPhone) {
+ setError('请输入手机号')
+ return
+ }
+
+ const phoneRegex = /^1[3-9]\d{9}$/
+ if (!phoneRegex.test(newPhone)) {
+ setError('请输入正确的手机号')
+ return
+ }
+
+ setSendingCode(true)
+ setError('')
+
+ try {
+ const res = await fetch('/api/auth/send-verification-code', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ phone: newPhone })
+ })
+
+ const data = await res.json()
+
+ if (data.success) {
+ setPhoneChanged(true)
+ setCodeCountdown(60)
+ setSuccess('验证码已发送到 ' + newPhone.substring(0,3) + '****' + newPhone.substring(7))
+ } else {
+ setError(data.message || '发送失败')
+ }
+ } catch (err) {
+ setError('发送失败,请稍后重试')
+ } finally {
+ setSendingCode(false)
+ }
+ }
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ setError('')
+ setSuccess('')
+
+ // 如果更换了手机号,需要验证码
+ if (newPhone !== form.phone) {
+ if (!verifyCode) {
+ setError('请输入手机验证码')
+ return
+ }
+ }
+
+ setSaving(true)
+
+ try {
+ const updateData = {
+ username: form.username,
+ email: form.email || null,
+ phone: newPhone,
+ address: form.address,
+ bio: form.bio
+ }
+
+ // 如果更换了手机号,添加验证码
+ if (newPhone !== form.phone) {
+ updateData.verifyCode = verifyCode
+ }
+
+ const res = await fetch('/api/users/me', {
+ method: 'PUT',
+ headers: {
+ 'Authorization': 'Bearer ' + token,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(updateData)
+ })
+
+ if (res.ok) {
+ const data = await res.json()
+ setSuccess('保存成功!')
+ setForm({...form, phone: newPhone})
+ setVerifyCode('')
+ setPhoneChanged(false)
+ // 更新本地存储
+ const userStr = localStorage.getItem('user')
+ if (userStr) {
+ const user = JSON.parse(userStr)
+ user.username = data.username || user.username
+ user.email = data.email || user.email
+ user.phone = data.phone || user.phone
+ localStorage.setItem('user', JSON.stringify(user))
+ }
+ } else {
+ const data = await res.json()
+ setError(data.error?.message || data.detail || '保存失败')
+ }
+ } catch (e) {
+ setError('保存失败,请重试')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handlePasswordChange = async (e) => {
+ e.preventDefault()
+ setError('')
+ setSuccess('')
+
+ if (!passwordForm.oldPassword) {
+ setError('请输入当前密码')
+ return
+ }
+
+ if (passwordForm.newPassword !== passwordForm.confirmPassword) {
+ setError('两次输入的密码不一致')
+ return
+ }
+
+ if (passwordForm.newPassword.length < 8) {
+ setError('密码至少8位,需包含大写+小写+数字')
+ return
+ }
+
+ setSaving(true)
+
+ try {
+ const res = await fetch('/api/auth/change-password', {
+ method: 'POST',
+ headers: {
+ 'Authorization': 'Bearer ' + token,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ old_password: passwordForm.oldPassword,
+ new_password: passwordForm.newPassword
+ })
+ })
+
+ const data = await res.json()
+
+ if (res.ok) {
+ setSuccess('密码修改成功!')
+ setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' })
+ } else {
+ setError(data.error?.message || data.detail || '密码修改失败')
+ }
+ } catch (e) {
+ setError('密码修改失败,请重试')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {/* 顶部导航 */}
+
+
window.history.back()} style={{ cursor: 'pointer', fontSize: '20px', color: '#fff' }}>←
+
个人设置
+
+
+ {error &&
{error}
}
+ {success &&
{success}
}
+
+ {/* 基本信息 */}
+
+
+ {/* 修改密码 */}
+
+
修改密码
+
+
+
+
+ {/* 退出登录 */}
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx
new file mode 100644
index 0000000..97ee369
--- /dev/null
+++ b/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)'
+ }}
+ >
+
+
+ )
+ })}
+
+ {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 (
+
+ {/* 顶部 */}
+
+
+
+ {/* 总统计 */}
+
+
📊 总统计
+
+
+
总数量
+
{stats.totalCount}
+
+
+
+
未评级
+
{ungradedCount}
+
+
+
+
+ {/* 财务统计 */}
+
+
💰 财务统计
+
+
+
总成本
+
¥{formatMoney(stats.totalCost)}
+
+
+
总收入
+
¥{formatMoney(stats.totalRevenue)}
+
+
+
预期利润
+
+¥{formatMoney(stats.expectedProfit)}
+
+
+
已实现利润
+
+¥{formatMoney(stats.totalProfit)}
+
+
+
+
+ {/* 盈亏统计移到顶部 */}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
new file mode 100644
index 0000000..5ae698f
--- /dev/null
+++ b/frontend/src/utils/api.js
@@ -0,0 +1,230 @@
+// 统一的 API 客户端
+
+const API_BASE = '' // 生产环境由 Nginx 代理
+
+import { ErrorCodes, matchErrorCode } from './errorCodes.js'
+
+// 错误处理
+class ApiError extends Error {
+ constructor(message, status, data, code = null) {
+ super(message)
+ this.name = 'ApiError'
+ this.status = status
+ this.data = data
+ this.code = code || matchErrorCode(status, message).code
+ }
+
+ // 获取格式化的错误信息
+ get formattedMessage() {
+ return `${this.code}: ${this.message}`
+ }
+}
+
+// 获取 Token
+const getToken = () => localStorage.getItem('token')
+
+// 统一请求方法
+async function request(endpoint, options = {}) {
+ const token = getToken()
+
+ const defaultHeaders = {
+ 'Content-Type': 'application/json',
+ }
+
+ if (token) {
+ defaultHeaders['Authorization'] = `Bearer ${token}`
+ }
+
+ const config = {
+ ...options,
+ headers: {
+ ...defaultHeaders,
+ ...(options.headers || {})
+ }
+ }
+
+ try {
+ const response = await fetch(`${API_BASE}${endpoint}`, config)
+
+ const data = await response.json()
+
+ if (!response.ok) {
+ // 处理 401 未授权
+ if (response.status === 401) {
+ localStorage.removeItem('token')
+ localStorage.removeItem('user')
+ window.location.hash = '#/login'
+ throw new ApiError(
+ data.detail || data.message || '登录已过期,请重新登录',
+ 401,
+ data,
+ 'E00010'
+ )
+ }
+
+ // 处理 422 验证错误
+ if (response.status === 422 && data.detail) {
+ const detail = Array.isArray(data.detail) ? data.detail[0] : data.detail
+ const field = detail.loc ? detail.loc.join('.') : ''
+ const msg = detail.msg || detail.message || data.message
+
+ throw new ApiError(
+ `${field}: ${msg}`,
+ 422,
+ data
+ )
+ }
+
+ // 其他错误
+ throw new ApiError(
+ data.detail || data.message || data.error || '请求失败',
+ response.status,
+ data
+ )
+ }
+
+ return data
+ } catch (error) {
+ // 网络错误
+ if (error.name === 'TypeError' && error.message.includes('fetch')) {
+ throw new ApiError('网络连接失败,请检查网络', 0, null, 'E00001')
+ }
+ throw error
+ }
+}
+
+// API 模块
+export const api = {
+ // 认证
+ auth: {
+ login: async (username, password) => {
+ // 登录时不使用 token
+ const params = new URLSearchParams()
+ params.append('username', username)
+ params.append('password', password)
+
+ const response = await fetch(`${API_BASE}/api/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: params.toString()
+ })
+
+ const data = await response.json()
+
+ if (!response.ok) {
+ // 优先使用 error.code 和 error.message(后端标准格式)
+ const errorCode = data.error?.code || data.code
+ const errorMsg = data.error?.message || data.detail || data.message || '登录失败'
+
+ const error = new ApiError(errorMsg, response.status, data)
+ if (errorCode) error.code = errorCode
+ throw error
+ }
+
+ return data
+ },
+
+ register: (data) => request('/api/auth/register', {
+ method: 'POST',
+ body: JSON.stringify(data)
+ })
+ },
+
+ // 当前用户
+ user: {
+ me: async () => {
+ const token = getToken()
+ if (!token) throw new ApiError('未登录', 401, null, 'E00010')
+
+ const response = await fetch(`${API_BASE}/api/users/me`, {
+ headers: { 'Authorization': `Bearer ${token}` }
+ })
+
+ const data = await response.json()
+
+ if (!response.ok) {
+ throw new ApiError(
+ data.detail || data.message || '获取用户信息失败',
+ response.status,
+ data
+ )
+ }
+
+ return data
+ },
+ update: (data) => request('/api/users/me', {
+ method: 'PUT',
+ body: JSON.stringify(data)
+ })
+ },
+
+ // 藏品
+ collections: {
+ // 列表
+ list: (params = {}) => {
+ const query = new URLSearchParams(params).toString()
+ return request(`/api/collections${query ? '?' + query : ''}`)
+ },
+
+ // 详情
+ get: (id) => request(`/api/collections/${id}`),
+
+ // 创建
+ create: (data) => request('/api/collections', {
+ method: 'POST',
+ body: JSON.stringify(data)
+ }),
+
+ // 更新
+ update: (id, data) => request(`/api/collections/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data)
+ }),
+
+ // 删除
+ delete: (id) => request(`/api/collections/${id}`, {
+ method: 'DELETE'
+ }),
+
+ // 下一个编号
+ nextCode: () => request('/api/collections/next-code'),
+
+ // 统计
+ stats: (params = {}) => {
+ const query = new URLSearchParams(params).toString()
+ return request(`/api/collections/stats${query ? '?' + query : ''}`)
+ },
+
+ // 导出
+ export: (params) => request(`/api/collections/export?${new URLSearchParams(params)}`)
+ },
+
+ // OCR
+ ocr: {
+ recognize: (file) => {
+ const formData = new FormData()
+ formData.append('image', file)
+
+ return request('/api/ocr/recognize', {
+ method: 'POST',
+ body: formData
+ })
+ }
+ },
+
+ // 用户管理(仅管理员)
+ admin: {
+ users: {
+ list: (page = 1, limit = 20) => request(`/api/admin/users?page=${page}&limit=${limit}`),
+ get: (userId) => request(`/api/admin/users/${userId}`),
+ collections: (userId) => request(`/api/admin/users/${userId}/collections`),
+ count: (userId) => request(`/api/admin/users/${userId}/count`)
+ }
+ }
+}
+
+// 导出错误类
+export { ApiError }
+
+// 默认导出
+export default api
diff --git a/frontend/src/utils/errorCodes.js b/frontend/src/utils/errorCodes.js
new file mode 100644
index 0000000..e0ea3ce
--- /dev/null
+++ b/frontend/src/utils/errorCodes.js
@@ -0,0 +1,165 @@
+// 错误码定义
+// 格式:E + 模块 (2 位) + 序号 (3 位)
+
+export const ErrorCodes = {
+ // ============ 通用错误 (00-09) ============
+ E00000: { code: 'E00000', message: '未知错误', httpStatus: 0 },
+ E00001: { code: 'E00001', message: '网络连接失败,请检查网络', httpStatus: 0 },
+ E00002: { code: 'E00002', message: '服务器响应超时', httpStatus: 0 },
+ E00003: { code: 'E00003', message: '服务器内部错误', httpStatus: 500 },
+
+ // ============ 认证错误 (10-19) ============
+ E00010: { code: 'E00010', message: '未登录或登录已过期', httpStatus: 401 },
+ E00011: { code: 'E00011', message: '用户名或密码错误', httpStatus: 401 },
+ E00012: { code: 'E00012', message: '验证码错误', httpStatus: 400 },
+ E00013: { code: 'E00013', message: '账号已被禁用', httpStatus: 403 },
+ E00014: { code: 'E00014', message: '无权访问此资源', httpStatus: 403 },
+ E00015: { code: 'E00015', message: '令牌无效或已过期', httpStatus: 401 },
+
+ // ============ 登录注册 (20-29) ============
+ E00020: { code: 'E00020', message: '请输入用户名和密码', httpStatus: 400 },
+ E00021: { code: 'E00021', message: '用户名至少 3 个字符', httpStatus: 400 },
+ E00022: { code: 'E00022', message: '密码至少 6 个字符', httpStatus: 400 },
+ E00023: { code: 'E00023', message: '用户名已存在', httpStatus: 400 },
+ E00024: { code: 'E00024', message: '邮箱已被注册', httpStatus: 400 },
+ E00025: { code: 'E00025', message: '邮箱格式不正确', httpStatus: 400 },
+ E00026: { code: 'E00026', message: '手机号格式不正确', httpStatus: 400 },
+
+ // ============ 藏品管理 (30-39) ============
+ E00030: { code: 'E00030', message: '藏品名称不能为空', httpStatus: 400 },
+ E00031: { code: 'E00031', message: '藏品名称至少 2 个字符', httpStatus: 400 },
+ E00032: { code: 'E00032', message: '藏品分类不能为空', httpStatus: 400 },
+ E00033: { code: 'E00033', message: '藏品不存在', httpStatus: 404 },
+ E00034: { code: 'E00034', message: '禁止重复:此冠字号已存在', httpStatus: 400 },
+ E00035: { code: 'E00035', message: '成本价格必须>=0', httpStatus: 400 },
+ E00036: { code: 'E00036', message: '目标价格必须>=0', httpStatus: 400 },
+ E00037: { code: 'E00037', message: '发行年份必须是 4 位数字', httpStatus: 400 },
+ E00038: { code: 'E00038', message: '图片格式不正确', httpStatus: 400 },
+ E00039: { code: 'E00039', message: '图片大小不能超过 10MB', httpStatus: 400 },
+
+ // ============ OCR 识别 (40-49) ============
+ E00040: { code: 'E00040', message: '请选择图片文件', httpStatus: 400 },
+ E00041: { code: 'E00041', message: '图片尺寸太小,无法识别', httpStatus: 400 },
+ E00042: { code: 'E00042', message: 'OCR 识别失败,请重试', httpStatus: 500 },
+ E00043: { code: 'E00043', message: 'OCR 服务暂时不可用', httpStatus: 503 },
+ E00044: { code: 'E00044', message: '无法识别图片内容', httpStatus: 400 },
+
+ // ============ 用户管理 (50-59) ============
+ E00050: { code: 'E00050', message: '仅管理员可访问', httpStatus: 403 },
+ E00051: { code: 'E00051', message: '用户不存在', httpStatus: 404 },
+ E00052: { code: 'E00052', message: '不能删除自己', httpStatus: 400 },
+ E00053: { code: 'E00053', message: '不能修改自己的角色', httpStatus: 403 },
+
+ // ============ 文件上传 (60-69) ============
+ E00060: { code: 'E00060', message: '文件太大', httpStatus: 400 },
+ E00061: { code: 'E00061', message: '不支持的文件格式', httpStatus: 400 },
+ E00062: { code: 'E00062', message: '上传失败', httpStatus: 500 },
+}
+
+// 根据 HTTP 状态码和错误信息匹配错误码
+export function matchErrorCode(status, errorMessage = '') {
+ // 首先尝试精确匹配错误信息
+ for (const code in ErrorCodes) {
+ const error = ErrorCodes[code]
+ if (error.httpStatus === status) {
+ // 检查错误信息是否包含关键词
+ const msg = errorMessage.toLowerCase()
+
+ if (status === 401) {
+ if (msg.includes('password') || msg.includes('密码')) return ErrorCodes.E00011
+ if (msg.includes('token') || msg.includes('令牌')) return ErrorCodes.E00015
+ return ErrorCodes.E00010
+ }
+
+ if (status === 403) {
+ if (msg.includes('admin') || msg.includes('管理员')) return ErrorCodes.E00050
+ return ErrorCodes.E00014
+ }
+
+ if (status === 404) {
+ if (msg.includes('user')) return ErrorCodes.E00051
+ if (msg.includes('collection') || msg.includes('藏品')) return ErrorCodes.E00033
+ }
+
+ if (status === 400) {
+ if (msg.includes('captcha') || msg.includes('验证码')) return ErrorCodes.E00012
+ if (msg.includes('username') || msg.includes('用户名')) return ErrorCodes.E00023
+ if (msg.includes('email') || msg.includes('邮箱')) return ErrorCodes.E00024
+ if (msg.includes('重复') || msg.includes('duplicate')) return ErrorCodes.E00034
+ if (msg.includes('价格') || msg.includes('price')) {
+ if (msg.includes('greater') || msg.includes('>=')) return ErrorCodes.E00035
+ }
+ if (msg.includes('year') || msg.includes('年份')) return ErrorCodes.E00037
+ if (msg.includes('image') || msg.includes('图片')) return ErrorCodes.E00038
+ }
+
+ if (status === 422) {
+ // 验证错误
+ if (msg.includes('cost_price') || msg.includes('成本')) return ErrorCodes.E00035
+ if (msg.includes('target_price') || msg.includes('目标价格')) return ErrorCodes.E00036
+ if (msg.includes('issue_year') || msg.includes('年份')) return ErrorCodes.E00037
+ if (msg.includes('name') || msg.includes('名称')) return ErrorCodes.E00031
+ if (msg.includes('category') || msg.includes('分类')) return ErrorCodes.E00032
+ }
+
+ if (status === 500) {
+ if (msg.includes('ocr') || msg.includes('识别')) return ErrorCodes.E00042
+ }
+ }
+ }
+
+ // 默认错误码
+ if (status === 0) return ErrorCodes.E00001
+ if (status >= 500) return ErrorCodes.E00003
+ return ErrorCodes.E00000
+}
+
+// 格式化错误信息
+export function formatError(error) {
+ if (error.code && typeof error.code === 'string' && error.code.startsWith('E')) {
+ // 已经是错误码格式
+ const errorCode = ErrorCodes[error.code]
+ if (errorCode) {
+ return `${errorCode.code}: ${errorCode.message}`
+ }
+ }
+
+ // 匹配错误码
+ const status = error.status || error.httpStatus || 0
+ const message = error.message || error.detail || error.error || ''
+ const errorCode = matchErrorCode(status, message)
+
+ return `${errorCode.code}: ${errorCode.message}`
+}
+
+// 显示错误 Toast
+export function showErrorToast(error, duration = 3000) {
+ const errorMessage = formatError(error)
+
+ // 创建 Toast 元素
+ const toast = document.createElement('div')
+ toast.style.cssText = `
+ position: fixed;
+ top: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(239, 68, 68, 0.95);
+ color: white;
+ padding: 12px 24px;
+ border-radius: 8px;
+ font-size: 14px;
+ z-index: 9999;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+ animation: slideDown 0.3s ease;
+ `
+ toast.textContent = errorMessage
+ document.body.appendChild(toast)
+
+ // 3 秒后移除
+ setTimeout(() => {
+ toast.style.animation = 'slideUp 0.3s ease'
+ setTimeout(() => toast.remove(), 300)
+ }, duration)
+}
+
+export default { ErrorCodes, matchErrorCode, formatError, showErrorToast }
diff --git a/start.sh b/start.sh
index c6dcf0a..d326932 100755
--- a/start.sh
+++ b/start.sh
@@ -1,7 +1,7 @@
#!/bin/bash
cd /root/jiachenlong/backend
-export DATABASE_URL=postgresql://caibot:caibot123@47.98.171.101:5432/zodiac
-export SECRET_KEY=production-secret-key-a-env
+export DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/zodiac
+export SECRET_KEY=production-secret-key-c-env
export OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
export OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
export SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
@@ -9,4 +9,4 @@ export SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
export SMS_SIGN_NAME=苏州算力
export SMS_TEMPLATE_CODE=SMS_501590956
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 > /tmp/uvicorn.log 2>&1 &
-echo "后端服务已启动"
+echo '后端服务已启动'
diff --git a/user_agreement.html b/user_agreement.html
new file mode 100644
index 0000000..f06cf22
--- /dev/null
+++ b/user_agreement.html
@@ -0,0 +1,115 @@
+
+
+
+
+
+
用户协议 - 甲辰收藏
+
+
+
+
+
甲辰收藏平台用户协议
+
版本生效日期:自注册之日起
+
+
特别提示:本协议涉及您的重大权利义务,加粗条款请您重点阅读。您完成注册、登录或继续使用本平台服务,即视为已充分阅读、理解并同意本协议全部内容。本协议适用《中华人民共和国民法典》《网络安全法》《个人信息保护法》《电子商务法》等法律法规。
+
+
一、协议主体与适用范围
+
+ - 本协议由您与甲辰收藏平台运营主体(以下简称"平台")签订,约束您使用平台藏品管理,信息展示,数据存储,社区互动等全部服务。
+ - 平台发布的隐私政策、藏品发布规则、交易规范等均为本协议组成部分,与本协议具有同等法律效力。
+ - 您承诺为具备完全民事行为能力的自然人/法人;未成年人使用需监护人同意并陪同。
+
+
+
二、个人信息收集与使用
+
(一)收集原则
+
平台遵循合法、正当、必要、诚信、最小必要原则收集信息,仅为实现服务功能所必需,不强制收集非必要信息。
+
+
(二)收集范围与目的
+
+ - 注册与身份信息:用户名、手机号、邮箱(用于账号创建,安全验证、客服联系)。
+ - 藏品与行为信息:藏品上传内容、收藏记录、浏览操作、发布评论、交易数据(用于藏品管理,服务优化、风控核验)。
+ - 设备与网络信息:设备型号、系统版本、IP 地址、日志信息(用于安全防护、故障排查,防作弊)。
+ - 位置信息:仅在您主动开启定位权限时收集,用于同城展示等可选功能,关闭不影响基础服务。
+
+
+
(三)信息使用规则
+
+ - 平台仅在您授权范围内使用信息,不超出约定目的、范围与期限处理。
+ - 向第三方共享信息时,将单独告知并取得您明确同意,法律法规要求除外。
+ - 您有权查阅、更正、删除个人信息,申请注销账号,平台在核验身份后依法处理。
+ - 平台采取加密、去标识化、权限管控等措施保护信息安全,制定安全事件应急预案。
+
+
+
三、公开信息使用与免责条款
+
+ - 公开信息定义:您通过平台主动发布、设置为公开可见的藏品资料、图文、评论、动态等内容,均属于您自行公开的信息。
+ - 授权使用:您同意平台可在合理范围内使用您的公开信息,用于藏品展示、平台运营、合规审核、服务推广等,不侵犯您合法权益。
+ - 法定免责依据:根据《个人信息保护法》第二十七条,平台处理您自行合法公开的信息,在无明确拒绝且不对您权益造成重大影响的情形下,无需另行取得单独同意。
+ - 第三方行为免责:
+
+ - 公开信息可被其他用户浏览、复制、存储、转发,平台无法完全控制第三方使用行为。
+ - 因第三方擅自使用、转载、篡改您公开信息引发的纠纷、损失,平台不承担责任。
+
+
+ - 内容责任自负:
+
+ - 您对公开信息的真实性、合法性、原创性承担全部责任,不得侵犯第三方知识产权、肖像权、名誉权等。
+ - 如因您发布违法、侵权内容导致平台受损,您应承担全部赔偿责任。
+
+
+ - 平台管理边界:平台仅依据法律法规与平台规则进行内容审核,对用户自主发布的公开信息不做修改、篡改,不承担真实性担保责任。
+
+
+
四、用户权利与义务
+
+ - 您有权使用平台基础服务,对账号与密码安全负责,不得转借、出售账号。
+ - 不得发布违法违规、侵权、虚假、低俗等违反法律法规与公序良俗的内容。
+ - 不得利用平台从事洗钱、诈骗、非法交易等违法活动。
+ - 您对自行上传的藏品素材、文字等享有知识产权,授权平台在服务范围内非独占使用。
+
+
+
五、知识产权条款
+
+ - 平台所有文字、图标、界面设计、软件代码等知识产权归平台所有,受法律保护。
+ - 您上传的原创内容知识产权归您所有;您授权平台为提供服务之目的,使用、存储、展示、传播该内容。
+ - 未经权利人书面许可,任何主体不得复制、改编、传播、商用平台内容或用户原创内容。
+
+
+
六、服务变更、中断与终止
+
+ - 平台因维护、升级、政策调整需变更或暂停服务的,将提前公示;因不可抗力、监管要求、第三方故障导致服务中断的,平台不承担违约责任。
+ - 平台有权对违规账号采取警示、限流、删帖、封禁等处理措施。
+ - 您可随时停止使用服务;账号注销后,平台按法律法规留存相关数据,逾期依法删除。
+
+
+
七、免责声明(法律允许范围内)
+
+ - 平台按"现状"提供服务,对服务及时性、安全性、稳定性不作绝对担保。
+ - 平台不对藏品真伪、价值、权属作明示或暗示保证,藏品鉴定与价值判断由您自行负责。
+ - 法律允许的最大范围内,平台不对间接损失、利润损失、数据丢失等承担赔偿责任;因平台故意或重大过失导致的损失除外。
+ - 因您自身操作不当、账号保管不善、第三方侵权等导致的损失,由您自行承担。
+
+
+
八、协议修改与争议解决
+
+ - 平台修改协议将提前7日公示,您继续使用视为接受修订;如不同意,可停止使用并注销账号。
+ - 因本协议产生争议,双方协商解决;协商不成,提交平台运营主体所在地有管辖权的人民法院诉讼解决。
+ - 本协议适用中华人民共和国大陆地区法律(不含港澳台法律)。
+
+
+
九、联系与通知
+
平台联系方式:aicoolbot@163.com;您可通过客服渠道咨询协议、隐私、投诉等相关事宜。
+
+
+