完整C环境部署版本 v1.2.12

This commit is contained in:
jiachenlong 2026-03-23 11:08:52 +08:00
parent 0143a06a2a
commit 9913530863
46 changed files with 9571 additions and 4 deletions

9
backend/.dockerignore Normal file
View File

@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.pyo
.git
.env
uploads/*
!uploads/.gitkeep
logs/*
*.log

26
backend/.env.example Normal file
View File

@ -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

25
backend/Dockerfile Normal file
View File

@ -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"]

42
backend/README.md Normal file
View File

@ -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

View File

95
backend/app/core/auth.py Normal file
View File

@ -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

View File

@ -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()

View File

@ -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
}
}
)

View File

@ -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

103
backend/app/main.py Normal file
View File

@ -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)

View File

@ -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

View File

View File

@ -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")

View File

182
backend/app/routers/auth.py Normal file
View File

@ -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": "验证码错误或已过期"}

View File

@ -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. 删除关联的 operationsf99_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="删除失败")

376
backend/app/routers/ocr.py Normal file
View File

@ -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
}
}

View File

@ -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

View File

@ -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": "删除成功"}

View File

View File

@ -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

126
backend/app/services/oss.py Normal file
View File

@ -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

106
backend/app/services/sms.py Normal file
View File

@ -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

13
backend/requirements.txt Normal file
View File

@ -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

0
backend/uploads/.gitkeep Normal file
View File

View File

@ -1,6 +1,6 @@
{ {
"name": "jiachenlong-frontend", "name": "jiachenlong-frontend",
"version": "1.0.1", "version": "1.2.12",
"private": true, "private": true,
"description": "甲辰藏品管理系统 - 移动端前端", "description": "甲辰藏品管理系统 - 移动端前端",
"scripts": { "scripts": {

110
frontend/src/App.jsx Normal file
View File

@ -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 <Home />
if (basePath === '/stats') return <Stats />
if (basePath === '/list') return <List />
if (basePath === '/add') return <Add />
if (basePath === '/login') return <Login />
if (basePath === '/settings') return <Settings />
if (basePath === '/admin') return <Admin />
if (basePath.startsWith('/edit')) return <Edit />
if (basePath.startsWith('/detail')) return <Detail />
return <Home />
}
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 <Login />
}
//
if (path === '/login') {
// 使 href
window.location.href = window.location.origin + window.location.pathname + '#/'
return null
}
return (
<div style={{ minHeight: '100vh', background: '#0f172a' }}>
{getComponent()}
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: '60px',
background: 'rgba(15, 23, 42, 0.98)',
display: 'flex',
borderTop: '1px solid rgba(255,255,255,0.1)',
zIndex: 1000
}}>
{[
{ path: '/', icon: '🏠', label: '首页' },
{ path: '/stats', icon: '📊', label: '统计' },
{ path: '/list', icon: '📚', label: '藏品' },
{ path: '/add', icon: '🎯', label: '添加' },
...(isAdmin ? [{ path: '/admin', icon: '⚙️', label: '管理' }] : [])
].map(tab => (
<div
key={tab.path}
onClick={() => handleNavigate(tab.path)}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: path === tab.path ? '#fbbf24' : '#94a3b8',
cursor: 'pointer'
}}
>
<div style={{ fontSize: '22px' }}>{tab.icon}</div>
<div style={{ fontSize: '11px', marginTop: '2px' }}>{tab.label}</div>
</div>
))}
</div>
</div>
)
}

View File

@ -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
}

1
frontend/src/index.css Normal file
View File

@ -0,0 +1 @@
/* 全局样式 */

23
frontend/src/main.jsx Normal file
View File

@ -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(
<React.StrictMode>
<HashRouter>
<App />
</HashRouter>
</React.StrictMode>
)
console.log('App rendered successfully')
} catch (e) {
console.error('Render error:', e)
root.innerHTML = '<div style="color:red;padding:20px;background:#fff;">Error: ' + e.message + '</div>'
}
// v2.7.2 build
// Force rebuild v2.7.2 - 1773392274

673
frontend/src/pages/Add.jsx Normal file
View File

@ -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 (
<div style={{ flex: 1, minWidth: '45%' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{label}</div>
{options ? (
<select value={form[field] || ''} onChange={onChange}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '6px', width: '100%', fontSize: '13px' }}>
<option value="">请选择</option>
{options.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
) : (
<input type={type} value={form[field] ?? ''} onChange={onChange}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '6px', width: '100%', fontSize: '13px' }} />
)}
</div>
)
}
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 (
<div style={{ background: '#0f172a', minHeight: '100vh', paddingBottom: '80px' }}>
{/* 顶部标签切换 */}
<div style={{ padding: '12px 16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => setActiveTab('ai')}
style={{ flex: 1, padding: '10px', background: activeTab === 'ai' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'ai' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
🤖 AI 识别
</button>
<button onClick={() => setActiveTab('manual')}
style={{ flex: 1, padding: '10px', background: activeTab === 'manual' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'manual' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
手工录入
</button>
<button onClick={() => setActiveTab('batch')} disabled
style={{ flex: 1, padding: '10px', background: 'rgba(255,255,255,0.02)', color: '#64748b', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'not-allowed' }}>
📦 批量录入
</button>
</div>
</div>
{error && (
<div style={{ margin: '16px', background: 'rgba(239, 68, 68, 0.2)', border: '1px solid #ef4444', color: '#ef4444', padding: '12px', borderRadius: '8px' }}> {error}</div>
)}
{/* AI 识别模式 */}
{activeTab === 'ai' && (
<div style={{ padding: '16px' }}>
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '24px', textAlign: 'center', marginBottom: '12px' }}>
<input ref={fileInputRef} type="file" accept="image/*" capture="environment" onChange={handleSelectImage} style={{ display: 'none' }} />
{imagePreview ? (
<div>
<img src={imagePreview} alt="已选择图片" style={{ maxWidth: '100%', borderRadius: '8px', marginBottom: '16px' }} />
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
<button onClick={() => { setSelectedImage(null); setImagePreview(null); }}
style={{ padding: '12px 24px', background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', border: '1px solid #ef4444', borderRadius: '8px', fontSize: '14px', cursor: 'pointer' }}>🗑 重新选择</button>
<button onClick={handleRecognize} disabled={recognizing}
style={{ padding: '12px 24px', background: recognizing ? '#64748b' : '#fbbf24', color: recognizing ? '#94a3b8' : '#1e293b', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: recognizing ? 'not-allowed' : 'pointer' }}>
{recognizing ? '🔍 识别中...' : '🤖 开始识别'}
</button>
</div>
</div>
) : (
<div>
<div onClick={() => { 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' }}>
<div style={{ fontSize: '40px', marginBottom: '12px' }}>📷</div>
<div style={{ color: '#60a5fa', fontSize: '15px', fontWeight: 'bold', marginBottom: '4px' }}>拍照识别</div>
<div style={{ color: '#94a3b8', fontSize: '13px' }}>使用相机拍照并识别</div>
</div>
<div onClick={() => { 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' }}>
<div style={{ fontSize: '40px', marginBottom: '12px' }}>🖼</div>
<div style={{ color: '#22c55e', fontSize: '15px', fontWeight: 'bold', marginBottom: '4px' }}>从相册选择</div>
<div style={{ color: '#94a3b8', fontSize: '13px' }}>从相册选择已有图片</div>
</div>
</div>
)}
</div>
<div style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', borderRadius: '8px', padding: '16px', marginTop: '12px' }}>
<div style={{ color: '#60a5fa', fontSize: '14px', fontWeight: 'bold', marginBottom: '8px' }}>💡 识别说明</div>
<ul style={{ color: '#94a3b8', fontSize: '13px', paddingLeft: '20px', margin: 0 }}>
<li>支持拍照或从相册选择图片</li>
<li>自动识别名称版别冠字序号等字段</li>
<li>识别结果可手动修改完善</li>
<li>建议拍摄清晰光线充足的正面照片</li>
</ul>
</div>
</div>
)}
{/* 手工录入模式 - 完整表单 */}
{activeTab === 'manual' && (
<div style={{ padding: '16px' }}>
{/* 图片上传区域 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold' }}>藏品图片 ({uploadImages.length}/1)</div>
</div>
<input ref={imageFileInputRef} type="file" accept="image/*" onChange={handleUploadImage} style={{ display: 'none' }} />
{uploadImages.length > 0 ? (
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.min(uploadImages.length, 1)}, 1fr)`, gap: '12px' }}>
{uploadImages.map((img, index) => (
<div key={index} style={{ position: 'relative' }}>
<img src={img.preview} alt={img.name} style={{ width: '100%', aspectRatio: '1.5', objectFit: 'contain', borderRadius: '12px', border: '2px solid #10b981', background: 'rgba(0,0,0,0.3)' }} />
<div style={{
position: 'absolute',
bottom: '4px',
left: '4px',
right: '4px',
background: 'rgba(0,0,0,0.7)',
color: '#fff',
padding: '4px',
borderRadius: '4px',
fontSize: '10px',
textAlign: 'center',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>{img.name}</div>
<button
onClick={() => {
const newImages = uploadImages.filter((_, i) => i !== index)
setUploadImages(newImages)
}}
style={{
position: 'absolute',
top: '8px',
right: '8px',
width: '28px',
height: '28px',
borderRadius: '50%',
background: 'rgba(239, 68, 68, 0.9)',
color: '#fff',
border: 'none',
fontSize: '16px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>×</button>
<div style={{
position: 'absolute',
top: '8px',
left: '8px',
background: 'rgba(0,0,0,0.6)',
color: '#fff',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px'
}}>{index + 1}/{uploadImages.length}</div>
</div>
))}
{uploadImages.length < 1 && (
<div onClick={() => 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'
}}>
<div style={{ fontSize: '32px', marginBottom: '8px' }}>📷</div>
<div>添加图片</div>
<div style={{ fontSize: '11px', marginTop: '4px' }}>最多可上传 1 </div>
</div>
)}
</div>
) : (
<div onClick={() => 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'
}}>
<div style={{ fontSize: '48px', marginBottom: '8px' }}>📷</div>
<div>点击上传图片</div>
<div style={{ fontSize: '11px', marginTop: '4px', color: '#94a3b8' }}>最多可上传 1 </div>
</div>
)}
</div>
{/* 基本信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="名称 *" field="name" />
<Input form={form} handleChange={handleChange} label="编号(可选)" field="code" />
<Input form={form} handleChange={handleChange} label="持仓类型" field="category" options={categoryOptions} />
<Input form={form} handleChange={handleChange} label="珍惜度" field="rarity" options={rarityOptions} />
<Input form={form} handleChange={handleChange} label="冠字序号" field="prefixSerial" />
<Input form={form} handleChange={handleChange} label="版别 *" field="version" />
<Input form={form} handleChange={handleChange} label="面值" field="denomination" />
<Input form={form} handleChange={handleChange} label="状态" field="status" options={statusOptions} />
<Input form={form} handleChange={handleChange} label="包装" field="packaging" options={packagingOptions} />
</div>
</div>
{/* 评级信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>评级信息</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center', marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input type="checkbox" id="isGraded" checked={form.isGraded || false} onChange={(e) => handleChange('isGraded', e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: '#22c55e' }} />
<label htmlFor="isGraded" style={{ color: '#fff', cursor: 'pointer', fontSize: '13px' }}>是否评级</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input type="checkbox" id="threeStar" checked={form.threeStar || false} onChange={(e) => handleChange('threeStar', e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: '#22c55e' }} />
<label htmlFor="threeStar" style={{ color: '#fff', cursor: 'pointer', fontSize: '13px' }}>三星</label>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="评级公司" field="gradingCompany" />
<Input form={form} handleChange={handleChange} label="评级分数" field="gradingScore" />
</div>
</div>
{/* 特殊信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>特殊信息</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="特殊标识" field="specialMark" />
<Input form={form} handleChange={handleChange} label="号码特征" field="serialFeature" />
<Input form={form} handleChange={handleChange} label="号码分类" field="numberCategory" options={numberCategoryOptions} />
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
<Input form={form} handleChange={handleChange} label="材质" field="material" />
<Input form={form} handleChange={handleChange} label="发行量" field="issueQuantity" />
</div>
</div>
{/* 价格信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>价格信息</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="成本价" field="costPrice" type="number" />
<Input form={form} handleChange={handleChange} label="目标价" field="targetPrice" type="number" />
<Input form={form} handleChange={handleChange} label="出售价" field="goalPrice" type="number" />
<Input form={form} handleChange={handleChange} label="修复费" field="repairFee" type="number" />
<Input form={form} handleChange={handleChange} label="评级费" field="gradingFee" type="number" />
<Input form={form} handleChange={handleChange} label="用途" field="purpose" />
</div>
</div>
{/* 备注 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>备注</div>
<textarea value={form.remark || ''} onChange={(e) => handleChange('remark', e.target.value)} rows={3}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '10px', borderRadius: '8px', width: '100%', resize: 'none' }} />
</div>
{/* 保存按钮 */}
<button onClick={saving ? null : handleSave} disabled={saving}
style={{ width: '100%', padding: '14px', background: saving ? '#64748b' : '#fbbf24', color: saving ? '#94a3b8' : '#1e293b', border: 'none', borderRadius: '10px', fontSize: '16px', fontWeight: '600', cursor: saving ? 'not-allowed' : 'pointer', marginBottom: '12px' }}>
{saving ? '保存中...' : '✅ 保存藏品'}
</button>
<button onClick={() => setActiveTab('ai')}
style={{ width: '100%', padding: '14px', background: 'rgba(255,255,255,0.05)', color: '#94a3b8', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '10px', fontSize: '14px', cursor: 'pointer' }}>
🔄 切换到 AI 识别
</button>
</div>
)}
{/* 批量录入模式 */}
{activeTab === 'batch' && (
<div style={{ padding: '48px 16px', textAlign: 'center', color: '#94a3b8' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🚧</div>
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '8px' }}>批量录入开发中</div>
<div style={{ fontSize: '13px' }}>敬请期待后续版本</div>
</div>
)}
{/* 版本号 */}
<div style={{ position: 'fixed', bottom: '16px', right: '16px', color: 'rgba(255,255,255,0.2)', fontSize: '11px', zIndex: 100 }}>v{APP_VERSION}</div>
</div>
)
}

View File

@ -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 (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f172a', color: '#ef4444', padding: '20px' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🚫</div>
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>无权访问</div>
<div style={{ color: '#94a3b8', fontSize: '14px' }}>仅管理员可以访问用户管理界面</div>
</div>
</div>
)
}
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 <div style={{ padding: '20px', color: '#fff' }}>加载中...</div>
if (error) return <div style={{ padding: '20px', color: '#ef4444' }}> {error}</div>
return (
<div style={{ minHeight: '100vh', overflowY: 'auto', background: '#0f172a', padding: '20px', paddingBottom: '80px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h1 style={{ color: '#fbbf24', fontSize: '24px', fontWeight: '700' }}>
用户管理
</h1>
<button
onClick={() => setShowAddModal(true)}
style={{
padding: '10px 20px',
background: '#fbbf24',
color: '#1e293b',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer'
}}
>
添加用户
</button>
</div>
{/* 用户列表 - 卡片式布局 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{users.map((user) => (
<div key={user.id} style={{ background: 'rgba(30, 41, 59, 0.8)', borderRadius: '12px', padding: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ color: '#fbbf24', fontSize: '16px', fontWeight: 'bold' }}>{user.username}</div>
<div style={{
padding: '2px 8px', borderRadius: '4px',
background: user.role === 'admin' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(148, 163, 184, 0.2)',
color: user.role === 'admin' ? '#10b981' : '#94a3b8',
fontSize: '12px'
}}>
{user.role === 'admin' ? '👑 管理员' : '👤 用户'}
</div>
</div>
<div style={{ color: '#64748b', fontSize: '13px', marginTop: '4px' }}>
📧 {user.email || '未设置'} | 📱 {user.phone || '未设置'}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ color: '#94a3b8', fontSize: '12px' }}>藏品数</div>
<div style={{ color: '#60a5fa', fontSize: '20px', fontWeight: 'bold', cursor: 'pointer' }} onClick={() => window.location.hash = '#/list?userId=' + user.id} title='点击查看'>{user.collection_count || 0}</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<div style={{ color: '#64748b', fontSize: '12px' }}>
注册时间{user.created_at ? new Date(user.created_at).toLocaleDateString('zh-CN') : '-'}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setEditingUser({ ...user })}
style={{
padding: '6px 12px',
borderRadius: '6px',
border: 'none',
background: 'rgba(59, 130, 246, 0.2)',
color: '#3b82f6',
cursor: 'pointer',
fontSize: '12px'
}}
>
编辑
</button>
{user.role !== 'admin' && (
<button
onClick={() => handleDeleteUser(user.id, user.username)}
style={{
padding: '6px 12px',
borderRadius: '6px',
border: 'none',
background: 'rgba(239, 68, 68, 0.2)',
color: '#ef4444',
cursor: 'pointer',
fontSize: '12px'
}}
>
🗑 删除
</button>
)}
</div>
</div>
</div>
))}
</div>
{users.length === 0 && (
<div style={{ padding: '40px', textAlign: 'center', color: '#94a3b8' }}>
暂无用户数据
</div>
)}
{/* 添加用户模态框 */}
{showAddModal && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px', width: '90%', maxWidth: '400px' }}>
<h2 style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold', marginBottom: '20px' }}>添加用户</h2>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>用户名 *</label>
<input
type="text"
value={newUser.username}
onChange={(e) => 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' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>密码 *</label>
<input
type="password"
value={newUser.password}
onChange={(e) => 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' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>邮箱</label>
<input
type="email"
value={newUser.email}
onChange={(e) => 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' }}
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>角色</label>
<select
value={newUser.role}
onChange={(e) => setNewUser({ ...newUser, role: 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' }}
>
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={() => setShowAddModal(false)}
style={{ flex: 1, padding: '12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer' }}
>
取消
</button>
<button
onClick={handleAddUser}
style={{ flex: 1, padding: '12px', background: '#fbbf24', color: '#1e293b', border: 'none', borderRadius: '8px', fontWeight: '600', cursor: 'pointer' }}
>
确定
</button>
</div>
</div>
</div>
)}
{/* 编辑用户模态框 */}
{editingUser && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px', width: '90%', maxWidth: '500px', maxHeight: '90vh', overflowY: 'auto' }}>
<h2 style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold', marginBottom: '20px' }}>编辑用户</h2>
{/* 第一部分:基本信息 */}
<div style={{ marginBottom: '24px' }}>
<h3 style={{ color: '#fbbf24', fontSize: '15px', fontWeight: 'bold', marginBottom: '16px', paddingBottom: '8px', borderBottom: '1px solid rgba(251, 191, 36, 0.3)' }}>📋 基本信息</h3>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>用户名</label>
<input
type="text"
value={editingUser.username}
onChange={(e) => 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' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>邮箱</label>
<input
type="email"
value={editingUser.email || ''}
onChange={(e) => 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' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>角色</label>
<select
value={editingUser.role}
onChange={(e) => setEditingUser({ ...editingUser, role: 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' }}
>
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</div>
</div>
{/* 第二部分:修改密码 */}
<div style={{ marginBottom: '24px' }}>
<h3 style={{ color: '#fbbf24', fontSize: '15px', fontWeight: 'bold', marginBottom: '16px', paddingBottom: '8px', borderBottom: '1px solid rgba(251, 191, 36, 0.3)' }}>🔐 修改密码可选</h3>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>新密码 <span style={{ color: '#64748b', fontSize: '12px' }}>留空则不修改</span></label>
<input
type="password"
value={editingUser.newPassword || ''}
onChange={(e) => 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' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>确认新密码</label>
<input
type="password"
value={editingUser.confirmPassword || ''}
onChange={(e) => 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' }}
/>
</div>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={() => setEditingUser(null)}
style={{ flex: 1, padding: '12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer' }}
>
取消
</button>
<button
onClick={handleEditUser}
style={{ flex: 1, padding: '12px', background: '#fbbf24', color: '#1e293b', border: 'none', borderRadius: '8px', fontWeight: '600', cursor: 'pointer' }}
>
确定
</button>
</div>
</div>
</div>
)}
{/* 版本号 - 移到表格下方,避免被底部导航遮挡 */}
<div style={{ textAlign: 'center', padding: '20px', color: 'rgba(255,255,255,0.3)', fontSize: '12px' }}>
v{APP_VERSION}
</div>
</div>
)
}

View File

@ -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 (
<div style={{ padding: '20px' }}>
<div
onClick={() => 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'
}}
>
<div style={{ fontSize: '60px', opacity: 0.5 }}>📦</div>
<div style={{ color: '#fff', fontSize: '16px', marginTop: '16px' }}>点击选择多张图片</div>
<div style={{ color: '#94a3b8', fontSize: '13px', marginTop: '8px' }}>最多10张</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleChooseImages}
style={{ display: 'none' }}
/>
</div>
)
}
const isSaved = savedCount > currentIndex
return (
<div style={{ padding: '16px', paddingBottom: '120px' }}>
{/* Sticky Header */}
<div style={{ position: 'sticky', top: 0, background: '#0f172a', padding: '12px 0', marginBottom: '12px', zIndex: 10, borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
<div style={{ color: '#fff', fontSize: '14px', marginBottom: '8px' }}>
已保存: {savedCount} / {images.length} | 当前第 {currentIndex + 1}
</div>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{images.map((_, i) => (
<div key={i} style={{
width: '24px', height: '24px', borderRadius: '4px',
background: i < savedCount ? '#22c55e' : (i === currentIndex ? '#fbbf24' : '#333'),
color: '#fff', fontSize: '10px', display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
{i + 1}
</div>
))}
</div>
{currentIndex === images.length - 1 && savedCount > 0 && (
<button onClick={handleReUpload} style={{ marginTop: '12px', padding: '8px 16px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '6px', fontSize: '13px', cursor: 'pointer' }}>
重新上传
</button>
)}
</div>
{/* Image */}
<div style={{ marginBottom: '16px' }}>
<img
src={URL.createObjectURL(images[currentIndex])}
alt="preview"
style={{ width: '100%', maxHeight: '200px', objectFit: 'contain', borderRadius: '8px' }}
/>
</div>
{/* Buttons - moved here */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<button
onClick={handlePrev}
disabled={currentIndex === 0}
style={{ flex: 1, padding: '12px', background: currentIndex === 0 ? '#333' : 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '8px', cursor: currentIndex === 0 ? 'not-allowed' : 'pointer' }}
>
上一张
</button>
{currentIndex < images.length - 1 && (
<button
onClick={handleNext}
style={{ flex: 1, padding: '12px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer' }}
>
下一张
</button>
)}
<button
onClick={handleReOCR}
disabled={loading}
style={{ flex: 1, padding: '12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '8px', cursor: loading ? 'not-allowed' : 'pointer' }}
>
重新识别
</button>
<button
onClick={handleSave}
disabled={!result || loading}
style={{ flex: 1, padding: '12px', background: result && !loading ? '#22c55e' : '#333', color: '#fff', border: 'none', borderRadius: '8px', fontWeight: 'bold', cursor: result && !loading ? 'pointer' : 'not-allowed' }}
>
保存结果
</button>
</div>
{loading && (
<div style={{ textAlign: 'center', padding: '20px', color: '#fbbf24' }}>🤖 AI识别中...</div>
)}
{result && !loading && (
<>
{/* 基本信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>名称</div>
<input type="text" value={result.name || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>编号</div>
<input type="text" value={result.code || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>持仓类型</div>
<select value={result.ownership_type || '自持'} onChange={(e) => updateResult('ownership_type', 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' }}>
{categoryOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>珍惜度</div>
<select value={result.rarity || '通货'} onChange={(e) => updateResult('rarity', 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' }}>
{rarityOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>冠字序号</div>
<input type="text" value={result.prefixSerial || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>版别</div>
<input type="text" value={result.version || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>状态</div>
<select value={result.status || 'in_collection'} onChange={(e) => updateResult('status', 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' }}>
{statusOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>封装</div>
<select value={result.packaging || '单张'} onChange={(e) => updateResult('packaging', 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' }}>
{packagingOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
</div>
</div>
{/* 评级信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '10px', padding: '8px', background: result.isGraded ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255,255,255,0.03)', borderRadius: '6px', cursor: 'pointer' }}
onClick={() => updateResult('isGraded', !result.isGraded)}>
<div style={{ width: '22px', height: '22px', borderRadius: '4px', border: '2px solid', borderColor: result.isGraded ? '#22c55e' : '#64748b', backgroundColor: result.isGraded ? '#22c55e' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{result.isGraded && <span style={{color:'#fff',fontSize:'14px'}}></span>}
</div>
<span style={{color:'#fff',fontSize:'13px'}}>已评级</span>
</div>
{result.isGraded && (
<>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>评级公司</div>
<input type="text" value={result.gradingCompany || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>评级分数</div>
<input type="text" value={result.gradingScore || ''} onChange={(e) => 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' }} />
</div>
</>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px', background: result.threeStar ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255,255,255,0.03)', borderRadius: '6px', cursor: 'pointer' }}
onClick={() => updateResult('threeStar', !result.threeStar)}>
<div style={{ width: '22px', height: '22px', borderRadius: '4px', border: '2px solid', borderColor: result.threeStar ? '#22c55e' : '#64748b', backgroundColor: result.threeStar ? '#22c55e' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{result.threeStar && <span style={{color:'#fff',fontSize:'14px'}}></span>}
</div>
<span style={{color:'#fff',fontSize:'13px'}}>三星</span>
</div>
</div>
{/* 特殊信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>特殊信息</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>发行机构</div>
<input type="text" value={result.issuer || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>年份</div>
<input type="text" value={result.issueYear || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>特殊标识</div>
<input type="text" value={result.specialMark || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>号码特征</div>
<input type="text" value={result.serialFeature || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>面额</div>
<input type="text" value={result.denomination || ''} onChange={(e) => 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' }} />
</div>
</div>
{/* 价格信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '16px' }}>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>成本价</div>
<input type="number" value={result.costPrice || ''} onChange={(e) => 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' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>出售价</div>
<input type="number" value={result.targetPrice || ''} onChange={(e) => 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' }} />
</div>
</div>
{/* 备注 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '16px' }}>
<div style={{ marginBottom: '8px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>备注</div>
<textarea value={result.remark || ''} onChange={(e) => updateResult('remark', e.target.value)} rows={2}
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', resize: 'none' }} />
</div>
</div>
</>
)}
{/* Footer */}
{savedCount > 0 && currentIndex === images.length - 1 && (
<button onClick={handleReUpload} style={{ width: '100%', padding: '14px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '15px', marginTop: '16px', cursor: 'pointer' }}>
重新上传
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleChooseImages}
style={{ display: 'none' }}
/>
</div>
)
}

View File

@ -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 }) => (
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<span style={{ color: '#94a3b8', fontSize: '13px' }}>{label}</span>
<span style={{ color: green ? '#22c55e' : '#fff', fontSize: '13px', textAlign: 'right', maxWidth: '60%' }}>{value || '-'}</span>
</div>
)
const Section = ({ title, children }) => (
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '10px', padding: '12px', marginBottom: '10px' }}>
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold', marginBottom: '8px' }}>{title}</div>
{children}
</div>
)
if (loading) {
return <div style={{ padding: '40px', textAlign: 'center', color: '#94a3b8' }}>加载中...</div>
}
if (error) {
return (
<div style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ fontSize: '60px' }}></div>
<div style={{ color: '#ef4444', marginTop: '16px', fontSize: '16px', fontWeight: 'bold' }}>{error}</div>
<button onClick={goBack} style={{ marginTop: '20px', padding: '12px 24px', background: '#fbbf24', color: '#1e293b', border: 'none', borderRadius: '8px', cursor: 'pointer' }}>返回列表</button>
</div>
)
}
if (!collection) {
return (
<div style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ fontSize: '60px' }}></div>
<div style={{ color: '#94a3b8', marginTop: '16px' }}>藏品不存在</div>
<button onClick={goBack} style={{ marginTop: '20px', padding: '12px 24px', background: '#fbbf24', color: '#1e293b', border: 'none', borderRadius: '8px', cursor: 'pointer' }}>返回列表</button>
</div>
)
}
return (
<div style={{ background: '#0f172a', minHeight: '100vh', paddingBottom: '80px' }}>
{/* 顶部 */}
<div style={{ padding: '12px 16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', position: 'sticky', top: 0, zIndex: 100 }}>
<span style={{ fontSize: '16px', fontWeight: 'bold', color: '#fff' }}>藏品详情</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '8px' }}>
<button onClick={goBack} style={{ padding: '6px 12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '6px', fontSize: '13px' }}>返回</button>
<button onClick={() => setShowDelete(true)} style={{ padding: '6px 12px', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '6px', fontSize: '13px' }}>删除</button>
<button onClick={handleEdit} style={{ padding: '6px 12px', background: '#fbbf24', color: '#1e293b', border: 'none', borderRadius: '6px', fontSize: '13px', fontWeight: '600' }}>编辑</button>
</div>
</div>
{/* 主信息 */}
<div style={{ padding: '16px' }}>
{/* 图片显示 */}
{collection.images && collection.images.length > 0 && (
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.min(collection.images.length, 1)}, 1fr)`, gap: '12px' }}>
{collection.images.map((img, index) => (
<div key={img.id || index} style={{ position: 'relative' }}>
<img
src={img.path?.startsWith('http') ? img.path : `/${img.path || `uploads/collections/${img.filename}`}`}
alt={img.originalName || '藏品图片'}
onClick={() => { 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 = '<div style="width:100%;aspectRatio:1.5;background:rgba(255,255,255,0.05);border-radius:12px;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.3);font-size:14px;">无图片</div>';
}}
/>
{collection.images.length > 1 && (
<div style={{
position: 'absolute',
top: '8px',
left: '8px',
background: 'rgba(0,0,0,0.6)',
color: '#fff',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px'
}}>
{index + 1}/{collection.images.length}
</div>
)}
</div>
))}
</div>
</div>
)}
<div style={{ fontSize: '18px', fontWeight: 'bold', textAlign: 'center', color: '#fff', marginBottom: '8px' }}>{collection.name}</div>
<div style={{ textAlign: 'center', color: '#fbbf24', fontSize: '14px', fontFamily: 'monospace', fontWeight: 'bold' }}>{collection.prefixSerial || '-'}</div>
</div>
{/* 信息区块 */}
<div style={{ padding: '0 16px' }}>
<Section title="基本信息">
<Field label="编号" value={collection.code} />
<Field label="持仓类型" value={getCategoryText(collection.category)} />
<Field label="珍惜度" value={collection.rarity || '-'} green={collection.rarity && collection.rarity !== '通货'} />
<Field label="版别" value={collection.version} />
<Field label="面值" value={collection.denomination} />
<Field label="状态" value={getStatusText(collection.status)} green={collection.status === 'in_collection'} />
<Field label="包装" value={collection.packaging} />
<Field label="创建时间" value={collection.createdAt ? new Date(collection.createdAt).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-') : '-'} />
</Section>
<Section title="评级信息">
<Field label="是否评级" value={collection.isGraded ? '已评级' : '未评级'} />
<Field label="评级公司" value={collection.gradingCompany} />
<Field label="评级分数" value={collection.gradingScore} green={collection.gradingScore} />
<Field label="三星" value={collection.threeStar ? '是' : '否'} green={collection.threeStar} />
</Section>
<Section title="特殊信息">
<Field label="特殊标识" value={collection.specialMark} />
<Field label="号码特征" value={collection.serialFeature} />
<Field label="号码分类" value={collection.numberCategory} />
<Field label="发行方" value={collection.issuer} />
<Field label="发行年份" value={collection.issueYear} />
<Field label="材质" value={collection.material} />
<Field label="发行量" value={collection.issueQuantity} />
</Section>
<Section title="价格信息">
<Field label="成本价" value={collection.costPrice ? '¥' + collection.costPrice : '-'} />
<Field label="目标价" value={collection.targetPrice ? '¥' + collection.targetPrice : '-'} />
<Field label="出售价" value={collection.goalPrice ? '¥' + collection.goalPrice : '-'} green={collection.goalPrice} />
<Field label="修复费" value={collection.repairFee ? '¥' + collection.repairFee : '-'} />
<Field label="评级费" value={collection.gradingFee ? '¥' + collection.gradingFee : '-'} />
<Field label="用途" value={collection.purpose} />
</Section>
{collection.remark && (
<Section title="备注">
<div style={{ color: '#fff', fontSize: '13px', lineHeight: '1.5' }}>{collection.remark}</div>
</Section>
)}
</div>
{/* 删除确认弹窗 */}
{showDelete && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px', width: '80%', maxWidth: '300px' }}>
<div style={{ color: '#fff', fontSize: '16px', fontWeight: 'bold', marginBottom: '16px', textAlign: 'center' }}>确定删除此藏品</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button onClick={() => setShowDelete(false)} style={{ flex: 1, padding: '12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '14px' }}>取消</button>
<button onClick={doDelete} style={{ flex: 1, padding: '12px', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '14px' }}>删除</button>
</div>
</div>
</div>
)}
{/* 图片查看器 */}
{showImage && collection.images && collection.images.length > 0 && (
<div
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.95)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}
onClick={() => setShowImage(false)}
>
{/* 左侧切换按钮 */}
{collection.images.length > 1 && (
<button
onClick={(e) => { e.stopPropagation(); setCurrentImageIndex((currentImageIndex - 1 + collection.images.length) % collection.images.length); }}
style={{
position: 'absolute',
left: '20px',
background: 'rgba(255,255,255,0.2)',
color: '#fff',
border: 'none',
borderRadius: '50%',
width: '50px',
height: '50px',
fontSize: '24px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
</button>
)}
{/* 图片 */}
<img
src={collection.images[currentImageIndex].path?.startsWith('http') ? collection.images[currentImageIndex].path : `/${collection.images[currentImageIndex].path || `uploads/collections/${collection.images[currentImageIndex].filename}`}`}
alt={collection.images[currentImageIndex].originalName || '藏品图片'}
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
{/* 右侧切换按钮 */}
{collection.images.length > 1 && (
<button
onClick={(e) => { e.stopPropagation(); setCurrentImageIndex((currentImageIndex + 1) % collection.images.length); }}
style={{
position: 'absolute',
right: '20px',
background: 'rgba(255,255,255,0.2)',
color: '#fff',
border: 'none',
borderRadius: '50%',
width: '50px',
height: '50px',
fontSize: '24px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
</button>
)}
{/* 图片指示器 */}
{collection.images.length > 1 && (
<div
style={{
position: 'absolute',
bottom: '40px',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: '10px'
}}
onClick={(e) => e.stopPropagation()}
>
{collection.images.map((_, index) => (
<div
key={index}
onClick={() => 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'
}}
/>
))}
</div>
)}
{/* 关闭提示 */}
<div
style={{
position: 'absolute',
top: '20px',
right: '20px',
background: 'rgba(255,255,255,0.2)',
color: '#fff',
border: 'none',
borderRadius: '50%',
width: '40px',
height: '40px',
fontSize: '24px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={() => setShowImage(false)}
>
×
</div>
</div>
)}
</div>
)
}
// v2.7.1

491
frontend/src/pages/Edit.jsx Normal file
View File

@ -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 (
<div style={{ flex: 1, minWidth: '45%', marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{label}</div>
{options ? (
<select value={form[field] || ''} onChange={onChange}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '6px', width: '100%', fontSize: '13px' }}>
<option value="">请选择</option>
{options.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
) : (
<input type={type} value={form[field] ?? ''} onChange={onChange}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '6px', width: '100%', fontSize: '13px' }} />
)}
</div>
)
}
//
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 (
<div style={{ background: '#0f172a', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '16px' }}>加载中...</div>
</div>
)
}
return (
<div style={{ background: '#0f172a', minHeight: '100vh', paddingBottom: '80px' }}>
{/* 顶部标题 */}
<div style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>编辑藏品</div>
<div style={{ color: '#94a3b8', fontSize: '13px', marginTop: '4px' }}>修改藏品信息</div>
</div>
<div onClick={() => window.history.back()} style={{ color: '#60a5fa', fontSize: '24px', cursor: 'pointer', padding: '8px' }}></div>
</div>
{error && (
<div style={{ margin: '16px', padding: '12px', background: 'rgba(239, 68, 68, 0.2)', border: '1px solid #ef4444', color: '#ef4444', borderRadius: '8px' }}>
{error}
</div>
)}
<div style={{ padding: '16px' }}>
{/* 藏品图片 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold' }}>藏品图片 ({uploadImages.length}/1)</div>
{uploadImages.length === 0 && (
<button
onClick={() => fileInputRef.current?.click()}
style={{
padding: '6px 12px',
background: 'rgba(34, 197, 94, 0.2)',
color: '#22c55e',
border: '1px solid #22c55e',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer'
}}
>
📷 上传图片
</button>
)}
</div>
{/* 隐藏的文件输入框 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
style={{ display: 'none' }}
/>
{uploadImages.length > 0 ? (
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.min(uploadImages.length, 1)}, 1fr)`, gap: '12px' }}>
{uploadImages.map((img, index) => (
<div key={img.id || index} style={{ position: 'relative' }}>
<img
src={img.preview}
alt={img.name || '藏品图片'}
style={{
width: '100%',
aspectRatio: '1.5',
objectFit: 'contain',
borderRadius: '12px',
border: '2px solid #10b981',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
background: 'rgba(0,0,0,0.3)'
}}
/>
<div style={{
position: 'absolute',
top: '8px',
left: '8px',
background: 'rgba(0,0,0,0.6)',
color: '#fff',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px'
}}>{index + 1}/{uploadImages.length}</div>
</div>
))}
</div>
) : (
<div style={{ textAlign: 'center', color: '#94a3b8', padding: '24px' }}>
📷 暂无图片
</div>
)}
</div>
{/* 基本信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="名称 *" field="name" />
<Input form={form} handleChange={handleChange} label="编号" field="code" />
<Input form={form} handleChange={handleChange} label="持仓类型" field="category" options={categoryOptions} />
<Input form={form} handleChange={handleChange} label="珍惜度" field="rarity" options={rarityOptions} />
<Input form={form} handleChange={handleChange} label="冠字序号" field="prefixSerial" />
<Input form={form} handleChange={handleChange} label="版别 *" field="version" />
<Input form={form} handleChange={handleChange} label="面值" field="denomination" />
<Input form={form} handleChange={handleChange} label="状态" field="status" options={statusOptions} />
<Input form={form} handleChange={handleChange} label="包装" field="packaging" options={packagingOptions} />
</div>
</div>
{/* 评级信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>评级信息</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center', marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input type="checkbox" id="isGraded" checked={form.isGraded || false} onChange={(e) => handleChange('isGraded', e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: '#22c55e' }} />
<label htmlFor="isGraded" style={{ color: '#fff', cursor: 'pointer', fontSize: '13px' }}>是否评级</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input type="checkbox" id="threeStar" checked={form.threeStar || false} onChange={(e) => handleChange('threeStar', e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: '#22c55e' }} />
<label htmlFor="threeStar" style={{ color: '#fff', cursor: 'pointer', fontSize: '13px' }}>三星</label>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="评级公司" field="gradingCompany" />
<Input form={form} handleChange={handleChange} label="评级分数" field="gradingScore" />
</div>
</div>
{/* 特殊信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>特殊信息</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="特殊标识" field="specialMark" />
<Input form={form} handleChange={handleChange} label="号码特征" field="serialFeature" />
<Input form={form} handleChange={handleChange} label="号码分类" field="numberCategory" options={numberCategoryOptions} />
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
<Input form={form} handleChange={handleChange} label="材质" field="material" />
<Input form={form} handleChange={handleChange} label="发行量" field="issueQuantity" />
</div>
</div>
{/* 价格信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>价格信息</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="成本价" field="costPrice" type="number" />
<Input form={form} handleChange={handleChange} label="目标价" field="targetPrice" type="number" />
<Input form={form} handleChange={handleChange} label="出售价" field="goalPrice" type="number" />
<Input form={form} handleChange={handleChange} label="修复费" field="repairFee" type="number" />
<Input form={form} handleChange={handleChange} label="评级费" field="gradingFee" type="number" />
<Input form={form} handleChange={handleChange} label="用途" field="purpose" />
</div>
</div>
{/* 备注 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>备注</div>
<textarea
value={form.remark || ''}
onChange={(e) => handleChange('remark', e.target.value)}
rows={3}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '10px', borderRadius: '8px', width: '100%', resize: 'none' }}
/>
</div>
{/* 按钮 */}
<button
onClick={saving ? null : handleSave}
disabled={saving}
style={{
width: '100%',
padding: '14px',
background: saving ? '#64748b' : '#fbbf24',
color: saving ? '#94a3b8' : '#1e293b',
border: 'none',
borderRadius: '10px',
fontSize: '16px',
fontWeight: '600',
cursor: saving ? 'not-allowed' : 'pointer',
marginBottom: '12px'
}}
>
{saving ? '更新中...' : '✅ 更新藏品'}
</button>
<button
onClick={() => window.location.hash = '#/list'}
style={{
width: '100%',
padding: '14px',
background: 'rgba(255,255,255,0.05)',
color: '#94a3b8',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '10px',
fontSize: '14px',
cursor: 'pointer'
}}
>
🔄 返回列表
</button>
</div>
</div>
)
}

View File

@ -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 (
<div style={{ flex: 1, minWidth: '45%', marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{label}</div>
{options ? (
<select value={form[field] || ''} onChange={onChange}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '6px', width: '100%', fontSize: '13px' }}>
<option value="">请选择</option>
{options.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
) : (
<input type={type} value={form[field] ?? ''} onChange={onChange}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '6px', width: '100%', fontSize: '13px' }} />
)}
</div>
)
}
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 <div style={{ padding: '40px', textAlign: 'center', color: '#94a3b8' }}>加载中...</div>
}
return (
<div style={{ background: '#0f172a', minHeight: '100vh', overflowY: 'auto', paddingBottom: '120px' }}>
{/* 顶部 */}
<div style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', position: 'sticky', top: 0, zIndex: 100 }}>
<button onClick={goBack} style={{ background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer', padding: '8px', color: '#fff' }}>←</button>
<span style={{ fontSize: '18px', fontWeight: 'bold', marginLeft: '8px', color: '#fff' }}>编辑藏品</span>
</div>
<div style={{ padding: '16px' }}>
{/* 图片预览区域 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold' }}>藏品图片</div>
{collectionImages.length > 0 && (
<button
onClick={() => fileInputRef.current?.click()}
style={{
padding: '6px 12px',
background: 'rgba(34, 197, 94, 0.2)',
color: '#22c55e',
border: '1px solid #22c55e',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
>
📷 更换图片
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
style={{ display: 'none' }}
/>
{collectionImages.length > 0 ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '12px' }}>
{collectionImages.map((img, index) => (
<div key={img.id || index} style={{ position: 'relative' }}>
<img
src={`http://120.26.133.10:3000/${img.path || `uploads/collections/${img.filename}`}`}
alt={img.original_name || img.filename || '藏品图片'}
style={{
width: '100%',
aspectRatio: '1.5',
objectFit: 'contain',
borderRadius: '12px',
border: '2px solid #10b981',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
background: 'rgba(0,0,0,0.3)'
}}
/>
<div style={{
position: 'absolute',
top: '8px',
left: '8px',
background: 'rgba(0,0,0,0.6)',
color: '#fff',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px'
}}>{index + 1}/{collectionImages.length}</div>
</div>
))}
</div>
) : (
<div
onClick={() => 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'
}}
>
<div style={{ fontSize: '48px', marginBottom: '8px' }}>📷</div>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>点击上传图片</div>
<div style={{ fontSize: '11px', color: '#94a3b8' }}>
最多可上传 1 张
</div>
</div>
)}
</div>
{/* 基本信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="名称 *" field="name" />
<Input form={form} handleChange={handleChange} label="持仓类型" field="category" options={categoryOptions} />
<Input form={form} handleChange={handleChange} label="珍惜度" field="rarity" options={rarityOptions} />
<Input form={form} handleChange={handleChange} label="冠字序号" field="prefixSerial" />
<Input form={form} handleChange={handleChange} label="版别" field="version" />
<Input form={form} handleChange={handleChange} label="面值" field="denomination" />
<Input form={form} handleChange={handleChange} label="状态" field="status" options={statusOptions} />
<Input form={form} handleChange={handleChange} label="包装" field="packaging" options={packagingOptions} />
</div>
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', gap: '24px', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: '45%' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>编号</div>
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', color: '#fbbf24', padding: '8px', borderRadius: '6px', fontSize: '13px', fontFamily: 'monospace', fontWeight: 'bold' }}>
{form.code || '(保存后自动生成)'}
</div>
</div>
<div style={{ flex: 1, minWidth: '45%' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>创建时间</div>
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', color: '#94a3b8', padding: '8px', borderRadius: '6px', fontSize: '13px' }}>
{form.createdAt ? new Date(form.createdAt).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-') : '(保存后自动生成)'}
</div>
</div>
</div>
</div>
</div>
{/* 评级信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold', marginBottom: '12px' }}>评级信息</div>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
type="checkbox"
id="isGraded"
checked={form.isGraded || false}
onChange={(e) => 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' }}
/>
<label htmlFor="isGraded" style={{ color: '#fff', cursor: 'pointer', fontSize: '15px' }}>是否评级</label>
</div>
<Input form={form} handleChange={handleChange} label="评级公司" field="gradingCompany" />
<Input form={form} handleChange={handleChange} label="评级分数" field="gradingScore" />
<Input form={form} handleChange={handleChange} label="特殊标识" field="specialMark" />
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
type="checkbox"
id="threeStar"
checked={form.threeStar || false}
onChange={(e) => 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' }}
/>
<label htmlFor="threeStar" style={{ color: '#fff', cursor: 'pointer', fontSize: '15px' }}>三星</label>
</div>
</div>
{/* 特殊信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold', marginBottom: '12px' }}>特殊信息</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="号码特征" field="serialFeature" />
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
<Input form={form} handleChange={handleChange} label="材质" field="material" />
<Input form={form} handleChange={handleChange} label="发行量" field="issueQuantity" />
</div>
</div>
{/* 价格信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold', marginBottom: '12px' }}>价格信息</div>
<Input form={form} handleChange={handleChange} label="成本价" field="costPrice" type="number" />
<Input form={form} handleChange={handleChange} label="目标价" field="targetPrice" type="number" />
<Input form={form} handleChange={handleChange} label="出售价" field="goalPrice" type="number" />
<Input form={form} handleChange={handleChange} label="修复费" field="repairFee" type="number" />
<Input form={form} handleChange={handleChange} label="评级费" field="gradingFee" type="number" />
<Input form={form} handleChange={handleChange} label="用途" field="purpose" />
</div>
{/* 备注 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>备注</div>
<textarea
value={form.remark || ''}
onChange={(e) => handleChange('remark', e.target.value)}
rows={3}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '8px', width: '100%', resize: 'none' }}
/>
</div>
{/* 按钮 */}
<button onClick={saving ? null : handleSave} disabled={saving} style={{ width: '100%', padding: '14px', background: saving ? '#64748b' : '#fbbf24', color: '#1e293b', border: 'none', borderRadius: '10px', fontSize: '16px', fontWeight: '600', marginBottom: '12px', cursor: saving ? 'not-allowed' : 'pointer' }}>
{saving ? '保存中...' : '保存'}
</button>
<button onClick={handleDelete} style={{ width: '100%', padding: '14px', background: 'rgba(239,68,68,0.1)', color: '#ef4444', border: '1px solid #ef4444', borderRadius: '10px', fontSize: '16px', cursor: 'pointer' }}>
删除藏品
</button>
</div>
{/* 删除确认弹窗 */}
{showDelete && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px', width: '80%', maxWidth: '300px' }}>
<div style={{ color: '#fff', fontSize: '16px', fontWeight: 'bold', marginBottom: '16px', textAlign: 'center' }}>确定删除此藏品?</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button onClick={() => setShowDelete(false)} style={{ flex: 1, padding: '12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px' }}>取消</button>
<button onClick={doDelete} style={{ flex: 1, padding: '12px', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px' }}>删除</button>
</div>
</div>
</div>
)}
</div>
)
}

255
frontend/src/pages/Home.jsx Normal file
View File

@ -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 APIdata.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 (
<div style={{
minHeight: '100vh', overflowY: 'auto',
background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)',
padding: '20px',
fontFamily: '"Noto Sans SC", "PingFang SC", sans-serif'
}}>
{/* 顶部用户信息 */}
<div style={{
background: 'linear-gradient(135deg, rgba(59,130,246,0.2) 0%, rgba(139,92,246,0.2) 100%)',
borderRadius: '16px',
padding: '20px',
marginBottom: '20px',
border: '1px solid rgba(255,255,255,0.1)',
backdropFilter: 'blur(10px)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<img src="/static/images/jiachenlong-logo.png?v=1.2.4" alt="甲辰收藏" style={{ width: '48px', height: '48px', borderRadius: '50%', objectFit: 'cover' }} />
<div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '600' }}>{user?.username || '用户'}</div>
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: '12px' }}>欢迎回来 👋</div>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ color: 'rgba(255,255,255,0.3)', fontSize: '10px' }}>v{APP_VERSION}</div>
<div onClick={() => 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' }}>设置</div>
<div onClick={handleLogout} 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' }}>退出</div>
</div>
</div>
</div>
{/* 统计卡片网格 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '12px',
marginBottom: '20px'
}}>
{statCards.map((card, idx) => (
<div key={idx} style={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.02) 100%)',
borderRadius: '16px',
padding: '16px 12px',
border: '1px solid rgba(255,255,255,0.08)',
backdropFilter: 'blur(10px)',
textAlign: 'center',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'pointer'
}}
onMouseOver={e => { e.currentTarget.style.transform = 'translateY(-2px)' }}
onMouseOut={e => { e.currentTarget.style.transform = 'translateY(0)' }}
onClick={() => window.location.hash = '#/stats'}
>
<div style={{ fontSize: '24px', marginBottom: '4px' }}>{card.icon}</div>
<div style={{ color: card.color, fontSize: '18px', fontWeight: '700', marginBottom: '4px' }}>{card.value}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>{card.label}</div>
</div>
))}
</div>
{/* 快捷操作 */}
<div style={{ marginBottom: '20px' }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', marginBottom: '12px', paddingLeft: '4px' }}>快捷操作</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
<div onClick={() => 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'
}}>
<div style={{ fontSize: '24px' }}>📷</div>
<div>
<div style={{ color: '#fff', fontSize: '15px', fontWeight: '600' }}>AI识别</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '11px' }}>拍照识别藏品</div>
</div>
</div>
<div onClick={() => 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'
}}>
<div style={{ fontSize: '24px' }}></div>
<div>
<div style={{ color: '#fff', fontSize: '15px', fontWeight: '600' }}>手动录入</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '11px' }}>添加新藏品</div>
</div>
</div>
</div>
</div>
{/* 最近藏品 */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', paddingLeft: '4px' }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px' }}>最近藏品</div>
<div onClick={() => window.location.hash = '#/list'} style={{ color: '#3b82f6', fontSize: '12px', cursor: 'pointer' }}>查看全部 </div>
</div>
<div style={{
background: 'rgba(255,255,255,0.03)',
borderRadius: '12px',
border: '1px solid rgba(255,255,255,0.05)',
overflow: 'hidden'
}}>
{recentCollections.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>暂无藏品</div>
) : (
recentCollections.map((item, idx) => (
<div key={item.id || idx} onClick={() => 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'
}}>
<div>
<div style={{ color: '#fff', fontSize: '14px' }}>{item.code || '-'} <span style={{ color: 'rgba(255,255,255,0.4)', fontSize: '14px', fontWeight: 'bold' }}>{item.prefixSerial || ''}</span></div>
<div style={{ color: 'rgba(255,255,255,0.4)', fontSize: '12px', marginTop: '2px' }}>{item.version || '-'} · {item.status === 'sold' ? '已售' : item.status === 'in_collection' ? '收藏中' : item.status}</div>
</div>
<div style={{ color: item.costPrice ? '#22c55e' : 'rgba(255,255,255,0.3)', fontSize: '13px' }}>
{item.costPrice ? '¥' + item.costPrice : '-'}
</div>
</div>
))
)}
</div>
</div>
{/* 底部导航 */}
<div style={{
position: 'fixed',
bottom: '0',
left: '0',
right: '0',
background: 'rgba(15, 23, 42, 0.95)',
borderTop: '1px solid rgba(255,255,255,0.1)',
display: 'flex',
justifyContent: 'space-around',
padding: '12px 0',
backdropFilter: 'blur(10px)'
}}>
{(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) => (
<div key={item.idx} onClick={() => window.location.hash = item.hash} style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
cursor: 'pointer',
color: currentPath === item.hash ? '#fbbf24' : '#64748b'
}}>
<div style={{ fontSize: '18px', fontWeight: currentPath === item.hash ? 'bold' : 'normal' }}>{item.icon}</div>
<div style={{ fontSize: '10px', marginTop: '2px', fontWeight: currentPath === item.hash ? 'bold' : 'normal' }}>{item.label}</div>
</div>
))}
</div>
<div style={{ height: '70px' }}></div>
</div>
)
}

603
frontend/src/pages/List.jsx Normal file
View File

@ -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
}
//
// IDURL
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 '-'
// J101J + 9
const match = serial.match(/J(\d{9})/)
if (match) return 'J' + match[1]
// J10
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 }) => (
<div onClick={() => 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行编号 + 冠字号 + 包装(蓝色) | 状态 + 持仓类型 + 珍惜度(紫色) */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
<span style={{ color: '#fff', fontSize: '11px', fontWeight: 'normal' }}>{item.code || '-'}</span>
<span style={{ color: '#fbbf24', fontSize: '14px', fontFamily: 'monospace', }}>{formatPrefixSerial(item.prefixSerial)}</span>
{item.packaging && <span style={{ background: getPackagingColor(item.packaging) + '20', color: getPackagingColor(item.packaging), fontSize: '10px', padding: '1px 5px', borderRadius: '3px' }}>{item.packaging}</span>}
{item.numberCategory && <span style={{ color: getNumberCategoryColor(item.numberCategory), fontSize: '9px', padding: '1px 4px', background: getNumberCategoryColor(item.numberCategory) + '20', borderRadius: '2px', marginLeft: '4px' }}>{item.numberCategory}</span>}
</div>
<div style={{ display: 'flex', gap: '3px' }}>
{item.status && <span style={{ color: getStatusColor(item.status), fontSize: '9px', padding: '1px 4px', background: getStatusColor(item.status) + '25', borderRadius: '2px' }}>{getStatusText(item.status)}</span>}
{item.category && <span style={{ color: getCategoryColor(item.category), fontSize: '9px', padding: '1px 4px', background: getCategoryColor(item.category) + '25', borderRadius: '2px' }}>{getCategoryText(item.category)}</span>}
</div>
</div>
{/* 第2行版本(彩色) + 已评级 + 评级公司 + 评级分数 + 三星 + 特殊标识 | 备注 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '2px', flexWrap: 'wrap', marginBottom: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '2px', flexWrap: 'wrap' }}>
{item.version && <span style={{ background: getVersionColor(item.version).bg, color: getVersionColor(item.version).color, fontSize: '10px', padding: '1px 5px', borderRadius: '3px' }}>{item.version}</span>}
{item.isGraded && <span style={{ background: 'rgba(6, 182, 212, 0.15)', color: '#06b6d4', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>已评级</span>}
{item.gradingCompany && <span style={{ background: 'rgba(6, 182, 212, 0.15)', color: '#06b6d4', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>{item.gradingCompany.substring(0,4)}</span>}
{item.gradingScore && <span style={{ background: 'rgba(249, 115, 22, 0.2)', color: '#f97316', fontSize: '9px', padding: '1px 4px', borderRadius: '2px', }}>{item.gradingScore}</span>}
{item.threeStar && <span style={{ background: 'rgba(6, 182, 212, 0.15)', color: '#06b6d4', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>三星</span>}
{item.specialMark && <span style={{ background: 'rgba(139, 92, 246, 0.15)', color: '#a78bfa', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>{item.specialMark}</span>}
</div>
<div style={{ display: 'flex', gap: '2px', marginLeft: 'auto' }}>
{item.rarity && <span style={{ color: getRarityColor(item.rarity), fontSize: '9px', padding: '1px 4px', background: getRarityColor(item.rarity) + '20', borderRadius: '2px' }}>{item.rarity}</span>}
{item.remark && <span style={{ background: 'rgba(100,116,139,0.2)', color: '#94a3b8', fontSize: '9px', padding: '1px 4px', borderRadius: '2px', maxWidth: '80px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.remark}</span>}
</div>
</div>
{/* 第3行成本 + 修复 + 评级 | 目标 + 出售 */}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', color: '#94a3b8', paddingTop: '4px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', gap: '8px' }}>
{item.costPrice && <span>成本: <span style={{ color: '#e2e8f0' }}>¥{item.costPrice}</span></span>}
{item.repairFee && <span>修复: <span style={{ color: '#e2e8f0' }}>¥{item.repairFee}</span></span>}
{item.gradingFee && <span>评级: <span style={{ color: '#e2e8f0' }}>¥{item.gradingFee}</span></span>}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
{item.targetPrice && <span>目标: <span style={{ color: '#fbbf24' }}>¥{item.targetPrice}</span></span>}
{item.goalPrice && <span>出售: <span style={{ color: '#4ade80' }}>¥{item.goalPrice}</span></span>}
</div>
</div>
</div>
)
return (
<div style={{ minHeight: '100vh', overflowY: 'auto', background: '#0f172a', paddingBottom: '70px' }}>
<div style={{ padding: '20px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<div style={{ color: '#fff', fontSize: '20px', }}>我的藏品 <span style={{ fontSize: '14px', color: '#fbbf24' }}>({filteredCollections.length})</span></div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{filter && filterType && (
<button
onClick={() => {
setFilter('')
setFilterType('')
window.location.hash = '#/list'
}}
style={{ background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', border: 'none', borderRadius: '6px', padding: '4px 10px', fontSize: '12px', cursor: 'pointer' }}
>
清除筛选
</button>
)}
<div style={{ color: 'rgba(255,255,255,0.3)', fontSize: '11px' }}>v{APP_VERSION}</div>
</div>
</div>
{filter && filterType && (
<div style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', borderRadius: '8px', padding: '12px', marginBottom: '16px' }}>
<div style={{ color: '#60a5fa', fontSize: '12px', }}>当前筛选</div>
<div style={{ color: '#fff', fontSize: '14px', marginTop: '4px' }}>
{getFilterLabel(filterType)} = <span style={{ color: '#fbbf24', }}>{getFilterValueLabel(filterType, filter)}</span>
</div>
</div>
)}
{/* 搜索框 - 全字段搜索 */}
<div style={{ position: 'relative', marginBottom: '16px' }}>
<input type="text"
placeholder="🔍 搜索任意字段:名称、编号、冠字号、版别、评级公司、分数、价格..."
value={search}
onChange={e => 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 && (
<button
onClick={() => setSearch('')}
style={{
position: 'absolute',
right: '12px',
top: '50%',
transform: 'translateY(-50%)',
background: 'rgba(255,255,255,0.1)',
border: 'none',
borderRadius: '50%',
width: '24px',
height: '24px',
color: '#fff',
fontSize: '16px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start'
}}
>
</button>
)}
</div>
{/* 排序表头按钮 */}
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap', marginBottom: '12px' }}>
<span style={{ color: '#94a3b8', fontSize: '12px', marginRight: '2px' }}>排序:</span>
{[
{ key: 'code', label: '编号' },
{ key: 'rarity', label: '珍惜度' },
{ key: 'numberCategory', label: '号码分类' },
{ key: 'packaging', label: '包装类型' },
].map(item => (
<div key={item.key} onClick={() => {
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' ? '↑' : '↓')}
</div>
))}
</div>
{/* 分页组件 */}
{pagination.pages > 1 && (
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', justifyContent: 'flex-start', marginBottom: '12px', padding: '8px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px' }}>
<button onClick={() => { setPage(Math.max(1, page - 1)) }} disabled={page === 1} style={{ padding: '4px 10px', borderRadius: '6px', border: 'none', background: page === 1 ? 'rgba(255,255,255,0.1)' : '#3b82f6', color: '#fff', cursor: page === 1 ? 'not-allowed' : 'pointer', opacity: page === 1 ? 0.5 : 1 }}>上一页</button>
{Array.from({ length: Math.min(5, pagination.pages) }, (_, i) => {
let startPage = Math.max(1, page - 2)
return <button key={i} onClick={() => { setPage(startPage + i) }} style={{ padding: '4px 8px', borderRadius: '6px', border: 'none', background: page === startPage + i ? '#3b82f6' : 'rgba(255,255,255,0.08)', color: '#fff', cursor: 'pointer' }}>{startPage + i}</button>
})}
<button onClick={() => { setPage(Math.min(pagination.pages, page + 1)) }} disabled={page === pagination.pages} style={{ padding: '4px 10px', borderRadius: '6px', border: 'none', background: page === pagination.pages ? 'rgba(255,255,255,0.1)' : '#3b82f6', color: '#fff', cursor: page === pagination.pages ? 'not-allowed' : 'pointer', opacity: page === pagination.pages ? 0.5 : 1 }}>下一页</button>
<span style={{ color: '#94a3b8', fontSize: '11px', marginLeft: '8px' }}>{pagination.total}</span>
</div>
)}
</div>
<div style={{ padding: '16px' }}>
{loading ? (
<div style={{ textAlign: 'center', padding: '60px', color: '#64748b' }}>加载中...</div>
) : filteredCollections.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 0' }}><div style={{ fontSize: '50px', opacity: 0.3 }}>📭</div><div style={{ color: '#64748b', marginTop: '16px' }}>{filter ? '暂无符合筛选条件的藏品' : '暂无藏品'}</div></div>
) : viewMode === 'grid' ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
{filteredCollections.map(item => (
<div key={item.id} onClick={() => 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' }}>
<div style={{ height: '80px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', fontSize: '32px', marginBottom: '10px' }}>🐉</div>
<div style={{ color: '#fff', fontSize: '11px', fontWeight: 'normal' }}>{item.code || '-'}</div>
<div style={{ color: '#fbbf24', fontSize: '11px', fontFamily: 'monospace', marginTop: '2px' }}>{formatPrefixSerial(item.prefixSerial)}</div>
<div style={{ display: 'flex', gap: '2px', marginTop: '6px', flexWrap: 'wrap' }}>
{item.gradingScore && <span style={{ color: '#fbbf24', fontSize: '11px', }}>{item.gradingScore}</span>}
{item.threeStar && <span style={{ color: '#fbbf24', fontSize: '10px' }}></span>}
</div>
</div>
))}
</div>
) : (
filteredCollections.map(item => <ListItem key={item.id} item={item} />)
)}
</div>
</div>
)
}

View File

@ -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 (
<div style={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}>
{/* Logo - 龙型 + 甲辰收藏文字 (橙色圆形设计) */}
<div style={{ marginBottom: '40px', textAlign: 'center' }}>
<img
src="/static/images/jiachenlong-logo.png?v=1"
alt="甲辰收藏"
style={{
width: '200px',
height: '200px',
marginBottom: '12px',
borderRadius: '50%',
boxShadow: '0 0 40px rgba(251, 191, 36, 0.4)',
background: '#fff'
}}
/>
<p style={{ color: 'rgba(255,255,255,0.6)', fontSize: '14px', margin: 0 }}>生肖纪念钞管理系统</p>
</div>
{/* 登录表单 */}
<div style={{
width: '100%',
maxWidth: '320px',
background: 'rgba(30, 41, 59, 0.8)',
borderRadius: '16px',
padding: '24px',
border: '1px solid rgba(255,255,255,0.1)'
}}>
<h2 style={{ color: '#fff', fontSize: '20px', fontWeight: '600', marginBottom: '24px', textAlign: 'center' }}>
欢迎回来 👋
</h2>
{error && (
<div style={{
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid rgba(239, 68, 68, 0.5)',
borderRadius: '8px',
padding: '12px',
marginBottom: '16px',
color: '#fca5a5',
fontSize: '13px'
}}>
{error}
</div>
)}
{/* 用户名 */}
<div style={{ marginBottom: '16px' }}>
<input
type="text"
value={username}
onChange={(e) => 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'
}}
/>
</div>
{/* 密码 */}
<div style={{ marginBottom: '20px' }}>
<input
type="password"
value={password}
onChange={(e) => 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'
}}
/>
</div>
{/* 获取 */}
<div style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', gap: '12px' }}>
<div onClick={generateCaptcha} 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: '#fbbf24',
fontSize: '18px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: '700',
cursor: 'pointer',
userSelect: 'none'
}}>
{captcha.num1} + {captcha.num2} = ?
</div>
<input
type="text"
value={captcha.answer}
onChange={(e) => 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'
}}
/>
</div>
</div>
{/* 登录按钮 */}
<button
onClick={handleLogin}
disabled={loading}
style={{
width: '100%',
padding: '16px',
borderRadius: '12px',
border: 'none',
background: loading ? '#f59e0b' : 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%)',
color: '#0f172a',
fontSize: '18px',
fontWeight: '700',
cursor: loading ? 'not-allowed' : 'pointer',
boxShadow: '0 4px 15px rgba(251, 191, 36, 0.3)'
}}
>
{loading ? '登录中...' : '登录'}
</button>
{/* 注册链接 */}
<div style={{ textAlign: 'center', marginTop: '16px', display: 'flex', justifyContent: 'center', gap: '16px' }}>
<span
onClick={() => setShowRegister(true)}
style={{
color: '#fbbf24',
fontSize: '14px',
cursor: 'pointer',
textDecoration: 'underline'
}}
>
注册新账号
</span>
<span style={{ color: 'rgba(255,255,255,0.3)' }}>|</span>
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: '14px' }}>
需要帮助联系管理员
</span>
</div>
</div>
{/* 版本号 */}
<div style={{
position: 'fixed',
bottom: '20px',
color: 'rgba(251, 191, 36, 0.5)',
fontSize: '12px',
textAlign: 'center',
fontWeight: '600'
}}>
v{APP_VERSION}
</div>
{/* 注册弹窗 */}
{showRegister && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
zIndex: 1000
}}>
<div style={{
width: '100%',
maxWidth: '320px',
background: 'rgba(30, 41, 59, 0.95)',
borderRadius: '16px',
padding: '24px',
border: '1px solid rgba(255,255,255,0.1)',
maxHeight: '90vh',
overflowY: 'auto'
}}>
<h2 style={{ color: '#fbbf24', fontSize: '20px', fontWeight: '600', marginBottom: '20px', textAlign: 'center' }}>
注册新账号 🎉
</h2>
{registerError && (
<div style={{
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid rgba(239, 68, 68, 0.5)',
borderRadius: '8px',
padding: '12px',
marginBottom: '16px',
color: '#fca5a5',
fontSize: '13px'
}}>
{registerError}
</div>
)}
{/* 用户名 */}
<div style={{ marginBottom: '16px' }}>
<input
type="text"
value={registerData.username}
onChange={(e) => 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'
}}
/>
</div>
{/* 手机号 */}
<div style={{ marginBottom: '16px' }}>
<input
type="tel"
value={registerData.phone}
onChange={(e) => 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'
}}
/>
</div>
{/* 短信获取 */}
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="text"
value={registerData.verifyCode}
onChange={(e) => 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'
}}
/>
<button
onClick={handleSendCode}
disabled={sendingCode || codeCountdown > 0}
style={{
minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: 'none',
background: codeCountdown > 0 ? 'rgba(148, 163, 184, 0.3)' : 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
color: '#fff',
fontSize: '14px',
fontWeight: '600',
cursor: codeCountdown > 0 ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap'
}}
>
{codeCountdown > 0 ? `${codeCountdown}` : sendingCode ? '发送中...' : '获取验证码'}
</button>
</div>
</div>
{/* 邮箱(可选) */}
<div style={{ marginBottom: '16px' }}>
<input
type="email"
value={registerData.email}
onChange={(e) => 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'
}}
/>
</div>
{/* 密码 */}
<div style={{ marginBottom: '16px' }}>
<input
type="password"
value={registerData.password}
onChange={(e) => 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'
}}
/>
</div>
{/* 确认密码 */}
<div style={{ marginBottom: '24px' }}>
<input
type="password"
value={registerData.confirmPassword}
onChange={(e) => 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'
}}
/>
</div>
{/* 用户协议勾选 */}
<div style={{ marginBottom: '20px', display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
<input
type="checkbox"
checked={registerData.agreeTerms}
onChange={(e) => setRegisterData(prev => ({ ...prev, agreeTerms: e.target.checked }))}
style={{ width: '18px', height: '18px', marginTop: '2px', cursor: 'pointer' }}
/>
<div style={{ fontSize: '12px', color: 'rgba(255,255,255,0.7)', lineHeight: '1.5' }}>
我已阅读并同意
<span
onClick={() => window.open('/user_agreement.html', '_blank')}
style={{ color: '#fbbf24', cursor: 'pointer', textDecoration: 'underline' }}
>用户协议</span>
</div>
</div>
{/* 按钮 */}
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={() => {
setShowRegister(false)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '' })
setRegisterError('')
setCodeSent(false)
setCodeCountdown(0)
}}
style={{
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: 'none',
background: 'rgba(148, 163, 184, 0.2)',
color: '#94a3b8',
fontSize: '16px',
fontWeight: '600',
cursor: 'pointer'
}}
>
取消
</button>
<button
onClick={handleRegister}
disabled={registerLoading}
style={{
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: 'none',
background: registerLoading ? '#f59e0b' : 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%)',
color: '#0f172a',
fontSize: '16px',
fontWeight: '600',
cursor: registerLoading ? 'not-allowed' : 'pointer'
}}
>
{registerLoading ? '注册中...' : '注册'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

654
frontend/src/pages/OCR.jsx Normal file
View File

@ -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 (
<div style={{ marginBottom: '12px' }} ref={inputRef}>
<div style={{ fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>{label}</div>
{options ? (
<select
value={form[field] || ''}
onChange={onChange}
onFocus={handleFocus}
disabled={disabled}
style={{
background: disabled ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
color: disabled ? '#64748b' : '#fff',
padding: '10px',
borderRadius: '8px',
width: '100%'
}}
>
<option value="">请选择</option>
{options.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
) : (
<input
type={type}
value={form[field] ?? ''}
onChange={onChange}
onFocus={handleFocus}
disabled={disabled}
style={{
background: disabled ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
color: disabled ? '#64748b' : '#fff',
padding: '10px',
borderRadius: '8px',
width: '100%'
}}
/>
)}
</div>
)
}
//
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 (
<div style={{ background: '#0f172a', minHeight: '100vh', paddingBottom: '80px' }}>
{/* 顶部标签切换 */}
<div style={{ padding: '12px 16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setActiveTab('ai')}
style={{
flex: 1,
padding: '10px',
background: activeTab === 'ai' ? '#fbbf24' : 'rgba(255,255,255,0.05)',
color: activeTab === 'ai' ? '#1e293b' : '#fff',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer'
}}
>
🤖 AI 识别
</button>
<button
onClick={() => {
setActiveTab('manual');
setMode('form');
}}
style={{
flex: 1,
padding: '10px',
background: activeTab === 'manual' ? '#fbbf24' : 'rgba(255,255,255,0.05)',
color: activeTab === 'manual' ? '#1e293b' : '#fff',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer'
}}
>
手工录入
</button>
<button
onClick={() => setActiveTab('batch')}
disabled
style={{
flex: 1,
padding: '10px',
background: 'rgba(255,255,255,0.02)',
color: '#64748b',
border: '1px solid rgba(255,255,255,0.05)',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: 'not-allowed'
}}
>
📦 批量录入
</button>
</div>
</div>
{/* 内容区域 */}
{activeTab === 'ai' && (
<div style={{ padding: '16px' }}>
{/* 图片选择区域 */}
<div style={{
background: 'rgba(255,255,255,0.03)',
borderRadius: '12px',
padding: '24px',
textAlign: 'center',
marginBottom: '12px'
}}>
<input
ref={fileInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleSelectImage}
style={{ display: 'none' }}
/>
{imagePreview ? (
<div>
<img
src={imagePreview}
alt="已选择图片"
style={{ maxWidth: '100%', borderRadius: '8px', marginBottom: '16px' }}
/>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
<button
onClick={() => { setSelectedImage(null); setImagePreview(null); }}
style={{
padding: '12px 24px',
background: 'rgba(239, 68, 68, 0.2)',
color: '#ef4444',
border: '1px solid #ef4444',
borderRadius: '8px',
fontSize: '14px',
cursor: 'pointer'
}}
>
🗑 重新选择
</button>
<button
onClick={handleRecognize}
disabled={recognizing}
style={{
padding: '12px 24px',
background: recognizing ? '#64748b' : '#fbbf24',
color: recognizing ? '#94a3b8' : '#1e293b',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: recognizing ? 'not-allowed' : 'pointer'
}}
>
{recognizing ? '🔍 识别中...' : '🤖 开始识别'}
</button>
</div>
</div>
) : (
<div>
<div
onClick={() => {
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'
}}
>
<div style={{ fontSize: '40px', marginBottom: '12px' }}>📷</div>
<div style={{ color: '#60a5fa', fontSize: '15px', fontWeight: 'bold', marginBottom: '4px' }}>
拍照识别
</div>
<div style={{ color: '#94a3b8', fontSize: '13px' }}>
使用相机拍照并识别
</div>
</div>
<div
onClick={() => {
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'
}}
>
<div style={{ fontSize: '40px', marginBottom: '12px' }}>🖼</div>
<div style={{ color: '#22c55e', fontSize: '15px', fontWeight: 'bold', marginBottom: '4px' }}>
从相册选择
</div>
<div style={{ color: '#94a3b8', fontSize: '13px' }}>
从相册选择已有图片
</div>
</div>
</div>
)}
</div>
{/* 错误提示 */}
{error && (
<div style={{
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid #ef4444',
color: '#ef4444',
padding: '12px',
borderRadius: '8px',
marginBottom: '12px'
}}>
{error}
</div>
)}
{/* 提示信息 */}
<div style={{
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
padding: '16px',
marginTop: '12px'
}}>
<div style={{ color: '#60a5fa', fontSize: '14px', fontWeight: 'bold', marginBottom: '8px' }}>💡 识别说明</div>
<ul style={{ color: '#94a3b8', fontSize: '13px', paddingLeft: '20px', margin: 0 }}>
<li>支持拍照或从相册选择图片</li>
<li>自动识别名称版别冠字序号等字段</li>
<li>识别结果可手动修改完善</li>
<li>建议拍摄清晰光线充足的正面照片</li>
</ul>
</div>
</div>
</div>
)
}
//
return (
<div style={{ background: '#0f172a', minHeight: '100vh', paddingBottom: '80px' }}>
{/* 顶部 */}
<div style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '12px' }}>
<button
onClick={() => setMode('camera')}
style={{
background: 'rgba(255,255,255,0.1)',
color: '#fff',
border: 'none',
borderRadius: '6px',
width: '36px',
height: '36px',
fontSize: '20px',
cursor: 'pointer'
}}
>
</button>
<div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>确认信息</div>
<div style={{ color: '#94a3b8', fontSize: '13px' }}>请检查并完善识别结果</div>
</div>
</div>
{error && (
<div style={{
margin: '16px',
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid #ef4444',
color: '#ef4444',
padding: '12px',
borderRadius: '8px'
}}>
{error}
</div>
)}
<div style={{ padding: '16px' }}>
{/* 基本信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
<Input form={form} handleChange={handleChange} label="名称 *" field="name" />
<Input form={form} handleChange={handleChange} label="版别 *" field="version" />
<Input form={form} handleChange={handleChange} label="冠字序号" field="prefixSerial" />
<Input form={form} handleChange={handleChange} label="面值" field="denomination" />
<Input form={form} handleChange={handleChange} label="材质" field="material" />
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
</div>
{/* 价格信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>价格信息</div>
<Input form={form} handleChange={handleChange} label="成本价" field="costPrice" type="number" />
<Input form={form} handleChange={handleChange} label="目标价" field="targetPrice" type="number" />
<Input form={form} handleChange={handleChange} label="出售价" field="goalPrice" type="number" />
</div>
{/* 备注 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>备注</div>
<textarea
value={form.remark || ''}
onChange={(e) => handleChange('remark', e.target.value)}
rows={3}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '10px', borderRadius: '8px', width: '100%', resize: 'none' }}
/>
</div>
{/* 保存按钮 */}
<button
onClick={saving ? null : handleSave}
disabled={saving}
style={{
width: '100%',
padding: '14px',
background: saving ? '#64748b' : '#fbbf24',
color: saving ? '#94a3b8' : '#1e293b',
border: 'none',
borderRadius: '10px',
fontSize: '16px',
fontWeight: '600',
cursor: saving ? 'not-allowed' : 'pointer',
marginBottom: '12px'
}}
>
{saving ? '保存中...' : '✅ 保存藏品'}
</button>
<button
onClick={() => setMode('camera')}
style={{
width: '100%',
padding: '14px',
background: 'rgba(255,255,255,0.05)',
color: '#94a3b8',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '10px',
fontSize: '14px',
cursor: 'pointer'
}}
>
🔄 重新识别
</button>
</div>
</div>
)
}

View File

@ -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 (
<div style={{ minHeight: '100vh', background: '#0f172a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ color: '#fff' }}>加载中...</div>
</div>
)
}
return (
<div style={{ minHeight: '100vh', background: '#0f172a', padding: '20px', paddingBottom: '80px' }}>
{/* 顶部导航 */}
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '24px' }}>
<div onClick={() => window.history.back()} style={{ cursor: 'pointer', fontSize: '20px', color: '#fff' }}></div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold', marginLeft: '16px' }}>个人设置</div>
</div>
{error && <div style={{ background: '#fee2e2', color: '#dc2626', padding: '12px', borderRadius: '8px', marginBottom: '16px' }}>{error}</div>}
{success && <div style={{ background: '#dcfce7', color: '#16a34a', padding: '12px', borderRadius: '8px', marginBottom: '16px' }}>{success}</div>}
{/* 基本信息 */}
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '20px', marginBottom: '16px' }}>
<div style={{ color: '#fff', fontSize: '16px', fontWeight: 'bold', marginBottom: '16px' }}>基本信息</div>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>用户名</label>
<input
type="text"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>邮箱选填</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="选填"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>手机号</label>
<input
type="tel"
value={newPhone}
onChange={(e) => {
setNewPhone(e.target.value)
setPhoneChanged(true)
setVerifyCode('')
}}
placeholder="11位手机号"
maxLength={11}
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
{/* 手机验证码 - 仅在手机号变更时显示 */}
{phoneChanged && (
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>手机验证码</label>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
placeholder="6位验证码"
maxLength={6}
style={{ flex: 1, padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
<button
type="button"
onClick={handleSendCode}
disabled={sendingCode || codeCountdown > 0}
style={{
width: '120px',
padding: '12px',
borderRadius: '8px',
border: 'none',
background: codeCountdown > 0 ? '#4b5563' : '#22c55e',
color: '#fff',
fontSize: '14px',
cursor: codeCountdown > 0 ? 'not-allowed' : 'pointer'
}}
>
{codeCountdown > 0 ? codeCountdown + '秒' : sendingCode ? '发送中...' : '获取验证码'}
</button>
</div>
</div>
)}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>地址选填</label>
<input
type="text"
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
placeholder="选填"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>简介选填</label>
<textarea
value={form.bio}
onChange={(e) => setForm({ ...form, bio: e.target.value })}
placeholder="选填"
rows={3}
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff', resize: 'none' }}
/>
</div>
<button
type="submit"
disabled={saving}
style={{ width: '100%', padding: '14px', borderRadius: '8px', border: 'none', background: '#3b82f6', color: '#fff', fontSize: '16px', cursor: saving ? 'not-allowed' : 'pointer', opacity: saving ? 0.6 : 1 }}
>
{saving ? '保存中...' : '保存修改'}
</button>
</form>
</div>
{/* 修改密码 */}
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '20px', marginBottom: '16px' }}>
<div style={{ color: '#fff', fontSize: '16px', fontWeight: 'bold', marginBottom: '16px' }}>修改密码</div>
<form onSubmit={handlePasswordChange}>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>当前密码</label>
<input
type="password"
value={passwordForm.oldPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, oldPassword: e.target.value })}
placeholder="请输入当前密码"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>新密码</label>
<input
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
placeholder="至少8位需包含大写+小写+数字"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>确认新密码</label>
<input
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
placeholder="再次输入新密码"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
<button
type="submit"
disabled={saving}
style={{ width: '100%', padding: '14px', borderRadius: '8px', border: 'none', background: '#f59e0b', color: '#fff', fontSize: '16px', cursor: saving ? 'not-allowed' : 'pointer', opacity: saving ? 0.6 : 1 }}
>
{saving ? '修改中...' : '修改密码'}
</button>
</form>
</div>
{/* 退出登录 */}
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '20px' }}>
<button
onClick={() => {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.hash = '#/login'
}}
style={{ width: '100%', padding: '14px', borderRadius: '8px', border: '1px solid #ef4444', background: 'transparent', color: '#ef4444', fontSize: '16px', cursor: 'pointer' }}
>
退出登录
</button>
</div>
</div>
)
}

View File

@ -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 }) => (
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>{title}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}>
{getSortedData(data, type).map((item, index) => {
const value = item[valueKey]
const label = getLabel(type, item[labelKey] || value)
const color = getColor(type, value)
return (
<div
key={index}
onClick={() => 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)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flex: 1, overflow: 'hidden' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: color, flexShrink: 0 }} />
<div style={{ color: color, fontSize: '12px', fontWeight: '500', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
</div>
</div>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: 'bold', marginLeft: '8px', flexShrink: 0 }}>
{item.count}
</div>
</div>
</div>
)
})}
</div>
{data.length > 6 && (
<div style={{ textAlign: 'center', color: '#94a3b8', fontSize: '12px', marginTop: '8px' }}>
{data.length}显示前 6
</div>
)}
</div>
)
if (loading) {
return (
<div style={{ background: '#0f172a', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '16px' }}>加载中...</div>
</div>
)
}
const gradedCount = stats.byGrading.find(g => g.isGraded === true)?.count || 0
const ungradedCount = stats.byGrading.find(g => g.isGraded === false)?.count || 0
return (
<div style={{ background: '#0f172a', minHeight: '100vh', paddingBottom: '80px' }}>
{/* 顶部 */}
<div style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>统计分析</div>
<div style={{ color: '#94a3b8', fontSize: '13px', marginTop: '4px' }}>点击统计项查看明细</div>
</div>
<div style={{ color: 'rgba(255,255,255,0.3)', fontSize: '11px' }}>v{APP_VERSION}</div>
</div>
<div style={{ padding: '16px' }}>
{/* 总统计 */}
<div style={{ background: 'linear-gradient(135deg, rgba(251, 191, 36, 0.2) 0%, rgba(251, 191, 36, 0.05) 100%)', borderRadius: '12px', padding: '20px', marginBottom: '16px', border: '1px solid rgba(251, 191, 36, 0.3)' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '16px' }}>📊 总统计</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>总数量</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{stats.totalCount}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>已评级</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{gradedCount}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>未评级</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{ungradedCount}</div>
</div>
</div>
</div>
{/* 财务统计 */}
<div style={{ background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.05) 100%)', borderRadius: '12px', padding: '20px', marginBottom: '16px', border: '1px solid rgba(34, 197, 94, 0.3)' }}>
<div style={{ color: '#22c55e', fontSize: '14px', fontWeight: 'bold', marginBottom: '16px' }}>💰 财务统计</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>总成本</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>¥{formatMoney(stats.totalCost)}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>总收入</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>¥{formatMoney(stats.totalRevenue)}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>预期利润</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: 'bold' }}>+¥{formatMoney(stats.expectedProfit)}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>已实现利润</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: 'bold' }}>+¥{formatMoney(stats.totalProfit)}</div>
</div>
</div>
</div>
{/* 盈亏统计移到顶部 */}
<DistributionCard title="💰 盈亏统计" data={stats.byProfitLoss} type="profitLoss" valueKey="type" labelKey="label" />
<DistributionCard title="📦 包装分布" data={stats.byPackaging} type="packaging" valueKey="packaging" />
<DistributionCard title="🔢 号码分类分布" data={stats.byNumberCategory} type="numberCategory" valueKey="numberCategory" />
<DistributionCard title="📋 状态分布" data={stats.byStatus} type="status" valueKey="status" />
<DistributionCard title="⭐ 珍惜度分布" data={stats.byRarity} type="rarity" valueKey="rarity" />
<DistributionCard title="🏷️ 版别分布" data={stats.byVersion} type="version" valueKey="version" />
<DistributionCard title="🏅 评级机构分布" data={stats.byGradingCompany} type="gradingCompany" valueKey="company" />
<DistributionCard title="📈 评级分数分布" data={stats.byGradingScore} type="gradingScore" valueKey="score" />
<DistributionCard title="✨ 特殊标识分布" data={stats.bySpecialMark} type="specialMark" valueKey="mark" />
</div>
</div>
)
}

230
frontend/src/utils/api.js Normal file
View File

@ -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

View File

@ -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 }

View File

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
cd /root/jiachenlong/backend cd /root/jiachenlong/backend
export DATABASE_URL=postgresql://caibot:caibot123@47.98.171.101:5432/zodiac export DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/zodiac
export SECRET_KEY=production-secret-key-a-env export SECRET_KEY=production-secret-key-c-env
export OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG export OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
export OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1 export OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
export SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE export SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
@ -9,4 +9,4 @@ export SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
export SMS_SIGN_NAME=苏州算力 export SMS_SIGN_NAME=苏州算力
export SMS_TEMPLATE_CODE=SMS_501590956 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 & nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 > /tmp/uvicorn.log 2>&1 &
echo "后端服务已启动" echo '后端服务已启动'

115
user_agreement.html Normal file
View File

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>用户协议 - 甲辰收藏</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; line-height: 1.8; background: #f5f5f5; }
h1 { text-align: center; color: #1a1a1a; font-size: 24px; margin-bottom: 30px; }
h2 { color: #333; font-size: 18px; margin-top: 30px; margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 8px; }
h3 { color: #555; font-size: 15px; margin-top: 20px; margin-bottom: 10px; }
.content { color: #444; font-size: 14px; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
.content p { margin: 10px 0; }
.highlight { font-weight: bold; }
.version { text-align: center; color: #999; font-size: 12px; margin-bottom: 20px; }
ul { padding-left: 20px; }
li { margin: 5px 0; }
</style>
</head>
<body>
<div class="content">
<h1>甲辰收藏平台用户协议</h1>
<div class="version">版本生效日期:自注册之日起</div>
<p><span class="highlight">特别提示:</span>本协议涉及您的重大权利义务,加粗条款请您重点阅读。您完成注册、登录或继续使用本平台服务,即视为已充分阅读、理解并同意本协议全部内容。本协议适用《中华人民共和国民法典》《网络安全法》《个人信息保护法》《电子商务法》等法律法规。</p>
<h2>一、协议主体与适用范围</h2>
<ol>
<li>本协议由您与甲辰收藏平台运营主体(以下简称"平台")签订,约束您使用平台藏品管理,信息展示,数据存储,社区互动等全部服务。</li>
<li>平台发布的隐私政策、藏品发布规则、交易规范等均为本协议组成部分,与本协议具有同等法律效力。</li>
<li>您承诺为具备完全民事行为能力的自然人/法人;未成年人使用需监护人同意并陪同。</li>
</ol>
<h2>二、个人信息收集与使用</h2>
<h3>(一)收集原则</h3>
<p>平台遵循合法、正当、必要、诚信、最小必要原则收集信息,仅为实现服务功能所必需,不强制收集非必要信息。</p>
<h3>(二)收集范围与目的</h3>
<ol>
<li><strong>注册与身份信息:</strong>用户名、手机号、邮箱(用于账号创建,安全验证、客服联系)。</li>
<li><strong>藏品与行为信息:</strong>藏品上传内容、收藏记录、浏览操作、发布评论、交易数据(用于藏品管理,服务优化、风控核验)。</li>
<li><strong>设备与网络信息:</strong>设备型号、系统版本、IP 地址、日志信息(用于安全防护、故障排查,防作弊)。</li>
<li><strong>位置信息:</strong>仅在您主动开启定位权限时收集,用于同城展示等可选功能,关闭不影响基础服务。</li>
</ol>
<h3>(三)信息使用规则</h3>
<ol>
<li>平台仅在您授权范围内使用信息,不超出约定目的、范围与期限处理。</li>
<li>向第三方共享信息时,将单独告知并取得您明确同意,法律法规要求除外。</li>
<li>您有权查阅、更正、删除个人信息,申请注销账号,平台在核验身份后依法处理。</li>
<li>平台采取加密、去标识化、权限管控等措施保护信息安全,制定安全事件应急预案。</li>
</ol>
<h2>三、公开信息使用与免责条款</h2>
<ol>
<li><strong>公开信息定义:</strong>您通过平台主动发布、设置为公开可见的藏品资料、图文、评论、动态等内容,均属于您自行公开的信息。</li>
<li><strong>授权使用:</strong>您同意平台可在合理范围内使用您的公开信息,用于藏品展示、平台运营、合规审核、服务推广等,不侵犯您合法权益。</li>
<li><strong>法定免责依据:</strong>根据《个人信息保护法》第二十七条,平台处理您自行合法公开的信息,在无明确拒绝且不对您权益造成重大影响的情形下,无需另行取得单独同意。</li>
<li><strong>第三方行为免责:</strong>
<ul>
<li>公开信息可被其他用户浏览、复制、存储、转发,平台无法完全控制第三方使用行为。</li>
<li>因第三方擅自使用、转载、篡改您公开信息引发的纠纷、损失,平台不承担责任。</li>
</ul>
</li>
<li><strong>内容责任自负:</strong>
<ul>
<li>您对公开信息的真实性、合法性、原创性承担全部责任,不得侵犯第三方知识产权、肖像权、名誉权等。</li>
<li>如因您发布违法、侵权内容导致平台受损,您应承担全部赔偿责任。</li>
</ul>
</li>
<li><strong>平台管理边界:</strong>平台仅依据法律法规与平台规则进行内容审核,对用户自主发布的公开信息不做修改、篡改,不承担真实性担保责任。</li>
</ol>
<h2>四、用户权利与义务</h2>
<ol>
<li>您有权使用平台基础服务,对账号与密码安全负责,不得转借、出售账号。</li>
<li>不得发布违法违规、侵权、虚假、低俗等违反法律法规与公序良俗的内容。</li>
<li>不得利用平台从事洗钱、诈骗、非法交易等违法活动。</li>
<li>您对自行上传的藏品素材、文字等享有知识产权,授权平台在服务范围内非独占使用。</li>
</ol>
<h2>五、知识产权条款</h2>
<ol>
<li>平台所有文字、图标、界面设计、软件代码等知识产权归平台所有,受法律保护。</li>
<li>您上传的原创内容知识产权归您所有;您授权平台为提供服务之目的,使用、存储、展示、传播该内容。</li>
<li>未经权利人书面许可,任何主体不得复制、改编、传播、商用平台内容或用户原创内容。</li>
</ol>
<h2>六、服务变更、中断与终止</h2>
<ol>
<li>平台因维护、升级、政策调整需变更或暂停服务的,将提前公示;因不可抗力、监管要求、第三方故障导致服务中断的,平台不承担违约责任。</li>
<li>平台有权对违规账号采取警示、限流、删帖、封禁等处理措施。</li>
<li>您可随时停止使用服务;账号注销后,平台按法律法规留存相关数据,逾期依法删除。</li>
</ol>
<h2>七、免责声明(法律允许范围内)</h2>
<ol>
<li>平台按"现状"提供服务,对服务及时性、安全性、稳定性不作绝对担保。</li>
<li>平台不对藏品真伪、价值、权属作明示或暗示保证,藏品鉴定与价值判断由您自行负责。</li>
<li>法律允许的最大范围内,平台不对间接损失、利润损失、数据丢失等承担赔偿责任;因平台故意或重大过失导致的损失除外。</li>
<li>因您自身操作不当、账号保管不善、第三方侵权等导致的损失,由您自行承担。</li>
</ol>
<h2>八、协议修改与争议解决</h2>
<ol>
<li>平台修改协议将提前7日公示您继续使用视为接受修订如不同意可停止使用并注销账号。</li>
<li>因本协议产生争议,双方协商解决;协商不成,提交平台运营主体所在地有管辖权的人民法院诉讼解决。</li>
<li>本协议适用中华人民共和国大陆地区法律(不含港澳台法律)。</li>
</ol>
<h2>九、联系与通知</h2>
<p>平台联系方式aicoolbot@163.com您可通过客服渠道咨询协议、隐私、投诉等相关事宜。</p>
</div>
</body>
</html>