Compare commits

..

No commits in common. "main" and "v1.2.30-zixun" have entirely different histories.

70 changed files with 2713 additions and 11761 deletions

View File

@ -6,9 +6,9 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| **版本** | v1.2.38 | | **版本** | v1.2.2 |
| **代号** | 最终优化版本 | | **代号** | 列表优化版本 |
| **发布日期** | 2026-03-31 | | **发布日期** | 2026-03-20 |
## 技术栈 ## 技术栈
@ -47,12 +47,11 @@ jiachenlong/
| 版本 | 日期 | 说明 | | 版本 | 日期 | 说明 |
|------|------|------| |------|------|------|
| v1.2.38 | 2026-03-31 | 最终优化版本 - Admin页面显示28字段、登录AI配号统计、修复OCR/短信/注册提示 |
| v1.2.35 | 2026-03-31 | Admin页面显示28字段登录/AI/配号次数统计 |
| v1.2.30 | 2026-03-25 | 资讯功能页面 + 管理后台V2 |
| v1.2.2 | 2026-03-20 | 列表布局优化 | | v1.2.2 | 2026-03-20 | 列表布局优化 |
| v1.2.1 | 2026-03-20 | 密码强度验证、用户协议 | | v1.2.1 | 2026-03-20 | 密码强度验证、用户协议 |
| v1.2.0 | 2026-03-20 | 管理员查看用户藏品 | | v1.2.0 | 2026-03-20 | 管理员查看用户藏品 |
| v1.1.21 | 2026-03-19 | Nginx优化、OSS集成 |
| v1.1.0 | 2026-03-18 | 用户设置功能 |
## 快速开始 ## 快速开始

1
VERSION Normal file
View File

@ -0,0 +1 @@
VERSION=1.2.12

View File

@ -1 +0,0 @@
1.2.98

View File

@ -11,11 +11,9 @@ from app.core.database import SessionLocal
from app.models.models import User from app.models.models import User
# 配置 # 配置
SECRET_KEY = os.getenv("SECRET_KEY") SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
if not SECRET_KEY:
raise ValueError("SECRET_KEY environment variable is not set. Please configure it in production!")
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7天 ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
# HTTP Bearer 认证 # HTTP Bearer 认证
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
@ -59,10 +57,14 @@ def decode_access_token(token: str) -> Optional[dict]:
def get_current_user( def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(lambda: SessionLocal()) db: Session = Depends(lambda: SessionLocal())
) -> Optional[User]: ) -> User:
"""获取当前用户可返回None""" """获取当前用户"""
if not credentials: if not credentials:
return None raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未提供认证信息",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials token = credentials.credentials
payload = decode_access_token(token) payload = decode_access_token(token)
@ -84,6 +86,10 @@ def get_current_user(
user = db.query(User).filter(User.f99_90_id == user_id).first() user = db.query(User).filter(User.f99_90_id == user_id).first()
if not user: if not user:
return None raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在",
headers={"WWW-Authenticate": "Bearer"},
)
return user return user

View File

@ -1,37 +0,0 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import QueuePool
COOLBOT_DB_URL = os.getenv(
"COOLBOT_DB_URL",
"postgresql://coolbot:Coolbot123@pgm-bp1t1008h019ez6cno.pg.rds.aliyuncs.com:5432/coolbot_data"
)
coolbot_engine = create_engine(
COOLBOT_DB_URL,
poolclass=QueuePool,
pool_size=10,
max_overflow=20,
pool_timeout=30,
pool_recycle=1800,
pool_pre_ping=True,
echo=False,
connect_args={
"connect_timeout": 10,
"application_name": "zodiac-coolbot"
}
)
CoolbotSession = sessionmaker(autocommit=False, autoflush=False, bind=coolbot_engine)
def get_coolbot_db():
"""获取coolbot数据库会话"""
db = CoolbotSession()
try:
yield db
except Exception:
db.rollback()
raise
finally:
db.close()

View File

@ -1,69 +1,34 @@
import os import os
import time from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import QueuePool
from typing import Generator
import logging
logger = logging.getLogger(__name__)
# 支持 MySQL, PostgreSQL
DATABASE_URL = os.getenv( DATABASE_URL = os.getenv(
"DATABASE_URL", DATABASE_URL,
"postgresql://postgres:postgres@127.0.0.1:5432/zodiac" postgresql://postgres:postgres@localhost:5432/zodiac
) )
# 增强版数据库引擎配置 # 数据库引擎配置
engine = create_engine( engine = create_engine(
DATABASE_URL, DATABASE_URL,
# 连接池配置 pool_pre_ping=True,
poolclass=QueuePool, pool_size=10,
pool_size=20, # 常规连接数 max_overflow=20,
max_overflow=40, # 允许超出的连接数(高并发时) pool_recycle=3600, # 1小时回收连接
pool_timeout=30, # 获取连接超时时间(秒) pool_timeout=30, # 获取连接超时30秒
pool_recycle=1800, # 连接回收时间30分钟避免连接过期 echo=False
pool_pre_ping=True, # 每次获取连接前检查连接是否有效
echo=False,
# 连接参数优化
connect_args={
"connect_timeout": 10,
"application_name": "zodiac-api",
"options": "-c statement_timeout=30000" # 查询超时30秒
}
) )
# 添加连接事件监听器
@event.listens_for(engine, "connect")
def set_connect_timeout(dbapi_conn, connection_record):
"""设置连接参数"""
cursor = dbapi_conn.cursor()
cursor.execute("SET statement_timeout = 30000")
cursor.close()
@event.listens_for(engine, "checkout")
def check_connection(dbapi_conn, connection_record, connection_proxy):
"""检出连接时检查"""
try:
cursor = dbapi_conn.cursor()
cursor.execute("SELECT 1")
cursor.close()
except Exception as e:
logger.warning(f"连接检查失败: {e}")
raise Exception("数据库连接无效")
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base() Base = declarative_base()
def get_db() -> Generator:
"""获取数据库会话,带错误处理""" def get_db():
"""获取数据库会话"""
db = SessionLocal(expire_on_commit=False) db = SessionLocal(expire_on_commit=False)
try: try:
yield db yield db
except Exception as e:
logger.error(f"数据库会话错误: {e}")
db.rollback()
raise
finally: finally:
db.close() db.close()

View File

@ -15,10 +15,6 @@ from app.middleware.logging import logging_middleware
from app.routers import auth, collections, operations from app.routers import auth, collections, operations
from app.routers import ocr as ocr_router from app.routers import ocr as ocr_router
from app.routers import users as users_router from app.routers import users as users_router
from app.routers import information as information_router
from app.routers import yichens as yichens_router
from app.routers import seek as seek_router
from app.routers import deal as deal_router
# 版本信息 - 从 config/VERSION 文件读取 # 版本信息 - 从 config/VERSION 文件读取
def get_version(): def get_version():
@ -54,11 +50,10 @@ app = FastAPI(
# 设置全局错误处理器 # 设置全局错误处理器
setup_error_handlers(app) setup_error_handlers(app)
# CORS 配置 - 生产环境限制域名 # CORS 配置
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://47.103.29.111,http://120.55.81.21,https://socoolbot.com").split(",")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=ALLOWED_ORIGINS, allow_origins=["*"], # 生产环境应该限制域名
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@ -69,10 +64,10 @@ uploads_dir = "uploads"
os.makedirs(uploads_dir, exist_ok=True) os.makedirs(uploads_dir, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads") app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
# 挂载项目静态资源目录 # 挂载项目静态资源目录(可选,生产环境建议用 Nginx
static_dir = Path(__file__).parent.parent / "static" # static_dir = Path(__file__).parent.parent.parent / "static"
if static_dir.exists(): # if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") # app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# 添加日志中间件 # 添加日志中间件
app.middleware("http")(logging_middleware) app.middleware("http")(logging_middleware)
@ -84,10 +79,6 @@ app.include_router(operations.router)
app.include_router(ocr_router.router) # OCR 识别 app.include_router(ocr_router.router) # OCR 识别
app.include_router(users_router.router) # 当前用户接口 app.include_router(users_router.router) # 当前用户接口
app.include_router(users_router.admin_router) # 管理员用户管理 app.include_router(users_router.admin_router) # 管理员用户管理
app.include_router(information_router.router)
app.include_router(yichens_router.router) # 一尘看板
app.include_router(seek_router.router) # 寻配号
app.include_router(deal_router.router) # 成交行情
@app.get("/") @app.get("/")
@ -110,6 +101,3 @@ if __name__ == "__main__":
import uvicorn import uvicorn
port = int(os.getenv("PORT", "3000")) port = int(os.getenv("PORT", "3000"))
uvicorn.run(app, host="0.0.0.0", port=port) uvicorn.run(app, host="0.0.0.0", port=port)
from app.routers import seek as seek_router
from app.routers import deal as deal_router

View File

@ -1,54 +0,0 @@
from sqlalchemy import Column, String, Text, Float, Date, Boolean, Integer, DateTime
from sqlalchemy.sql import func
from app.core.database import Base
import uuid
def generate_uuid():
return str(uuid.uuid4())
class DealInfo(Base):
__tablename__ = "deal_info"
id = Column(String(36), primary_key=True, default=generate_uuid)
user_id = Column(String(36), nullable=True, index=True)
# 标题和内容
title = Column(String(255), nullable=False)
content = Column(Text, nullable=True)
# 成交信息
deal_price = Column(Float, nullable=True) # 成交价格
deal_date = Column(Date, nullable=True) # 成交日期
deal_no = Column(String(20), nullable=True, index=True) # 行情编号从A000001开始递增
# 包装和分类
packaging = Column(String(50), nullable=True) # 包装(标百/标十/单张)
category = Column(String(100), nullable=True) # 分类
# 评级相关
is_graded = Column(Boolean, default=False) # 是否评级
grading_company = Column(String(100), nullable=True) # 评级机构
grading_score = Column(String(50), nullable=True) # 评级分数
# 号码特征
tail_number = Column(String(10), nullable=True) # 尾号
size_type = Column(String(20), nullable=True) # 大小号
# 版别
version = Column(String(50), nullable=True) # 版别
# 交易信息
platform = Column(String(50), nullable=True) # 成交平台
seller = Column(String(100), nullable=True) # 出售者
buyer = Column(String(100), nullable=True) # 购买者
# 状态
status = Column(String(20), default="active")
# 统计
view_count = Column(Integer, default=0)
contact_count = Column(Integer, default=0)
# 时间
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@ -1,5 +1,5 @@
# 数据库模型 - 使用字段编码 # 数据库模型 - 使用字段编码
from sqlalchemy import Column, String, Float, Boolean, DateTime, Integer, Text, ForeignKey, Date, UniqueConstraint from sqlalchemy import Column, String, Float, Boolean, DateTime, Integer, Text, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.core.database import Base from app.core.database import Base
@ -26,26 +26,9 @@ class User(Base):
bio = Column(Text, nullable=True) bio = Column(Text, nullable=True)
password = Column(String(255), nullable=False) password = Column(String(255), nullable=False)
role = Column(String(50), default="user") 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_92_created_at = Column(DateTime(timezone=True), server_default=func.now())
f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now()) f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 新增字段
f99_94_level = Column(String(20), default="青铜") # 会员等级
f99_95_ai_count = Column(Integer, default=0) # ai识别次数
f99_96_search_count = Column(Integer, default=0) # 寻号使用次数
f99_97_collection_count = Column(Integer, default=0) # 藏品数量
f01_06_phone_verified = Column(Boolean, default=False) # 手机号已核验
f99_98_login_count = Column(Integer, default=0) # 登录次数
f99_99_last_login = Column(DateTime(timezone=True), nullable=True) # 最后登录时间
f01_07_gender = Column(String(10), nullable=True) # 性别
f01_08_birthday = Column(Date, nullable=True) # 生日
f01_09_region = Column(String(100), nullable=True) # 地区
f01_10_realname_verified = Column(Boolean, default=False) # 实名认证
f99_100_points = Column(Integer, default=0) # 积分
f01_11_balance = Column(Float, default=0) # 余额
f01_12_total_amount = Column(Float, default=0) # 累计金额
f01_13_invite_code = Column(String(20), nullable=True) # 邀请码(自己的邀请码)
f99_101_invited_count = Column(Integer, default=0) # 通过自己邀请码注册的用户数量
collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan") collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan")
operations = relationship("Operation", back_populates="user", cascade="all, delete-orphan") operations = relationship("Operation", back_populates="user", cascade="all, delete-orphan")
@ -147,122 +130,3 @@ class CustomField(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", back_populates="custom_fields") user = relationship("User", back_populates="custom_fields")
# 资讯模型
class Information(Base):
__tablename__ = "information"
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)
# 信息类型: seek-寻配号, deal-成交数据, publish-发布
info_type = Column(String(20), nullable=False, index=True)
# 标题
title = Column(String(255), nullable=False)
# 内容描述
content = Column(Text, nullable=True)
# 关联藏品ID
collection_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="SET NULL"), nullable=True)
# 期望条件 (寻配号用)
expect_category = Column(String(100), nullable=True) # 期望类别
expect_version = Column(String(100), nullable=True) # 期望版别
expect_packaging = Column(String(100), nullable=True) # 期望包装
expect_number = Column(String(50), nullable=True) # 期望号码
expect_price_min = Column(Float, nullable=True) # 期望价格区间
expect_price_max = Column(Float, nullable=True)
# 成交价格 (成交数据用)
deal_price = Column(Float, nullable=True)
deal_date = Column(Date, nullable=True)
# 评级相关字段
packaging = Column(String(50), nullable=True)
is_graded = Column(Boolean, default=False)
grading_company = Column(String(100), nullable=True)
grading_score = Column(String(50), nullable=True)
category = Column(String(100), nullable=True)
# 行情编号
deal_no = Column(String(50), nullable=True, index=True)
# 状态: active-有效, closed-已关闭, expired-已过期
status = Column(String(20), default="active", index=True)
# 匹配状态: pending-尚未匹配, matched-已经匹配
is_matched = Column(String(20), default="pending", index=True)
# 匹配的用户ID当用户愿意交换联系方式时
matched_user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="SET NULL"), nullable=True)
# 匹配者的联系方式(只有发布者和匹配者可见)
matched_contact = Column(String(100), nullable=True)
# 浏览/联系次数
view_count = Column(Integer, default=0)
contact_count = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
user = relationship("User", foreign_keys=[user_id])
collection = relationship("Collection")
# 资讯评论/留言
class InformationComment(Base):
__tablename__ = "information_comments"
id = Column(String(36), primary_key=True, default=generate_uuid)
information_id = Column(String(36), ForeignKey("information.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
content = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", foreign_keys=[user_id])
# 资讯联系方式查看记录
class InformationContactView(Base):
__tablename__ = "information_contact_views"
id = Column(String(36), primary_key=True, default=generate_uuid)
information_id = Column(String(36), ForeignKey("information.id", ondelete="CASCADE"), nullable=False, index=True)
viewer_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
viewer = relationship("User", foreign_keys=[viewer_id])
# 资讯关联用户 (收藏/点赞)
class InformationLike(Base):
__tablename__ = "information_likes"
id = Column(String(36), primary_key=True, default=generate_uuid)
information_id = Column(String(36), ForeignKey("information.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
UniqueConstraint('information_id', 'user_id', name='uq_information_user'),
)
# 角色常量
class UserRole:
ADMIN = "admin" # 管理员:全部权限
EDITOR = "editor" # 信息员:可发布信息、管理资讯
USER = "user" # 普通用户:基本功能
@classmethod
def get_role_name(cls, role):
names = {
cls.ADMIN: "管理员",
cls.EDITOR: "信息员",
cls.USER: "用户"
}
return names.get(role, "用户")

View File

@ -1,39 +0,0 @@
from sqlalchemy import Column, String, Text, Float, Date, Boolean, Integer, DateTime
from sqlalchemy.sql import func
from app.core.database import Base
import uuid
def generate_uuid():
return str(uuid.uuid4())
class SeekInfo(Base):
__tablename__ = "seek_info"
id = Column(String(36), primary_key=True, default=generate_uuid)
user_id = Column(String(36), nullable=False, index=True)
# 标题和内容
title = Column(String(255), nullable=False)
content = Column(Text, nullable=True)
# 期望条件(求购条件)
expect_category = Column(String(100), nullable=True) # 期望类别
expect_version = Column(String(100), nullable=True) # 期望版别
expect_packaging = Column(String(100), nullable=True) # 期望包装
expect_number = Column(String(50), nullable=True) # 期望号码
expect_price_min = Column(Float, nullable=True) # 期望最低价
expect_price_max = Column(Float, nullable=True) # 期望最高价
# 匹配状态
status = Column(String(20), default="active") # active/closed/expired
is_matched = Column(String(10), default="false") # 是否已匹配
matched_user_id = Column(String(36), nullable=True) # 匹配的用户ID
matched_contact = Column(String(100), nullable=True) # 匹配的联系方式
# 统计
view_count = Column(Integer, default=0)
contact_count = Column(Integer, default=0)
# 时间
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@ -1,489 +1,182 @@
# auth - 认证路由 # 认证路由 - 使用字段编码
# Version: 1.2.90
# 更新:
from fastapi import APIRouter
# Version: 1.2.x
# 更新:
from fastapi import APIRouter, Depends, HTTPException, status, Body from fastapi import APIRouter, Depends, HTTPException, status, Body
# 更新:
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
# 更新:
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
# 更新:
from app.core.database import get_db 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.core.auth import verify_password, create_access_token, get_password_hash, get_current_user
# 更新:
from app.models.models import User from app.models.models import User
# 更新:
from app.schemas.schemas import Token, UserCreate, UserResponse from app.schemas.schemas import Token, UserCreate, UserResponse
# 更新:
# 更新:
router = APIRouter(prefix="/api/auth", tags=["认证"]) router = APIRouter(prefix="/api/auth", tags=["认证"])
# 更新:
# 更新:
# 更新:
# 更新:
# 更新:
def generate_user_code(db): def generate_user_code(db):
# 更新: """生成用户编码从0001开始"""
"""生成用户编码从201开始按自然数顺序递增跳过已存在的"""
# 更新:
# 查找最大的user_code # 查找最大的user_code
# 更新:
max_code = db.query(User.user_code).filter(User.user_code != None).order_by(User.user_code.desc()).first() 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]: if max_code and max_code[0]:
# 更新:
try: try:
# 更新:
num = int(max_code[0]) + 1 num = int(max_code[0]) + 1
# 更新: return f"{num:04d}"
if num < 201:
# 更新:
num = 201
# 更新:
# 检查是否已存在,如果存在则继续递增
# 更新:
while db.query(User).filter(User.user_code == str(num)).first():
# 更新:
num += 1
# 更新:
return str(num)
# 更新:
except: except:
# 更新:
pass pass
# 更新: return "0001"
return "201"
# 更新:
# 更新:
@router.post("/register", response_model=UserResponse) @router.post("/register", response_model=UserResponse)
# 更新:
def register(user_data: UserCreate, db: Session = Depends(get_db)): 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() existing_user = db.query(User).filter(User.f01_01_name == user_data.f01_01_name).first()
# 更新:
if existing_user: if existing_user:
# 更新:
raise HTTPException( raise HTTPException(
# 更新:
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
# 更新:
detail="f01_01_name: 用户名已存在" detail="f01_01_name: 用户名已存在"
# 更新:
) )
# 更新:
# 更新:
# 检查邮箱是否已存在 # 检查邮箱是否已存在
# 更新:
# 更新:
# 检查手机号是否已存在
# 更新:
if user_data.phone:
# 更新:
existing_phone = db.query(User).filter(User.phone == user_data.phone).first()
# 更新:
if existing_phone:
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_400_BAD_REQUEST,
# 更新:
detail="E00040:该手机号已被注册,请更换手机号"
# 更新:
)
# 更新:
if user_data.email: if user_data.email:
# 更新:
existing_email = db.query(User).filter(User.email == user_data.email).first() existing_email = db.query(User).filter(User.email == user_data.email).first()
# 更新:
if existing_email: if existing_email:
# 更新:
raise HTTPException( raise HTTPException(
# 更新:
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
# 更新: detail="邮箱已被注册"
detail="E00041:该邮箱已被注册,请更换邮箱"
# 更新:
) )
# 更新:
# 更新:
# 处理邀请码
# 更新:
invited_by_user = None
# 更新:
if user_data.invite_code:
# 更新:
# 查找邀请人
# 更新:
invited_by_user = db.query(User).filter(User.user_code == user_data.invite_code).first()
# 更新:
if not invited_by_user:
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_400_BAD_REQUEST,
# 更新:
detail="E00042:邀请码无效"
# 更新:
)
# 更新:
# 更新:
# 创建用户 # 创建用户
# 更新:
import uuid import uuid
# 更新:
hashed_password = get_password_hash(user_data.password) hashed_password = get_password_hash(user_data.password)
# 更新:
generated_code = generate_user_code(db)
# 更新:
user = User( user = User(
# 更新:
f99_90_id=str(uuid.uuid4()), f99_90_id=str(uuid.uuid4()),
# 更新:
f99_91_user_id=str(uuid.uuid4()), f99_91_user_id=str(uuid.uuid4()),
# 更新: user_code=generate_user_code(db),
user_code=generated_code,
# 更新:
f01_01_name=user_data.f01_01_name, f01_01_name=user_data.f01_01_name,
# 更新:
email=user_data.email, email=user_data.email,
# 更新:
phone=user_data.phone, phone=user_data.phone,
# 更新:
avatar=user_data.avatar, avatar=user_data.avatar,
# 更新:
address=user_data.address, address=user_data.address,
# 更新:
bio=user_data.bio, bio=user_data.bio,
# 更新:
password=hashed_password, password=hashed_password,
# 更新:
role="user" role="user"
# 更新:
) )
# 更新:
# 更新:
db.add(user) db.add(user)
# 更新:
db.flush() # 确保获取user ID
# 更新:
# 更新:
# 更新邀请人、被邀请人的关联关系
# 更新:
if invited_by_user:
# 更新:
# 记录是被谁邀请的
# 更新:
user.f01_13_invite_code = invited_by_user.user_code
# 更新:
# 增加邀请人的邀请计数
# 更新:
invited_by_user.f99_101_invited_count = (invited_by_user.f99_101_invited_count or 0) + 1
# 更新:
# 更新:
# 生成自己的邀请码用自己的user_code
# 更新:
user.f01_13_invite_code = generated_code
# 更新:
# 更新:
db.commit() db.commit()
# 更新:
db.refresh(user) db.refresh(user)
# 更新:
# 更新: return user
# 返回用户信息避免Pydantic序列化问题
# 更新:
return {
# 更新:
"id": user.f99_90_id,
# 更新:
"username": user.f01_01_name,
# 更新:
"user_code": user.user_code,
# 更新:
"email": user.email,
# 更新:
"phone": user.phone,
# 更新:
"avatar": user.avatar,
# 更新:
"role": user.role,
# 更新:
"level": user.f99_94_level,
# 更新:
"aiCount": user.f99_95_ai_count or 0,
# 更新:
"searchCount": user.f99_96_search_count or 0,
# 更新:
"collectionCount": user.f99_97_collection_count or 0
# 更新:
}
# 更新:
# 更新:
# 更新:
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
# 更新:
def login( def login(
# 更新:
form_data: OAuth2PasswordRequestForm = Depends(), form_data: OAuth2PasswordRequestForm = Depends(),
# 更新:
db: Session = Depends(get_db) db: Session = Depends(get_db)
# 更新:
): ):
# 更新: """用户登录"""
"""用户登录 - 支持用户名或用户编码登录""" # 查找用户
# 更新:
# 先尝试用户名登录
# 更新:
user = db.query(User).filter(User.f01_01_name == form_data.username).first() user = db.query(User).filter(User.f01_01_name == form_data.username).first()
# 更新:
# 如果用户名不存在,尝试用户编码登录
# 更新:
if not user: if not user:
# 更新:
user = db.query(User).filter(User.user_code == form_data.username).first()
# 更新:
if not user:
# 更新:
raise HTTPException( raise HTTPException(
# 更新:
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
# 更新:
detail="E00011: 用户名或密码错误", detail="E00011: 用户名或密码错误",
# 更新:
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
# 更新:
) )
# 更新:
# 更新:
# 验证密码 # 验证密码
# 更新:
if not verify_password(form_data.password, user.password): if not verify_password(form_data.password, user.password):
# 更新:
raise HTTPException( raise HTTPException(
# 更新:
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
# 更新:
detail="E00011: 用户名或密码错误", detail="E00011: 用户名或密码错误",
# 更新:
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
# 更新:
) )
# 更新:
# 更新:
# 更新登录次数和最后登录时间
# 更新:
from datetime import datetime
# 更新:
user.f99_98_login_count = (user.f99_98_login_count or 0) + 1
# 更新:
user.f99_99_last_login = datetime.now()
# 更新:
db.commit()
# 更新:
# 更新:
# 生成 token # 生成 token
# 更新:
access_token = create_access_token(data={"sub": user.f99_90_id}) access_token = create_access_token(data={"sub": user.f99_90_id})
# 更新:
# 更新:
return { return {
# 更新:
"access_token": access_token, "access_token": access_token,
# 更新:
"token_type": "bearer" "token_type": "bearer"
# 更新:
} }
# 更新:
# 更新:
# 更新:
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
# 更新:
def get_current_user_info( def get_current_user_info(
# 更新:
current_user: User = Depends(lambda: None) current_user: User = Depends(lambda: None)
# 更新:
): ):
# 更新:
"""获取当前用户信息""" """获取当前用户信息"""
# 更新:
raise HTTPException( raise HTTPException(
# 更新:
status_code=status.HTTP_501_NOT_IMPLEMENTED, status_code=status.HTTP_501_NOT_IMPLEMENTED,
# 更新:
detail="请使用正确的依赖注入" detail="请使用正确的依赖注入"
# 更新:
) )
# 更新:
# 更新:
# 更新:
@router.post("/change-password") @router.post("/change-password")
# 更新:
def change_password( def change_password(
# 更新:
old_password: str = Body(...), old_password: str = Body(...),
# 更新:
new_password: str = Body(...), new_password: str = Body(...),
# 更新:
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db) db: Session = Depends(get_db)
# 更新:
): ):
# 更新:
"""修改当前用户密码""" """修改当前用户密码"""
# 更新:
from app.core.auth import verify_password, get_password_hash from app.core.auth import verify_password, get_password_hash
# 更新:
# 更新:
# 在当前session中重新查询用户 # 在当前session中重新查询用户
# 更新:
user = db.query(User).filter(User.f99_90_id == current_user.f99_90_id).first() user = db.query(User).filter(User.f99_90_id == current_user.f99_90_id).first()
# 更新:
if not user: if not user:
# 更新:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
# 更新:
# 更新:
# 验证旧密码 # 验证旧密码
# 更新:
if not verify_password(old_password, user.password): if not verify_password(old_password, user.password):
# 更新:
raise HTTPException( raise HTTPException(
# 更新:
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
# 更新:
detail="当前密码错误" detail="当前密码错误"
# 更新:
) )
# 更新:
# 更新:
# 更新密码 # 更新密码
# 更新:
user.password = get_password_hash(new_password) user.password = get_password_hash(new_password)
# 更新:
db.commit() db.commit()
# 更新:
# 更新:
return {"message": "密码修改成功"} return {"message": "密码修改成功"}
# 更新:
# 更新:
# 更新:
# ============ 短信验证码接口 ============ # ============ 短信验证码接口 ============
# 更新:
# 更新:
@router.post("/send-verification-code") @router.post("/send-verification-code")
# 更新:
def send_verification_code( def send_verification_code(
# 更新:
phone: str = Body(..., min_length=11, max_length=11), phone: str = Body(..., min_length=11, max_length=11),
# 更新:
purpose: str = Body("register") # register | login | reset_password purpose: str = Body("register") # register | login | reset_password
# 更新:
): ):
# 更新:
"""发送短信验证码""" """发送短信验证码"""
# 更新:
from app.services.sms import send_verification_code as send_sms from app.services.sms import send_verification_code as send_sms
# 更新:
# 更新:
# 验证手机号格式 # 验证手机号格式
# 更新:
if not phone.startswith("1") or len(phone) != 11: if not phone.startswith("1") or len(phone) != 11:
# 更新:
return {"success": False, "message": "手机号格式不正确"} return {"success": False, "message": "手机号格式不正确"}
# 更新:
# 更新:
result = send_sms(phone) result = send_sms(phone)
# 更新:
# 更新:
if result["success"]: if result["success"]:
# 更新:
return { return {
# 更新:
"success": True, "success": True,
# 更新:
"message": f"验证码已发送到 {phone[:3]}****{phone[7:]}", "message": f"验证码已发送到 {phone[:3]}****{phone[7:]}",
# 更新:
"expire": result.get("expire", 300) "expire": result.get("expire", 300)
# 更新:
} }
# 更新:
else: else:
# 更新:
return result return result
# 更新:
# 更新:
# 更新:
@router.post("/verify-code") @router.post("/verify-code")
# 更新:
def verify_code( def verify_code(
# 更新:
phone: str = Body(...), phone: str = Body(...),
# 更新:
code: str = Body(..., min_length=6, max_length=6) code: str = Body(..., min_length=6, max_length=6)
# 更新:
): ):
# 更新:
"""验证短信验证码(仅验证,不执行后续操作)""" """验证短信验证码(仅验证,不执行后续操作)"""
# 更新:
from app.services.sms import verify_code as check_code from app.services.sms import verify_code as check_code
# 更新:
# 更新:
is_valid = check_code(phone, code) is_valid = check_code(phone, code)
# 更新:
# 更新:
if is_valid: if is_valid:
# 更新:
return {"success": True, "message": "验证成功"} return {"success": True, "message": "验证成功"}
# 更新:
else: else:
# 更新:
return {"success": False, "message": "验证码错误或已过期"} return {"success": False, "message": "验证码错误或已过期"}
# 更新:

File diff suppressed because it is too large Load Diff

View File

@ -1,502 +0,0 @@
# deal - 成交行情路由
# Version: 1.2.85
# 更新:
from fastapi import APIRouter, Depends, Query, HTTPException
# 更新:
# Version: 1.2.x
# 更新:
from sqlalchemy.orm import Session
# 更新:
from pydantic import BaseModel
# 更新:
from typing import Optional
# 更新:
from datetime import datetime, date
# 更新:
from app.core.database import get_db
# 更新:
from app.core.auth import get_current_user
# 更新:
from app.models.deal_info import DealInfo
# 更新:
# 更新:
router = APIRouter(prefix="/api/deal", tags=["成交行情"])
# 更新:
# 更新:
# ============ Schema ============
# 更新:
class DealInfoCreate(BaseModel):
# 更新:
title: str
# 更新:
content: Optional[str] = None
# 更新:
deal_price: Optional[float] = None
# 更新:
deal_date: Optional[str] = None # YYYY-MM-DD
# 更新:
packaging: Optional[str] = None
# 更新:
category: Optional[str] = None
# 更新:
is_graded: Optional[bool] = False
# 更新:
grading_company: Optional[str] = None
# 更新:
grading_score: Optional[str] = None
# 更新:
tail_number: Optional[str] = None
# 更新:
size_type: Optional[str] = None
# 更新:
version: Optional[str] = None
# 更新:
platform: Optional[str] = None
# 更新:
seller: Optional[str] = None
# 更新:
buyer: Optional[str] = None
# 更新:
# 更新:
class DealInfoUpdate(BaseModel):
# 更新:
title: Optional[str] = None
# 更新:
content: Optional[str] = None
# 更新:
deal_price: Optional[float] = None
# 更新:
deal_date: Optional[str] = None
# 更新:
packaging: Optional[str] = None
# 更新:
category: Optional[str] = None
# 更新:
is_graded: Optional[bool] = None
# 更新:
grading_company: Optional[str] = None
# 更新:
grading_score: Optional[str] = None
# 更新:
tail_number: Optional[str] = None
# 更新:
size_type: Optional[str] = None
# 更新:
version: Optional[str] = None
# 更新:
platform: Optional[str] = None
# 更新:
seller: Optional[str] = None
# 更新:
buyer: Optional[str] = None
# 更新:
status: Optional[str] = None
# 更新:
# 更新:
class DealInfoResponse(BaseModel):
# 更新:
id: str
# 更新:
user_id: Optional[str]
# 更新:
title: str
# 更新:
content: Optional[str]
# 更新:
deal_price: Optional[float]
# 更新:
deal_date: Optional[date]
# 更新:
deal_no: Optional[str]
# 更新:
packaging: Optional[str]
# 更新:
category: Optional[str]
# 更新:
is_graded: Optional[bool]
# 更新:
grading_company: Optional[str]
# 更新:
grading_score: Optional[str]
# 更新:
tail_number: Optional[str]
# 更新:
size_type: Optional[str]
# 更新:
version: Optional[str]
# 更新:
platform: Optional[str]
# 更新:
seller: Optional[str]
# 更新:
buyer: Optional[str]
# 更新:
status: str
# 更新:
view_count: int
# 更新:
contact_count: int
# 更新:
created_at: Optional[datetime]
# 更新:
updated_at: Optional[datetime]
# 更新:
# 更新:
class Config:
# 更新:
from_attributes = True
# 更新:
# 更新:
# 生成行情编号
# 更新:
def generate_deal_no(db: Session):
# 更新:
"""生成行情编号从A000001开始递增"""
# 更新:
last = db.query(DealInfo).order_by(DealInfo.deal_no.desc()).first()
# 更新:
if last and last.deal_no:
# 更新:
# 例如 A000001 -> 2 -> A000002
# 更新:
num = int(last.deal_no[1:]) + 1
# 更新:
return f"A{num:06d}"
# 更新:
return "A000001"
# 更新:
# 更新:
# ============ API ============
# 更新:
@router.get("/list", response_model=list[DealInfoResponse])
# 更新:
def get_deal_list(
# 更新:
status: str = Query("active"),
# 更新:
deal_date: Optional[str] = Query(None),
# 更新:
page: int = Query(1, ge=1),
# 更新:
page_size: int = Query(20, ge=1, le=1000),
# 更新:
user_only: bool = Query(False), # 是否只查看自己的
# 更新:
current_user: Optional = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""获取成交行情列表"""
# 更新:
query = db.query(DealInfo).filter(DealInfo.status == status)
# 更新:
# 更新:
# 我的行情:只查看自己的(管理员也只看自己的)
# 更新:
if user_only and current_user:
# 更新:
query = query.filter(DealInfo.user_id == current_user.f99_90_id)
# 更新:
# 更新:
# 成交日期过滤
# 更新:
if deal_date:
# 更新:
query = query.filter(DealInfo.deal_date == deal_date)
# 更新:
# 更新:
# 排序:优先成交日期倒序,同日按编号倒序
# 更新:
query = query.order_by(DealInfo.deal_date.desc().nullslast(), DealInfo.deal_no.desc().nullslast())
# 更新:
# 更新:
# 分页
# 更新:
offset = (page - 1) * page_size
# 更新:
items = query.offset(offset).limit(page_size).all()
# 更新:
# 更新:
return items
# 更新:
# 更新:
@router.get("/stats")
# 更新:
def get_deal_stats(
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""获取成交行情统计"""
# 更新:
total = db.query(DealInfo).filter(DealInfo.status == "active").count()
# 更新:
# 更新:
# 按日期统计
# 更新:
from sqlalchemy import func
# 更新:
date_stats = db.query(
# 更新:
DealInfo.deal_date,
# 更新:
func.count(DealInfo.id).label('count')
# 更新:
).filter(
# 更新:
DealInfo.status == "active",
# 更新:
DealInfo.deal_date.isnot(None)
# 更新:
).group_by(DealInfo.deal_date).order_by(DealInfo.deal_date.desc()).limit(10).all()
# 更新:
# 更新:
return {
# 更新:
"total": total,
# 更新:
"by_date": [{"date": str(d.deal_date), "count": d.count} for d in date_stats]
# 更新:
}
# 更新:
# 更新:
@router.post("", response_model=DealInfoResponse)
# 更新:
def create_deal(
# 更新:
data: DealInfoCreate,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""创建成交行情"""
# 更新:
if not current_user:
# 更新:
raise HTTPException(status_code=401, detail="请先登录")
# 更新:
# 更新:
# 生成行情编号
# 更新:
deal_no = generate_deal_no(db)
# 更新:
# 更新:
# 解析日期
# 更新:
deal_date = None
# 更新:
if data.deal_date:
# 更新:
try:
# 更新:
deal_date = datetime.strptime(data.deal_date, "%Y-%m-%d").date()
# 更新:
except:
# 更新:
pass
# 更新:
# 更新:
deal = DealInfo(
# 更新:
user_id=current_user.f99_90_id if current_user else None,
# 更新:
title=data.title,
# 更新:
content=data.content,
# 更新:
deal_price=data.deal_price,
# 更新:
deal_date=deal_date,
# 更新:
deal_no=deal_no,
# 更新:
packaging=data.packaging,
# 更新:
category=data.category,
# 更新:
is_graded=data.is_graded or False,
# 更新:
grading_company=data.grading_company,
# 更新:
grading_score=data.grading_score,
# 更新:
tail_number=data.tail_number,
# 更新:
size_type=data.size_type,
# 更新:
version=data.version,
# 更新:
platform=data.platform,
# 更新:
seller=data.seller,
# 更新:
buyer=data.buyer,
# 更新:
status="active"
# 更新:
)
# 更新:
db.add(deal)
# 更新:
db.commit()
# 更新:
db.refresh(deal)
# 更新:
return deal
# 更新:
# 更新:
@router.get("/{deal_id}", response_model=DealInfoResponse)
# 更新:
def get_deal(
# 更新:
deal_id: str,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""获取成交行情详情"""
# 更新:
deal = db.query(DealInfo).filter(DealInfo.id == deal_id).first()
# 更新:
if not deal:
# 更新:
raise HTTPException(status_code=404, detail="成交行情不存在")
# 更新:
# 更新:
# 增加浏览数
# 更新:
deal.view_count += 1
# 更新:
db.commit()
# 更新:
# 更新:
return deal
# 更新:
# 更新:
@router.put("/{deal_id}", response_model=DealInfoResponse)
# 更新:
def update_deal(
# 更新:
deal_id: str,
# 更新:
data: DealInfoUpdate,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""更新成交行情"""
# 更新:
if not current_user:
# 更新:
raise HTTPException(status_code=401, detail="请先登录")
# 更新:
# 更新:
deal = db.query(DealInfo).filter(DealInfo.id == deal_id).first()
# 更新:
if not deal:
# 更新:
raise HTTPException(status_code=404, detail="成交行情不存在")
# 更新:
# 更新:
# 处理日期
# 更新:
if data.deal_date:
# 更新:
try:
# 更新:
data.deal_date = datetime.strptime(data.deal_date, "%Y-%m-%d").date()
# 更新:
except:
# 更新:
data.deal_date = None
# 更新:
# 更新:
for key, value in data.model_dump(exclude_unset=True).items():
# 更新:
setattr(deal, key, value)
# 更新:
# 更新:
db.commit()
# 更新:
db.refresh(deal)
# 更新:
return deal
# 更新:
# 更新:
@router.delete("/{deal_id}")
# 更新:
def delete_deal(
# 更新:
deal_id: str,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""删除成交行情"""
# 更新:
if not current_user:
# 更新:
raise HTTPException(status_code=401, detail="请先登录")
# 更新:
# 更新:
deal = db.query(DealInfo).filter(DealInfo.id == deal_id).first()
# 更新:
if not deal:
# 更新:
raise HTTPException(status_code=404, detail="成交行情不存在")
# 更新:
# 更新:
deal.status = "deleted"
# 更新:
db.commit()
# 更新:
# 更新:
return {"message": "删除成功"}
# 更新:

File diff suppressed because it is too large Load Diff

View File

@ -1,262 +0,0 @@
# news - 新闻路由
# Version: 1.2.80
# 更新:
from fastapi import APIRouter, Depends, HTTPException, Query
# 更新:
# Version: 1.2.x
# 更新:
from sqlalchemy import Table, MetaData
# 更新:
from sqlalchemy.orm import Session
# 更新:
from pydantic import BaseModel
# 更新:
from typing import Optional, List
# 更新:
from datetime import datetime, date
# 更新:
from app.core.database import get_db, engine
# 更新:
from app.models.models import User
# 更新:
from app.routers.auth import get_current_user
# 更新:
# 更新:
router = APIRouter(prefix="/api/news", tags=["资讯"])
# 更新:
metadata = MetaData()
# 更新:
# 更新:
# 分类表
# 更新:
categories_table = Table('news_categories', metadata, autoload_with=engine)
# 更新:
news_table = Table('news', metadata, autoload_with=engine)
# 更新:
user_posts_table = Table('user_posts', metadata, autoload_with=engine)
# 更新:
users_table = Table('users', metadata, autoload_with=engine)
# 更新:
deals_table = Table('deals', metadata, autoload_with=engine)
# 更新:
notifications_table = Table('notifications', metadata, autoload_with=engine)
# 更新:
# 更新:
# ============ 获取分类 ============
# 更新:
@router.get("/categories")
# 更新:
def get_categories(db: Session = Depends(get_db)):
# 更新:
results = db.query(categories_table).order_by(categories_table.c.sort_order).all()
# 更新:
return [dict(r._mapping) for r in results]
# 更新:
# 更新:
# ============ 获取资讯 ============
# 更新:
@router.get("")
# 更新:
def get_news(
# 更新:
category_id: Optional[int] = None,
# 更新:
page: int = 1,
# 更新:
limit: int = 20,
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
query = db.query(news_table)
# 更新:
if category_id:
# 更新:
query = query.filter(news_table.c.category_id == category_id)
# 更新:
offset = (page - 1) * limit
# 更新:
results = query.order_by(news_table.c.created_at.desc()).offset(offset).limit(limit).all()
# 更新:
return [dict(r._mapping) for r in results]
# 更新:
# 更新:
# ============ 获取用户发布 ============
# 更新:
@router.get("/posts")
# 更新:
def get_posts(
# 更新:
post_type: Optional[str] = None,
# 更新:
status: str = "active",
# 更新:
page: int = 1,
# 更新:
limit: int = 20,
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
query = db.query(user_posts_table).filter(user_posts_table.c.status == status)
# 更新:
if post_type:
# 更新:
query = query.filter(user_posts_table.c.post_type == post_type)
# 更新:
offset = (page - 1) * limit
# 更新:
results = query.order_by(user_posts_table.c.created_at.desc()).offset(offset).limit(limit).all()
# 更新:
return [dict(r._mapping) for r in results]
# 更新:
# 更新:
# ============ 创建发布 ============
# 更新:
class PostCreate(BaseModel):
# 更新:
post_type: str
# 更新:
title: str
# 更新:
content: Optional[str] = None
# 更新:
zodiac_type: Optional[str] = None
# 更新:
packaging: Optional[str] = None
# 更新:
# 更新:
@router.post("/posts")
# 更新:
def create_post(
# 更新:
post: PostCreate,
# 更新:
current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
result = db.execute(user_posts_table.insert().values(
# 更新:
user_id=current_user.f99_90_id,
# 更新:
post_type=post.post_type,
# 更新:
title=post.title,
# 更新:
content=post.content,
# 更新:
zodiac_type=post.zodiac_type,
# 更新:
packaging=post.packaging,
# 更新:
status="pending"
# 更新:
))
# 更新:
db.commit()
# 更新:
return {"success": True, "id": result.inserted_primary_key[0]}
# 更新:
# 更新:
# ============ 成交数据 ============
# 更新:
@router.get("/deals")
# 更新:
def get_deals(
# 更新:
zodiac_type: Optional[str] = None,
# 更新:
limit: int = 20,
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
query = db.query(deals_table)
# 更新:
if zodiac_type:
# 更新:
query = query.filter(deals_table.c.zodiac_type == zodiac_type)
# 更新:
results = query.order_by(deals_table.c.deal_date.desc()).limit(limit).all()
# 更新:
return [dict(r._mapping) for r in results]
# 更新:
# 更新:
# ============ 通知 ============
# 更新:
@router.get("/notifications")
# 更新:
def get_notifications(limit: int = 10, db: Session = Depends(get_db)):
# 更新:
results = db.query(notifications_table).filter(
# 更新:
notifications_table.c.is_published == True
# 更新:
).order_by(notifications_table.c.created_at.desc()).limit(limit).all()
# 更新:
return [dict(r._mapping) for r in results]
# 更新:
# 更新:
# ============ 首页数据 ============
# 更新:
@router.get("/home")
# 更新:
def get_home(db: Session = Depends(get_db)):
# 更新:
# 推荐发布
# 更新:
posts = db.query(user_posts_table).filter(
# 更新:
user_posts_table.c.status == "active"
# 更新:
).order_by(user_posts_table.c.created_at.desc()).limit(10).all()
# 更新:
# 更新:
# 成交
# 更新:
deals = db.query(deals_table).order_by(
# 更新:
deals_table.c.deal_date.desc()
# 更新:
).limit(10).all()
# 更新:
# 更新:
# 通知
# 更新:
notices = db.query(notifications_table).filter(
# 更新:
notifications_table.c.is_published == True
# 更新:
).order_by(notifications_table.c.created_at.desc()).limit(5).all()
# 更新:
# 更新:
return {
# 更新:
"posts": [dict(p._mapping) for p in posts],
# 更新:
"deals": [dict(d._mapping) for d in deals],
# 更新:
"notices": [dict(n._mapping) for n in notices]
# 更新:
}
# 更新:

View File

@ -1,770 +1,376 @@
# ocr - OCR识别路由 # OCR 识别路由 - 专业人民币生肖纪念钞鉴定
# Version: 1.2.75
# 更新:
import os import os
# 更新:
import uuid import uuid
# 更新:
import base64 import base64
# 更新:
import httpx import httpx
# 更新:
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
# 更新:
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
# 更新:
from app.core.database import get_db from app.core.database import get_db
# 更新:
from app.core.auth import get_current_user from app.core.auth import get_current_user
# 更新:
from app.models.models import User from app.models.models import User
# 更新:
# 更新:
router = APIRouter(prefix="/api/ocr", tags=["OCR 识别"]) router = APIRouter(prefix="/api/ocr", tags=["OCR 识别"])
# 更新:
# 更新:
# 阿里云 DashScope API 配置 # 阿里云 DashScope API 配置
# 更新:
DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "sk-9389024a37da4f7bb455ac9a6b28776f") DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "sk-9389024a37da4f7bb455ac9a6b28776f")
# 更新:
# 更新:
# 阿里云 OSS 配置 # 阿里云 OSS 配置
# 更新:
OSS_CONFIG = { OSS_CONFIG = {
# 更新:
"access_key_id": os.getenv("OSS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"), "access_key_id": os.getenv("OSS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"),
# 更新:
"access_key_secret": os.getenv("OSS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"), "access_key_secret": os.getenv("OSS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"),
# 更新:
"bucket_name": "jiachenlong-oss", "bucket_name": "jiachenlong-oss",
# 更新:
"endpoint": "oss-cn-hangzhou.aliyuncs.com", "endpoint": "oss-cn-hangzhou.aliyuncs.com",
# 更新:
"public_url": "https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com" "public_url": "https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com"
# 更新:
} }
# 更新:
# 更新:
# 临时上传目录用于OCR识别本地备选 # 临时上传目录用于OCR识别本地备选
# 更新:
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "temp") UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "temp")
# 更新:
os.makedirs(UPLOAD_DIR, exist_ok=True) 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): def get_oss_path(file_type: str, user_id: str = None, collection_id: str = None, filename: str = None):
# 更新:
"""生成OSS路径 - 按年/月/日分类""" """生成OSS路径 - 按年/月/日分类"""
# 更新:
from datetime import datetime from datetime import datetime
# 更新:
now = datetime.now() now = datetime.now()
# 更新:
year = now.strftime("%Y") year = now.strftime("%Y")
# 更新:
month = now.strftime("%m") month = now.strftime("%m")
# 更新:
day = now.strftime("%d") day = now.strftime("%d")
# 更新:
# 更新:
if file_type == "temp": if file_type == "temp":
# 更新:
# 临时文件: temp/{year}/{month}/{day}/{uuid}.{ext} # 临时文件: temp/{year}/{month}/{day}/{uuid}.{ext}
# 更新:
import uuid import uuid
# 更新:
unique_id = str(uuid.uuid4()) unique_id = str(uuid.uuid4())
# 更新:
ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg' ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg'
# 更新:
return f"temp/{year}/{month}/{day}/{unique_id}.{ext}", unique_id return f"temp/{year}/{month}/{day}/{unique_id}.{ext}", unique_id
# 更新:
# 更新:
elif file_type == "collection": elif file_type == "collection":
# 更新:
# 藏品文件: collections/{user_id}/{year}/{collection_id}/{filename} # 藏品文件: collections/{user_id}/{year}/{collection_id}/{filename}
# 更新:
if not user_id or not collection_id: if not user_id or not collection_id:
# 更新:
raise ValueError("user_id and collection_id required for collection") raise ValueError("user_id and collection_id required for collection")
# 更新:
return f"collections/{user_id}/{year}/{collection_id}/{filename}" return f"collections/{user_id}/{year}/{collection_id}/{filename}"
# 更新:
# 更新:
elif file_type == "avatar": elif file_type == "avatar":
# 更新:
# 头像: avatars/{user_id}/avatar.{ext} # 头像: avatars/{user_id}/avatar.{ext}
# 更新:
if not user_id: if not user_id:
# 更新:
raise ValueError("user_id required for avatar") raise ValueError("user_id required for avatar")
# 更新:
ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg' ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg'
# 更新:
return f"avatars/{user_id}/avatar.{ext}" return f"avatars/{user_id}/avatar.{ext}"
# 更新:
# 更新:
return None return None
# 更新:
# 更新:
# 更新:
# 上传图片到OSS - 使用服务层(带压缩) # 上传图片到OSS - 使用服务层(带压缩)
# 更新:
from app.services.oss import upload_to_oss as oss_upload from app.services.oss import upload_to_oss as oss_upload
# 更新:
# 更新:
def upload_to_oss(file_data, oss_key): def upload_to_oss(file_data, oss_key):
# 更新:
"""上传文件到阿里云OSS带自动压缩""" """上传文件到阿里云OSS带自动压缩"""
# 更新:
return oss_upload(file_data, oss_key) return oss_upload(file_data, oss_key)
# 更新:
# 更新:
# 专业提示词 # 专业提示词
# 更新:
PROFESSIONAL_PROMPT = """你是一名专业的人民币生肖纪念钞鉴定专家。请严格按照以下步骤分析这张图片,并提取准确信息。 PROFESSIONAL_PROMPT = """你是一名专业的人民币生肖纪念钞鉴定专家。请严格按照以下步骤分析这张图片,并提取准确信息。
# 更新:
# 更新:
识别流程 识别流程
# 更新:
1. 判断类型是否评级钞首先确认是否为裸钞还是评级钞有封装盒和标签 1. 判断类型是否评级钞首先确认是否为裸钞还是评级钞有封装盒和标签
# 更新:
2. 验证纪念钞特征对照生肖纪念钞特征进行确认 2. 验证纪念钞特征对照生肖纪念钞特征进行确认
# 更新:
3. 验证评级类型'标十'字眼的为标十'百连'字眼的为标百其他为单张 3. 验证评级类型'标十'字眼的为标十'百连'字眼的为标百其他为单张
# 更新:
4. 提取信息仔细阅读标签上的所有文字内容 4. 提取信息仔细阅读标签上的所有文字内容
# 更新:
# 更新:
版别格式要求 版别格式要求
# 更新:
只需要年份 + 属相例如 只需要年份 + 属相例如
# 更新:
- 2024 - 2024
# 更新:
- 2025 - 2025
# 更新:
- 2026 - 2026
# 更新:
# 更新:
输出要求 输出要求
# 更新:
严格按照以下格式输出每个字段必须填写具体值 严格按照以下格式输出每个字段必须填写具体值
# 更新:
1 发行机构中国人民银行 1 发行机构中国人民银行
# 更新:
2 发行版别2024 2 发行版别2024
# 更新:
3 面额贰拾圆 3 面额贰拾圆
# 更新:
4 是否评级/ 4 是否评级/
# 更新:
5 封装类型裸钞/单张/标十/标百 5 封装类型裸钞/单张/标十/标百
# 更新:
6 冠字序号J0xxxxxxxx 6 冠字序号J0xxxxxxxx
# 更新:
7 评级机构ACG/PCGS/PMG 7 评级机构ACG/PCGS/PMG
# 更新:
8 评级分数67/68/69 8 评级分数67/68/69
# 更新:
9 是否三星/ 9 是否三星/
# 更新:
10 特殊标识金山标/天马标/红绳版等 10 特殊标识金山标/天马标/红绳版等
# 更新:
11 号码特征金山号 2 天马号 3 张等 11 号码特征金山号 2 天马号 3 张等
# 更新:
# 更新:
现在请仔细分析提供的图片按上述格式输出结果""" 现在请仔细分析提供的图片按上述格式输出结果"""
# 更新:
# 更新:
# 更新:
@router.post("/recognize") @router.post("/recognize")
# 更新:
async def recognize_image( async def recognize_image(
# 更新:
image: UploadFile = File(...), image: UploadFile = File(...),
# 更新:
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db) db: Session = Depends(get_db)
# 更新:
): ):
# 更新:
"""OCR 图片识别 - 识别后自动保存图片到OSS临时目录""" """OCR 图片识别 - 识别后自动保存图片到OSS临时目录"""
# 更新:
try: try:
# 更新:
# 读取图片数据 # 读取图片数据
# 更新:
image_data = await image.read() image_data = await image.read()
# 更新:
image_base64 = base64.b64encode(image_data).decode('utf-8') image_base64 = base64.b64encode(image_data).decode('utf-8')
# 更新:
# 更新:
# 生成OSS临时路径: temp/{year}/{month}/{day}/{uuid}.{ext} # 生成OSS临时路径: temp/{year}/{month}/{day}/{uuid}.{ext}
# 更新:
oss_key, temp_id = get_oss_path("temp", filename=image.filename) oss_key, temp_id = get_oss_path("temp", filename=image.filename)
# 更新:
# 更新:
# 初始化temp_path为空
# 更新:
temp_path = None
# 更新:
# 更新:
# 上传到OSS # 上传到OSS
# 更新:
try: try:
# 更新:
image_url = upload_to_oss(image_data, oss_key) image_url = upload_to_oss(image_data, oss_key)
# 更新:
except Exception as oss_err: except Exception as oss_err:
# 更新:
# OSS失败时保存到本地作为备选 # OSS失败时保存到本地作为备选
# 更新:
temp_path = os.path.join(UPLOAD_DIR, oss_key.split('/')[-1]) temp_path = os.path.join(UPLOAD_DIR, oss_key.split('/')[-1])
# 更新:
os.makedirs(os.path.dirname(temp_path), exist_ok=True) os.makedirs(os.path.dirname(temp_path), exist_ok=True)
# 更新:
with open(temp_path, 'wb') as f: with open(temp_path, 'wb') as f:
# 更新:
f.write(image_data) f.write(image_data)
# 更新:
image_url = f"/uploads/temp/{oss_key.split('/')[-1]}" image_url = f"/uploads/temp/{oss_key.split('/')[-1]}"
# 更新:
# 更新:
headers = { headers = {
# 更新:
"Authorization": f"Bearer {DASHSCOPE_API_KEY}", "Authorization": f"Bearer {DASHSCOPE_API_KEY}",
# 更新:
"Content-Type": "application/json" "Content-Type": "application/json"
# 更新:
} }
# 更新:
# 更新:
# 阿里云 DashScope API 格式 (qwen-vl-plus 视觉模型) # 阿里云 DashScope API 格式 (qwen-vl-plus 视觉模型)
# 更新:
payload = { payload = {
# 更新:
"model": "qwen-vl-plus", "model": "qwen-vl-plus",
# 更新:
"input": { "input": {
# 更新:
"messages": [{ "messages": [{
# 更新:
"role": "user", "role": "user",
# 更新:
"content": [ "content": [
# 更新:
{ {
# 更新:
"image": f"data:{image.content_type};base64,{image_base64}" "image": f"data:{image.content_type};base64,{image_base64}"
# 更新:
}, },
# 更新:
{ {
# 更新:
"text": PROFESSIONAL_PROMPT "text": PROFESSIONAL_PROMPT
# 更新:
} }
# 更新:
] ]
# 更新:
}] }]
# 更新:
}, },
# 更新:
"parameters": { "parameters": {
# 更新:
"max_tokens": 1000 "max_tokens": 1000
# 更新:
} }
# 更新:
} }
# 更新:
# 更新:
async with httpx.AsyncClient(timeout=60.0) as client: async with httpx.AsyncClient(timeout=60.0) as client:
# 更新:
response = await client.post( response = await client.post(
# 更新:
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
# 更新:
json=payload, json=payload,
# 更新:
headers=headers headers=headers
# 更新:
) )
# 更新:
# 更新:
if response.status_code != 200: if response.status_code != 200:
# 更新:
# 识别失败,删除临时文件 # 识别失败,删除临时文件
# 更新: if os.path.exists(temp_path):
if temp_path and os.path.exists(temp_path):
# 更新:
os.remove(temp_path) os.remove(temp_path)
# 更新:
raise HTTPException(status_code=500, detail=f"OCR API 调用失败:{response.text[:200]}") raise HTTPException(status_code=500, detail=f"OCR API 调用失败:{response.text[:200]}")
# 更新:
# 更新:
ocr_result = response.json() ocr_result = response.json()
# 更新:
text_content = "" text_content = ""
# 更新:
# 新版API返回格式 # 新版API返回格式
# 更新:
if "output" in ocr_result and "choices" in ocr_result["output"]: if "output" in ocr_result and "choices" in ocr_result["output"]:
# 更新:
choices = ocr_result["output"]["choices"] choices = ocr_result["output"]["choices"]
# 更新:
if choices and len(choices) > 0: if choices and len(choices) > 0:
# 更新:
content = choices[0].get("message", {}).get("content", []) content = choices[0].get("message", {}).get("content", [])
# 更新:
if content and len(content) > 0: if content and len(content) > 0:
# 更新:
text_content = content[0].get("text", "") text_content = content[0].get("text", "")
# 更新:
# 更新:
fields = extract_fields(text_content) fields = extract_fields(text_content)
# 更新:
# 更新:
# 更新用户AI识别次数
# 更新:
current_user.f99_95_ai_count = (current_user.f99_95_ai_count or 0) + 1
# 更新:
db.commit()
# 更新:
# 更新:
# 返回识别结果和临时图片路径 # 返回识别结果和临时图片路径
# 更新:
return { return {
# 更新:
"success": True, "success": True,
# 更新:
"text": text_content, "text": text_content,
# 更新:
"fields": fields, "fields": fields,
# 更新:
"aiCount": current_user.f99_95_ai_count,
# 更新:
"temp_image": { "temp_image": {
# 更新:
"id": temp_id, "id": temp_id,
# 更新:
"filename": oss_key.split('/')[-1], "filename": oss_key.split('/')[-1],
# 更新:
"path": image_url, "path": image_url,
# 更新:
"original_name": image.filename, "original_name": image.filename,
# 更新:
"is_oss": image_url.startswith("https://") "is_oss": image_url.startswith("https://")
# 更新:
} }
# 更新:
} }
# 更新:
# 更新:
except Exception as e: except Exception as e:
# 更新:
import traceback import traceback
# 更新:
error_detail = f"识别失败:{str(e)}\n{traceback.format_exc()}" error_detail = f"识别失败:{str(e)}\n{traceback.format_exc()}"
# 更新:
raise HTTPException(status_code=500, detail=error_detail) raise HTTPException(status_code=500, detail=error_detail)
# 更新:
# 更新:
# 更新:
def extract_fields(text: str) -> dict: def extract_fields(text: str) -> dict:
# 更新:
"""从 OCR 文本中提取字段 - 直接返回 AI 识别结果""" """从 OCR 文本中提取字段 - 直接返回 AI 识别结果"""
# 更新:
import re import re
# 更新:
fields = {} fields = {}
# 更新:
# 更新:
# 解析结构化输出 # 解析结构化输出
# 更新:
patterns = { patterns = {
# 更新:
'issuer': r'✅.*?1.*?发行机构.*?[:]\s*(.+?)(?:\n|$)', 'issuer': r'✅.*?1.*?发行机构.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'version': r'✅.*?2.*?发行版别.*?[:]\s*(.+?)(?:\n|$)', 'version': r'✅.*?2.*?发行版别.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'denomination': r'✅.*?3.*?面额.*?[:]\s*(.+?)(?:\n|$)', 'denomination': r'✅.*?3.*?面额.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'is_graded_text': r'✅.*?4.*?是否评级.*?[:]\s*(.+?)(?:\n|$)', 'is_graded_text': r'✅.*?4.*?是否评级.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'packaging': r'✅.*?5.*?封装类型.*?[:]\s*(.+?)(?:\n|$)', 'packaging': r'✅.*?5.*?封装类型.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'prefix_serial': r'✅.*?6.*?冠字序号.*?[:]\s*(.+?)(?:\n|$)', 'prefix_serial': r'✅.*?6.*?冠字序号.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'grading_company': r'✅.*?7.*?评级机构.*?[:]\s*(.+?)(?:\n|$)', 'grading_company': r'✅.*?7.*?评级机构.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'grading_score': r'✅.*?8.*?评级分数.*?[:]\s*(.+?)(?:\n|$)', 'grading_score': r'✅.*?8.*?评级分数.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'three_star_text': r'✅.*?9.*?是否三星.*?[:]\s*(.+?)(?:\n|$)', 'three_star_text': r'✅.*?9.*?是否三星.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'special_mark': r'✅.*?10.*?特殊标识.*?[:]\s*(.+?)(?:\n|$)', 'special_mark': r'✅.*?10.*?特殊标识.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'serial_feature': r'✅.*?11.*?号码特征.*?[:]\s*(.+?)(?:\n|$)' 'serial_feature': r'✅.*?11.*?号码特征.*?[:]\s*(.+?)(?:\n|$)'
# 更新:
} }
# 更新:
# 更新:
for field, pattern in patterns.items(): for field, pattern in patterns.items():
# 更新:
match = re.search(pattern, text, re.IGNORECASE | re.DOTALL) match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
# 更新:
if match: if match:
# 更新:
value = match.group(1).strip() value = match.group(1).strip()
# 更新:
# 保留所有值,包括"无"和"未识别",让前端处理 # 保留所有值,包括"无"和"未识别",让前端处理
# 更新:
fields[field] = value fields[field] = value
# 更新:
# 更新:
# 处理是否评级 # 处理是否评级
# 更新:
if 'is_graded_text' in fields: if 'is_graded_text' in fields:
# 更新:
fields['is_graded'] = '' in fields.pop('is_graded_text') fields['is_graded'] = '' in fields.pop('is_graded_text')
# 更新:
# 更新:
# 处理是否三星 # 处理是否三星
# 更新:
if 'three_star_text' in fields: if 'three_star_text' in fields:
# 更新:
fields['three_star'] = '' in fields.pop('three_star_text') fields['three_star'] = '' in fields.pop('three_star_text')
# 更新:
# 更新:
# 简化版别字段2024 龙年贺岁纪念钞(标十) → 2024 龙) # 简化版别字段2024 龙年贺岁纪念钞(标十) → 2024 龙)
# 更新:
if 'version' in fields: if 'version' in fields:
# 更新:
version = fields['version'] version = fields['version']
# 更新:
# 提取年份和生肖 # 提取年份和生肖
# 更新:
year_match = re.search(r'(20\d{2})', version) year_match = re.search(r'(20\d{2})', version)
# 更新:
animal = '' animal = ''
# 更新:
if '' in version: if '' in version:
# 更新:
animal = '' animal = ''
# 更新:
elif '' in version: elif '' in version:
# 更新:
animal = '' animal = ''
# 更新:
elif '' in version: elif '' in version:
# 更新:
animal = '' animal = ''
# 更新:
elif '' in version: elif '' in version:
# 更新:
animal = '' animal = ''
# 更新:
elif '' in version: elif '' in version:
# 更新:
animal = '' animal = ''
# 更新:
elif '' in version: elif '' in version:
# 更新:
animal = '' animal = ''
# 更新:
elif '' in version: elif '' in version:
# 更新:
animal = '' animal = ''
# 更新:
elif '' in version: elif '' in version:
# 更新:
animal = '' animal = ''
# 更新:
elif '' in version: elif '' in version:
# 更新:
animal = '' animal = ''
# 更新:
elif '' in version: elif '' in version:
# 更新:
animal = '' animal = ''
# 更新:
elif '' in version: elif '' in version:
# 更新:
animal = '' animal = ''
# 更新:
elif '' in version: elif '' in version:
# 更新:
animal = '' animal = ''
# 更新:
# 更新:
if year_match and animal: if year_match and animal:
# 更新:
fields['version'] = f"{year_match.group(1)}{animal}" fields['version'] = f"{year_match.group(1)}{animal}"
# 更新:
# 更新:
return fields return fields
# 更新:
# 更新:
# 更新:
@router.post("/claim-temp-image") @router.post("/claim-temp-image")
# 更新:
async def claim_temp_image( async def claim_temp_image(
# 更新:
temp_id: str, temp_id: str,
# 更新:
collection_id: str, collection_id: str,
# 更新:
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db) db: Session = Depends(get_db)
# 更新:
): ):
# 更新:
"""将临时图片移动到正式藏品目录 - 按用户/年/藏品ID分类""" """将临时图片移动到正式藏品目录 - 按用户/年/藏品ID分类"""
# 更新:
from app.models.models import Collection, CollectionImage from app.models.models import Collection, CollectionImage
# 更新:
from datetime import datetime from datetime import datetime
# 更新:
# 更新:
# 验证藏品是否存在 # 验证藏品是否存在
# 更新:
collection = db.query(Collection).filter( collection = db.query(Collection).filter(
# 更新:
Collection.f99_90_id == collection_id, Collection.f99_90_id == collection_id,
# 更新:
Collection.f99_91_user_id == current_user.f99_90_id Collection.f99_91_user_id == current_user.f99_90_id
# 更新:
).first() ).first()
# 更新:
# 更新:
if not collection: if not collection:
# 更新:
raise HTTPException(status_code=404, detail="藏品不存在") raise HTTPException(status_code=404, detail="藏品不存在")
# 更新:
# 更新:
# 生成正式文件名和OSS路径: collections/{user_id}/{year}/{collection_id}/{filename} # 生成正式文件名和OSS路径: collections/{user_id}/{year}/{collection_id}/{filename}
# 更新:
code = collection.f01_02_code or "0000" code = collection.f01_02_code or "0000"
# 更新:
prefix = collection.f02_10_prefix_serial or "" prefix = collection.f02_10_prefix_serial or ""
# 更新:
username = current_user.f01_01_name username = current_user.f01_01_name
# 更新:
import time import time
# 更新:
final_filename = f"{username}-{code}-{prefix}-{int(time.time())}.jpg" 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_key = get_oss_path("collection", user_id=current_user.f99_90_id, collection_id=collection_id, filename=final_filename)
# 更新:
# 更新:
# 尝试从OSS获取临时图片 - 尝试多种扩展名和日期路径 # 尝试从OSS获取临时图片 - 尝试多种扩展名和日期路径
# 更新:
temp_extensions = ['jpg', 'jpeg', 'png', 'gif', 'JPG', 'JPEG', 'PNG', 'GIF'] temp_extensions = ['jpg', 'jpeg', 'png', 'gif', 'JPG', 'JPEG', 'PNG', 'GIF']
# 更新:
temp_content = None temp_content = None
# 更新:
found_key = None found_key = None
# 更新:
# 更新:
# 尝试最近7天的路径 # 尝试最近7天的路径
# 更新:
from datetime import timedelta from datetime import timedelta
# 更新:
for i in range(7): for i in range(7):
# 更新:
date = datetime.now() - timedelta(days=i) date = datetime.now() - timedelta(days=i)
# 更新:
year = date.strftime("%Y") year = date.strftime("%Y")
# 更新:
month = date.strftime("%m") month = date.strftime("%m")
# 更新:
day = date.strftime("%d") day = date.strftime("%d")
# 更新:
# 更新:
for ext in temp_extensions: for ext in temp_extensions:
# 更新:
try: try:
# 更新:
temp_oss_key = f"temp/{year}/{month}/{day}/{temp_id}.{ext}" temp_oss_key = f"temp/{year}/{month}/{day}/{temp_id}.{ext}"
# 更新:
import oss2 import oss2
# 更新:
auth = oss2.Auth(OSS_CONFIG["access_key_id"], OSS_CONFIG["access_key_secret"]) auth = oss2.Auth(OSS_CONFIG["access_key_id"], OSS_CONFIG["access_key_secret"])
# 更新:
bucket = oss2.Bucket(auth, OSS_CONFIG["endpoint"], OSS_CONFIG["bucket_name"]) bucket = oss2.Bucket(auth, OSS_CONFIG["endpoint"], OSS_CONFIG["bucket_name"])
# 更新:
temp_content = bucket.get_object(temp_oss_key).read() temp_content = bucket.get_object(temp_oss_key).read()
# 更新:
found_key = temp_oss_key found_key = temp_oss_key
# 更新:
break break
# 更新:
except: except:
# 更新:
continue continue
# 更新:
if temp_content: if temp_content:
# 更新:
break break
# 更新:
# 更新:
if temp_content: if temp_content:
# 更新:
# 上传到正式目录 # 上传到正式目录
# 更新:
bucket.put_object(oss_key, temp_content) bucket.put_object(oss_key, temp_content)
# 更新:
# 更新:
# 删除临时图片 # 删除临时图片
# 更新:
try: try:
# 更新:
bucket.delete_object(found_key) bucket.delete_object(found_key)
# 更新:
except: except:
# 更新:
pass pass
# 更新:
# 更新:
# OSS URL # OSS URL
# 更新:
image_path = f"{OSS_CONFIG['public_url']}/{oss_key}" image_path = f"{OSS_CONFIG['public_url']}/{oss_key}"
# 更新:
# 更新:
else: else:
# 更新:
# OSS失败使用本地文件 # OSS失败使用本地文件
# 更新:
temp_path = None temp_path = None
# 更新:
for ext in temp_extensions: for ext in temp_extensions:
# 更新:
temp_path = os.path.join(UPLOAD_DIR, f"{temp_id}.{ext}") temp_path = os.path.join(UPLOAD_DIR, f"{temp_id}.{ext}")
# 更新:
if os.path.exists(temp_path): if os.path.exists(temp_path):
# 更新:
break break
# 更新:
# 更新:
if not temp_path or not os.path.exists(temp_path): if not temp_path or not os.path.exists(temp_path):
# 更新:
raise HTTPException(status_code=404, detail="临时图片不存在或已过期") raise HTTPException(status_code=404, detail="临时图片不存在或已过期")
# 更新:
# 更新:
# 保存到本地 # 保存到本地
# 更新:
collection_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "collections") collection_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "collections")
# 更新:
os.makedirs(collection_dir, exist_ok=True) os.makedirs(collection_dir, exist_ok=True)
# 更新:
# 更新:
new_path = os.path.join(collection_dir, final_filename) new_path = os.path.join(collection_dir, final_filename)
# 更新:
import shutil import shutil
# 更新:
shutil.move(temp_path, new_path) shutil.move(temp_path, new_path)
# 更新:
image_path = f"uploads/collections/{final_filename}" image_path = f"uploads/collections/{final_filename}"
# 更新:
# 更新:
# 创建图片记录 # 创建图片记录
# 更新:
image_record = CollectionImage( image_record = CollectionImage(
# 更新:
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
# 更新:
collection_id=collection.f99_90_id, collection_id=collection.f99_90_id,
# 更新:
filename=final_filename, filename=final_filename,
# 更新:
original_name=temp_id, original_name=temp_id,
# 更新:
path=image_path path=image_path
# 更新:
) )
# 更新:
db.add(image_record) db.add(image_record)
# 更新:
db.commit() db.commit()
# 更新:
# 更新:
return { return {
# 更新:
"success": True, "success": True,
# 更新:
"image": { "image": {
# 更新:
"id": image_record.id, "id": image_record.id,
# 更新:
"filename": image_record.filename, "filename": image_record.filename,
# 更新:
"path": image_record.path "path": image_record.path
# 更新:
} }
# 更新:
} }
# 更新:

View File

@ -1,210 +1,104 @@
# operations - 运营操作路由 # 操作路由
# Version: 1.2.70
# 更新:
from typing import List, Optional from typing import List, Optional
# 更新:
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
# 更新:
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
# 更新:
from app.core.database import get_db from app.core.database import get_db
# 更新:
from app.core.auth import get_current_user from app.core.auth import get_current_user
# 更新:
from app.models.models import User, Collection, Operation from app.models.models import User, Collection, Operation
# 更新:
from app.schemas.schemas import OperationCreate, OperationResponse from app.schemas.schemas import OperationCreate, OperationResponse
# 更新:
# 更新:
router = APIRouter(prefix="/api", tags=["操作"]) router = APIRouter(prefix="/api", tags=["操作"])
# 更新:
# 更新:
# 更新:
@router.get("/operations", response_model=List[OperationResponse]) @router.get("/operations", response_model=List[OperationResponse])
# 更新:
def get_operations( def get_operations(
# 更新:
collection_id: Optional[str] = None, collection_id: Optional[str] = None,
# 更新:
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
# 更新:
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
# 更新:
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db) db: Session = Depends(get_db)
# 更新:
): ):
# 更新:
"""获取操作历史""" """获取操作历史"""
# 更新:
query = db.query(Operation).filter(Operation.user_id == current_user.id) query = db.query(Operation).filter(Operation.user_id == current_user.id)
# 更新:
# 更新:
if collection_id: if collection_id:
# 更新:
query = query.filter(Operation.collection_id == collection_id) query = query.filter(Operation.collection_id == collection_id)
# 更新:
# 更新:
operations = query.order_by(Operation.created_at.desc()) \ operations = query.order_by(Operation.created_at.desc()) \
# 更新:
.offset((page - 1) * limit) \ .offset((page - 1) * limit) \
# 更新:
.limit(limit) \ .limit(limit) \
# 更新:
.all() .all()
# 更新:
# 更新:
return operations return operations
# 更新:
# 更新:
# 更新:
@router.get("/operations/history") @router.get("/operations/history")
# 更新:
def get_operation_history( def get_operation_history(
# 更新:
collection_id: Optional[str] = None, collection_id: Optional[str] = None,
# 更新:
type: Optional[str] = None, type: Optional[str] = None,
# 更新:
start_date: Optional[str] = None, start_date: Optional[str] = None,
# 更新:
end_date: Optional[str] = None, end_date: Optional[str] = None,
# 更新:
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
# 更新:
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
# 更新:
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db) db: Session = Depends(get_db)
# 更新:
): ):
# 更新:
"""获取操作历史(带统计)""" """获取操作历史(带统计)"""
# 更新:
query = db.query(Operation).filter(Operation.user_id == current_user.id) query = db.query(Operation).filter(Operation.user_id == current_user.id)
# 更新:
# 更新:
if collection_id: if collection_id:
# 更新:
query = query.filter(Operation.collection_id == collection_id) query = query.filter(Operation.collection_id == collection_id)
# 更新:
if type: if type:
# 更新:
query = query.filter(Operation.type == type) query = query.filter(Operation.type == type)
# 更新:
if start_date: if start_date:
# 更新:
query = query.filter(Operation.created_at >= start_date) query = query.filter(Operation.created_at >= start_date)
# 更新:
if end_date: if end_date:
# 更新:
query = query.filter(Operation.created_at <= end_date) query = query.filter(Operation.created_at <= end_date)
# 更新:
# 更新:
total = query.count() total = query.count()
# 更新:
# 更新:
data = query.order_by(Operation.created_at.desc()) \ data = query.order_by(Operation.created_at.desc()) \
# 更新:
.offset((page - 1) * limit) \ .offset((page - 1) * limit) \
# 更新:
.limit(limit) \ .limit(limit) \
# 更新:
.all() .all()
# 更新:
# 更新:
return { return {
# 更新:
"data": data, "data": data,
# 更新:
"pagination": { "pagination": {
# 更新:
"page": page, "page": page,
# 更新:
"limit": limit, "limit": limit,
# 更新:
"total": total, "total": total,
# 更新:
"pages": (total + limit - 1) // limit "pages": (total + limit - 1) // limit
# 更新:
} }
# 更新:
} }
# 更新:
# 更新:
# 更新:
@router.post("/operations", response_model=OperationResponse) @router.post("/operations", response_model=OperationResponse)
# 更新:
def create_operation( def create_operation(
# 更新:
operation_data: OperationCreate, operation_data: OperationCreate,
# 更新:
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db) db: Session = Depends(get_db)
# 更新:
): ):
# 更新:
"""创建操作记录""" """创建操作记录"""
# 更新:
# 验证藏品存在 # 验证藏品存在
# 更新:
collection = db.query(Collection).filter( collection = db.query(Collection).filter(
# 更新:
Collection.id == operation_data.collection_id, Collection.id == operation_data.collection_id,
# 更新:
Collection.user_id == current_user.id Collection.user_id == current_user.id
# 更新:
).first() ).first()
# 更新:
# 更新:
if not collection: if not collection:
# 更新:
raise HTTPException(status_code=404, detail="藏品不存在") raise HTTPException(status_code=404, detail="藏品不存在")
# 更新:
# 更新:
operation = Operation( operation = Operation(
# 更新:
collection_id=operation_data.collection_id, collection_id=operation_data.collection_id,
# 更新:
user_id=current_user.id, user_id=current_user.id,
# 更新:
type=operation_data.type, type=operation_data.type,
# 更新:
price=operation_data.price, price=operation_data.price,
# 更新:
note=operation_data.note note=operation_data.note
# 更新:
) )
# 更新:
# 更新:
db.add(operation) db.add(operation)
# 更新:
db.commit() db.commit()
# 更新:
db.refresh(operation) db.refresh(operation)
# 更新:
# 更新:
return operation return operation
# 更新:

View File

@ -1,384 +0,0 @@
# seek - 寻号匹配路由
# Version: 1.2.70
# 更新:
from fastapi import APIRouter, Depends, Query, HTTPException
# 更新:
# Version: 1.2.x
# 更新:
from sqlalchemy.orm import Session
# 更新:
from pydantic import BaseModel
# 更新:
from typing import Optional
# 更新:
from datetime import datetime
# 更新:
from app.core.database import get_db
# 更新:
from app.core.auth import get_current_user
# 更新:
from app.models.seek_info import SeekInfo
# 更新:
# 更新:
router = APIRouter(prefix="/api/seek", tags=["寻配号"])
# 更新:
# 更新:
# ============ Schema ============
# 更新:
class SeekInfoCreate(BaseModel):
# 更新:
title: str
# 更新:
content: Optional[str] = None
# 更新:
expect_category: Optional[str] = None
# 更新:
expect_version: Optional[str] = None
# 更新:
expect_packaging: Optional[str] = None
# 更新:
expect_number: Optional[str] = None
# 更新:
expect_price_min: Optional[float] = None
# 更新:
expect_price_max: Optional[float] = None
# 更新:
# 更新:
class SeekInfoUpdate(BaseModel):
# 更新:
title: Optional[str] = None
# 更新:
content: Optional[str] = None
# 更新:
expect_category: Optional[str] = None
# 更新:
expect_version: Optional[str] = None
# 更新:
expect_packaging: Optional[str] = None
# 更新:
expect_number: Optional[str] = None
# 更新:
expect_price_min: Optional[float] = None
# 更新:
expect_price_max: Optional[float] = None
# 更新:
status: Optional[str] = None
# 更新:
# 更新:
class SeekInfoResponse(BaseModel):
# 更新:
id: str
# 更新:
user_id: str
# 更新:
title: str
# 更新:
content: Optional[str]
# 更新:
expect_category: Optional[str]
# 更新:
expect_version: Optional[str]
# 更新:
expect_packaging: Optional[str]
# 更新:
expect_number: Optional[str]
# 更新:
expect_price_min: Optional[float]
# 更新:
expect_price_max: Optional[float]
# 更新:
status: str
# 更新:
is_matched: Optional[str]
# 更新:
matched_user_id: Optional[str]
# 更新:
matched_contact: Optional[str]
# 更新:
view_count: int
# 更新:
contact_count: int
# 更新:
created_at: Optional[datetime]
# 更新:
updated_at: Optional[datetime]
# 更新:
# 更新:
class Config:
# 更新:
from_attributes = True
# 更新:
# 更新:
# ============ API ============
# 更新:
@router.get("/list", response_model=list[SeekInfoResponse])
# 更新:
def get_seek_list(
# 更新:
status: str = Query("active"),
# 更新:
page: int = Query(1, ge=1),
# 更新:
page_size: int = Query(20, ge=1, le=1000),
# 更新:
user_only: bool = Query(False),
# 更新:
current_user: Optional = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""获取寻配号列表"""
# 更新:
query = db.query(SeekInfo).filter(SeekInfo.status == status)
# 更新:
# 更新:
# 我的寻配号:只查看自己的
# 更新:
if user_only and current_user:
# 更新:
query = query.filter(SeekInfo.user_id == current_user.f99_90_id)
# 更新:
# 更新:
# 排序
# 更新:
query = query.order_by(SeekInfo.created_at.desc())
# 更新:
# 更新:
# 分页
# 更新:
offset = (page - 1) * page_size
# 更新:
items = query.offset(offset).limit(page_size).all()
# 更新:
# 更新:
return items
# 更新:
# 更新:
@router.get("/stats")
# 更新:
def get_seek_stats(
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""获取寻配号统计"""
# 更新:
total = db.query(SeekInfo).filter(SeekInfo.status == "active").count()
# 更新:
matched = db.query(SeekInfo).filter(SeekInfo.is_matched == "true").count()
# 更新:
# 更新:
return {
# 更新:
"total": total,
# 更新:
"matched": matched,
# 更新:
"unmatched": total - matched
# 更新:
}
# 更新:
# 更新:
@router.post("", response_model=SeekInfoResponse)
# 更新:
def create_seek(
# 更新:
data: SeekInfoCreate,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""创建寻配号"""
# 更新:
if not current_user:
# 更新:
raise HTTPException(status_code=401, detail="请先登录")
# 更新:
# 更新:
seek = SeekInfo(
# 更新:
user_id=current_user.f99_90_id,
# 更新:
title=data.title,
# 更新:
content=data.content,
# 更新:
expect_category=data.expect_category,
# 更新:
expect_version=data.expect_version,
# 更新:
expect_packaging=data.expect_packaging,
# 更新:
expect_number=data.expect_number,
# 更新:
expect_price_min=data.expect_price_min,
# 更新:
expect_price_max=data.expect_price_max,
# 更新:
status="active"
# 更新:
)
# 更新:
db.add(seek)
# 更新:
db.commit()
# 更新:
db.refresh(seek)
# 更新:
return seek
# 更新:
# 更新:
@router.get("/{seek_id}", response_model=SeekInfoResponse)
# 更新:
def get_seek(
# 更新:
seek_id: str,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""获取寻配号详情"""
# 更新:
seek = db.query(SeekInfo).filter(SeekInfo.id == seek_id).first()
# 更新:
if not seek:
# 更新:
raise HTTPException(status_code=404, detail="寻配号不存在")
# 更新:
# 更新:
# 增加浏览数
# 更新:
seek.view_count += 1
# 更新:
db.commit()
# 更新:
# 更新:
return seek
# 更新:
# 更新:
@router.put("/{seek_id}", response_model=SeekInfoResponse)
# 更新:
def update_seek(
# 更新:
seek_id: str,
# 更新:
data: SeekInfoUpdate,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""更新寻配号"""
# 更新:
if not current_user:
# 更新:
raise HTTPException(status_code=401, detail="请先登录")
# 更新:
# 更新:
seek = db.query(SeekInfo).filter(
# 更新:
SeekInfo.id == seek_id,
# 更新:
SeekInfo.user_id == current_user.f99_90_id
# 更新:
).first()
# 更新:
# 更新:
if not seek:
# 更新:
raise HTTPException(status_code=404, detail="寻配号不存在")
# 更新:
# 更新:
for key, value in data.model_dump(exclude_unset=True).items():
# 更新:
setattr(seek, key, value)
# 更新:
# 更新:
db.commit()
# 更新:
db.refresh(seek)
# 更新:
return seek
# 更新:
# 更新:
@router.delete("/{seek_id}")
# 更新:
def delete_seek(
# 更新:
seek_id: str,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""删除寻配号"""
# 更新:
if not current_user:
# 更新:
raise HTTPException(status_code=401, detail="请先登录")
# 更新:
# 更新:
seek = db.query(SeekInfo).filter(
# 更新:
SeekInfo.id == seek_id,
# 更新:
SeekInfo.user_id == current_user.f99_90_id
# 更新:
).first()
# 更新:
# 更新:
if not seek:
# 更新:
raise HTTPException(status_code=404, detail="寻配号不存在")
# 更新:
# 更新:
seek.status = "deleted"
# 更新:
db.commit()
# 更新:
# 更新:
return {"message": "删除成功"}
# 更新:

View File

@ -1,7 +1,4 @@
# users.py - 用户管理路由 # 用户管理路由
# Version: 1.2.98 (2026-04-19)
# 更新:新增 dealCount 字段,从 Information 表统计用户发布的行情数量
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -14,14 +11,12 @@ router = APIRouter(prefix="/api", tags=["用户"])
# ============ 当前用户接口 ============ # ============ 当前用户接口 ============
@router.get("/users/me") # 无 response_model避免 Pydantic 序列化问题 @router.get("/users/me", response_model=UserResponse)
def get_current_user_info( def get_current_user_info(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""获取当前登录用户信息""" """获取当前登录用户信息"""
return { return {
"id": current_user.f99_90_id,
"username": current_user.f01_01_name,
"f99_90_id": current_user.f99_90_id, "f99_90_id": current_user.f99_90_id,
"f01_01_name": current_user.f01_01_name, "f01_01_name": current_user.f01_01_name,
"email": current_user.email, "email": current_user.email,
@ -30,27 +25,11 @@ def get_current_user_info(
"address": current_user.address, "address": current_user.address,
"bio": current_user.bio, "bio": current_user.bio,
"role": current_user.role, "role": current_user.role,
"level": current_user.f99_94_level,
"aiCount": current_user.f99_95_ai_count or 0,
"searchCount": current_user.f99_96_search_count or 0,
"collectionCount": current_user.f99_97_collection_count or 0,
"phoneVerified": current_user.f01_06_phone_verified or False,
"loginCount": current_user.f99_98_login_count or 0,
"lastLogin": current_user.f99_99_last_login.isoformat() if current_user.f99_99_last_login else None,
"gender": current_user.f01_07_gender,
"birthday": current_user.f01_08_birthday.isoformat() if current_user.f01_08_birthday else None,
"region": current_user.f01_09_region,
"realnameVerified": current_user.f01_10_realname_verified or False,
"points": current_user.f99_100_points or 0,
"balance": float(current_user.f01_11_balance) if current_user.f01_11_balance else 0,
"totalAmount": float(current_user.f01_12_total_amount) if current_user.f01_12_total_amount else 0,
"inviteCode": current_user.f01_13_invite_code,
"user_code": current_user.user_code,
"f99_92_created_at": current_user.f99_92_created_at.isoformat() if current_user.f99_92_created_at else None, "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 "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避免 Pydantic 序列化问题 @router.put("/users/me", response_model=UserResponse)
def update_current_user( def update_current_user(
user_update: UserUpdate, user_update: UserUpdate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@ -100,7 +79,7 @@ def get_users(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""获取用户列表(仅管理员)""" """获取用户列表(仅管理员)"""
if current_user.role not in ["admin", "editor"]: if current_user.role != "admin":
raise HTTPException(status_code=403, detail="无权访问") raise HTTPException(status_code=403, detail="无权访问")
total = db.query(User).count() total = db.query(User).count()
@ -117,11 +96,6 @@ def get_users(
for u in users: for u in users:
# 统计每个用户的藏品数量 # 统计每个用户的藏品数量
count = db.query(Collection).filter(Collection.f99_91_user_id == u.f99_90_id).count() count = db.query(Collection).filter(Collection.f99_91_user_id == u.f99_90_id).count()
# 统计每个用户发布的行情数量info_type='deal'
deal_count = db.query(Information).filter(
Information.user_id == u.f99_90_id,
Information.info_type == "deal"
).count()
user_list.append({ user_list.append({
"id": u.f99_90_id, "id": u.f99_90_id,
"username": u.f01_01_name, "username": u.f01_01_name,
@ -130,16 +104,7 @@ def get_users(
"role": u.role, "role": u.role,
"user_code": u.user_code, "user_code": u.user_code,
"created_at": u.f99_92_created_at.isoformat() if u.f99_92_created_at else None, "created_at": u.f99_92_created_at.isoformat() if u.f99_92_created_at else None,
"collectionCount": count, "collection_count": count
"dealCount": deal_count,
"level": u.f99_94_level,
"aiCount": u.f99_95_ai_count,
"searchCount": u.f99_96_search_count,
"loginCount": u.f99_98_login_count,
"points": u.f99_100_points,
"balance": float(u.f01_11_balance) if u.f01_11_balance else 0,
"totalAmount": float(u.f01_12_total_amount) if u.f01_12_total_amount else 0,
"phoneVerified": u.f01_06_phone_verified,
}) })
return user_list return user_list
@ -152,7 +117,7 @@ def get_user(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""获取单个用户信息""" """获取单个用户信息"""
if current_user.role not in ["admin", "editor"]: if current_user.role != "admin":
raise HTTPException(status_code=403, detail="无权访问") raise HTTPException(status_code=403, detail="无权访问")
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
@ -177,7 +142,7 @@ def get_user_collections(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""获取指定用户的藏品列表""" """获取指定用户的藏品列表"""
if current_user.role not in ["admin", "editor"]: if current_user.role != "admin":
raise HTTPException(status_code=403, detail="无权访问") raise HTTPException(status_code=403, detail="无权访问")
collections = db.query(Collection).filter( collections = db.query(Collection).filter(
@ -194,7 +159,7 @@ def get_user_collection_count(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""获取指定用户的藏品数量""" """获取指定用户的藏品数量"""
if current_user.role not in ["admin", "editor"]: if current_user.role != "admin":
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问") raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
count = db.query(Collection).filter(Collection.user_id == user_id).count() count = db.query(Collection).filter(Collection.user_id == user_id).count()
@ -209,17 +174,11 @@ def update_user(
role: Optional[str] = Body(None), role: Optional[str] = Body(None),
password: Optional[str] = Body(None), password: Optional[str] = Body(None),
user_code: Optional[str] = Body(None), user_code: Optional[str] = Body(None),
level: Optional[str] = Body(None),
points: Optional[int] = Body(None),
balance: Optional[float] = Body(None),
totalAmount: Optional[float] = Body(None),
aiCount: Optional[int] = Body(None),
searchCount: Optional[int] = Body(None),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""更新用户信息(仅管理员)""" """更新用户信息(仅管理员)"""
if current_user.role not in ["admin", "editor"]: if current_user.role != "admin":
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问") raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
user = db.query(User).filter(User.f99_90_id == user_id).first() user = db.query(User).filter(User.f99_90_id == user_id).first()
@ -234,30 +193,6 @@ def update_user(
if role is not None: if role is not None:
user.role = role user.role = role
# 更新会员等级
if level is not None:
user.f99_94_level = level
# 更新积分
if points is not None:
user.f99_100_points = points
# 更新余额
if balance is not None:
user.f01_11_balance = balance
# 更新累计金额
if totalAmount is not None:
user.f01_12_total_amount = totalAmount
# 更新AI识别次数
if aiCount is not None:
user.f99_95_ai_count = aiCount
# 更新寻号次数
if searchCount is not None:
user.f99_96_search_count = searchCount
# 更新用户编码 # 更新用户编码
if user_code is not None: if user_code is not None:
# 只有非空字符串才检查唯一性 # 只有非空字符串才检查唯一性
@ -297,7 +232,7 @@ def delete_user(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""删除用户(仅管理员)""" """删除用户(仅管理员)"""
if current_user.role not in ["admin", "editor"]: if current_user.role != "admin":
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问") raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
# 不能删除自己 # 不能删除自己

View File

@ -1,760 +0,0 @@
# yichens - 一尘数据路由
# Version: 1.2.70
# 更新:
from fastapi import APIRouter, Depends, Query
# 更新:
# Version: 1.2.x
# 更新:
from sqlalchemy import func, text
# 更新:
from sqlalchemy.orm import Session
# 更新:
from pydantic import BaseModel
# 更新:
from typing import Optional, List
# 更新:
from datetime import datetime, date
# 更新:
from app.core.coolbot_db import get_coolbot_db
# 更新:
# 更新:
router = APIRouter(prefix="/api/yichens", tags=["一尘看板"])
# 更新:
# 更新:
# ============ 数据模型 ============
# 更新:
class YichensPostStats(BaseModel):
# 更新:
total_posts: int
# 更新:
total_deals: int # 出售
# 更新:
total_wants: int # 求购
# 更新:
total_replies: int
# 更新:
total_views: int
# 更新:
avg_price: Optional[float]
# 更新:
# 更新:
class CategoryStat(BaseModel):
# 更新:
category: str
# 更新:
count: int
# 更新:
# 更新:
class PostItem(BaseModel):
# 更新:
post_id: str
# 更新:
title: str
# 更新:
category: Optional[str]
# 更新:
post_type: str
# 更新:
price: Optional[float]
# 更新:
author_username: str
# 更新:
post_time: str
# 更新:
reply_count: int
# 更新:
view_count: int
# 更新:
url: Optional[str]
# 更新:
content: Optional[str]
# 更新:
# 更新:
class UserStat(BaseModel):
# 更新:
total_users: int
# 更新:
new_users_today: int
# 更新:
sellers: int
# 更新:
# 更新:
class UserItem(BaseModel):
# 更新:
user_id: str
# 更新:
username: str
# 更新:
avatar_url: Optional[str]
# 更新:
content: Optional[str]
# 更新:
credit_level: Optional[str]
# 更新:
credit_score: Optional[int]
# 更新:
post_count: int
# 更新:
is_seller: bool
# 更新:
registration_date: Optional[str]
# 更新:
# 更新:
# ============ 统计接口 ============
# 更新:
@router.get("/stats/posts", response_model=YichensPostStats)
# 更新:
def get_post_stats(days: int = Query(30, ge=1, le=365), db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取帖子统计"""
# 更新:
result = db.execute(text("""
# 更新:
SELECT
# 更新:
COUNT(*) as total_posts,
# 更新:
COUNT(*) FILTER (WHERE post_type = 'deal') as total_deals,
# 更新:
COUNT(*) FILTER (WHERE post_type = 'want') as total_wants,
# 更新:
COALESCE(SUM(reply_count), 0) as total_replies,
# 更新:
COALESCE(SUM(view_count), 0) as total_views,
# 更新:
AVG(price) as avg_price
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= NOW() - INTERVAL '1 day' * :days
# 更新:
"""), {"days": days}).fetchone()
# 更新:
# 更新:
return YichensPostStats(
# 更新:
total_posts=result[0] or 0,
# 更新:
total_deals=result[1] or 0,
# 更新:
total_wants=result[2] or 0,
# 更新:
total_replies=result[3] or 0,
# 更新:
total_views=result[4] or 0,
# 更新:
avg_price=float(result[5]) if result[5] else None
# 更新:
)
# 更新:
# 更新:
@router.get("/stats/categories", response_model=List[CategoryStat])
# 更新:
def get_category_stats(days: int = Query(30, ge=1, le=365), db: Session = Depends(get_coolbot_db)):
# 更新:
"""按分类统计帖子数量"""
# 更新:
results = db.execute(text("""
# 更新:
SELECT category, COUNT(*) as count
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= NOW() - INTERVAL '1 day' * :days
# 更新:
GROUP BY category
# 更新:
ORDER BY count DESC
# 更新:
"""), {"days": days}).fetchall()
# 更新:
# 更新:
return [CategoryStat(category=r[0] or "未知", count=r[1]) for r in results]
# 更新:
# 更新:
@router.get("/stats/users", response_model=UserStat)
# 更新:
def get_user_stats(db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取用户统计"""
# 更新:
result = db.execute(text("""
# 更新:
SELECT
# 更新:
COUNT(*) as total_users,
# 更新:
COUNT(*) FILTER (WHERE created_at::date = CURRENT_DATE) as new_users_today,
# 更新:
COUNT(*) FILTER (WHERE is_seller = true) as sellers
# 更新:
FROM yichens_users
# 更新:
""")).fetchone()
# 更新:
# 更新:
return UserStat(
# 更新:
total_users=result[0] or 0,
# 更新:
new_users_today=result[1] or 0,
# 更新:
sellers=result[2] or 0
# 更新:
)
# 更新:
# 更新:
@router.get("/posts")
# 更新:
def get_posts(
# 更新:
limit: int = Query(20, ge=1, le=500),
# 更新:
offset: int = Query(0, ge=0),
# 更新:
category: Optional[str] = None,
# 更新:
post_type: Optional[str] = None,
# 更新:
keyword: Optional[str] = None,
# 更新:
db: Session = Depends(get_coolbot_db)
# 更新:
):
# 更新:
"""获取帖子列表 - 支持全局搜索,返回总数和分页信息"""
# 更新:
# 构建WHERE条件
# 更新:
where_clauses = ["1=1"]
# 更新:
params = {"limit": limit, "offset": offset}
# 更新:
# 更新:
if category:
# 更新:
where_clauses.append("category = :category")
# 更新:
params["category"] = category
# 更新:
# 更新:
if post_type:
# 更新:
where_clauses.append("post_type = :post_type")
# 更新:
params["post_type"] = post_type
# 更新:
# 更新:
# 全局搜索
# 更新:
if keyword:
# 更新:
where_clauses.append("(title ILIKE :keyword OR content ILIKE :keyword OR category ILIKE :keyword OR author_username ILIKE :keyword)")
# 更新:
params["keyword"] = f"%{keyword}%"
# 更新:
# 更新:
where_sql = " AND ".join(where_clauses)
# 更新:
# 更新:
# 查询总数
# 更新:
count_query = f"SELECT COUNT(*) FROM yichens_posts WHERE {where_sql}"
# 更新:
total_count = db.execute(text(count_query), {k: v for k, v in params.items() if k not in ['limit', 'offset']}).scalar() or 0
# 更新:
# 更新:
# 查询数据 - 有post_time时按post_time排序没有时按crawled_at排序
# 更新:
data_query = f"""
# 更新:
SELECT post_id, title, content, category, post_type, price,
# 更新:
author_username, post_time, reply_count, view_count, url
# 更新:
FROM yichens_posts
# 更新:
WHERE {where_sql}
# 更新:
ORDER BY COALESCE(post_time, crawled_at) DESC LIMIT :limit OFFSET :offset
# 更新:
"""
# 更新:
results = db.execute(text(data_query), params).fetchall()
# 更新:
# 更新:
posts = [PostItem(
# 更新:
post_id=r[0],
# 更新:
title=r[1] or "",
# 更新:
content=r[2] or "",
# 更新:
category=r[3],
# 更新:
post_type=r[4] or "",
# 更新:
price=float(r[5]) if r[5] else None,
# 更新:
author_username=r[6] or "",
# 更新:
post_time=str(r[7]) if r[7] else "",
# 更新:
reply_count=r[8] or 0,
# 更新:
view_count=r[9] or 0,
# 更新:
url=r[10]
# 更新:
) for r in results]
# 更新:
# 更新:
return {
# 更新:
"posts": posts,
# 更新:
"total": total_count,
# 更新:
"page": offset // limit + 1,
# 更新:
"page_size": limit
# 更新:
}
# 更新:
# 更新:
@router.get("/users", response_model=List[UserItem])
# 更新:
def get_users(
# 更新:
limit: int = Query(20, ge=1, le=500),
# 更新:
offset: int = Query(0, ge=0),
# 更新:
is_seller: Optional[bool] = None,
# 更新:
db: Session = Depends(get_coolbot_db)
# 更新:
):
# 更新:
"""获取用户列表"""
# 更新:
query = """
# 更新:
SELECT user_id, username, avatar_url, credit_level, credit_score,
# 更新:
post_count, is_seller, registration_date
# 更新:
FROM yichens_users
# 更新:
WHERE 1=1
# 更新:
"""
# 更新:
params = {"limit": limit, "offset": offset}
# 更新:
# 更新:
if is_seller is not None:
# 更新:
query += " AND is_seller = :is_seller"
# 更新:
params["is_seller"] = is_seller
# 更新:
# 更新:
query += " ORDER BY post_count DESC LIMIT :limit OFFSET :offset"
# 更新:
# 更新:
results = db.execute(text(query), params).fetchall()
# 更新:
# 更新:
return [UserItem(
# 更新:
user_id=r[0],
# 更新:
username=r[1] or "",
# 更新:
avatar_url=r[2],
# 更新:
credit_level=r[3],
# 更新:
credit_score=r[4],
# 更新:
post_count=r[5] or 0,
# 更新:
is_seller=r[6] or False,
# 更新:
registration_date=str(r[7]) if r[7] else None
# 更新:
) for r in results]
# 更新:
# 更新:
# 更新:
@router.get("/stats/today")
# 更新:
async def get_today_stats(db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取今日新增帖子统计"""
# 更新:
query = """
# 更新:
SELECT
# 更新:
COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
# 更新:
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants,
# 更新:
SUM(CASE WHEN post_type = 'normal' THEN 1 ELSE 0 END) as others,
# 更新:
SUM(CASE WHEN category LIKE '%%' THEN 1 ELSE 0 END) as dragons,
# 更新:
SUM(CASE WHEN category LIKE '%%' THEN 1 ELSE 0 END) as horses,
# 更新:
SUM(CASE WHEN category LIKE '%%' THEN 1 ELSE 0 END) as snakes,
# 更新:
SUM(CASE WHEN title LIKE '%天马%' OR content LIKE '%天马%' THEN 1 ELSE 0 END) as tianma
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE
# 更新:
"""
# 更新:
result = db.execute(text(query)).fetchone()
# 更新:
return {
# 更新:
"total": result[0] or 0,
# 更新:
"deals": result[1] or 0,
# 更新:
"wants": result[2] or 0,
# 更新:
"others": result[3] or 0,
# 更新:
"dragons": result[4] or 0,
# 更新:
"horses": result[5] or 0,
# 更新:
"snakes": result[6] or 0,
# 更新:
"tianma": result[7] or 0
# 更新:
}
# 更新:
# 更新:
@router.get("/stats/hour")
# 更新:
async def get_hour_stats(db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取近一个小时新增帖子统计"""
# 更新:
query = """
# 更新:
SELECT
# 更新:
COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
# 更新:
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants,
# 更新:
SUM(CASE WHEN post_type = 'normal' THEN 1 ELSE 0 END) as others,
# 更新:
SUM(CASE WHEN category LIKE '%%' THEN 1 ELSE 0 END) as dragons,
# 更新:
SUM(CASE WHEN category LIKE '%%' THEN 1 ELSE 0 END) as horses,
# 更新:
SUM(CASE WHEN category LIKE '%%' THEN 1 ELSE 0 END) as snakes
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= NOW() - INTERVAL '1 hour'
# 更新:
"""
# 更新:
result = db.execute(text(query)).fetchone()
# 更新:
return {
# 更新:
"total": result[0] or 0,
# 更新:
"deals": result[1] or 0,
# 更新:
"wants": result[2] or 0,
# 更新:
"others": result[3] or 0,
# 更新:
"dragons": result[3] or 0,
# 更新:
"horses": result[4] or 0,
# 更新:
"snakes": result[5] or 0
# 更新:
}
# 更新:
# 更新:
@router.get("/stats/today-category")
# 更新:
async def get_today_category_stats(db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取今日帖子分类统计"""
# 更新:
query = """
# 更新:
SELECT category, COUNT(*) as count
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE
# 更新:
GROUP BY category
# 更新:
ORDER BY count DESC
# 更新:
"""
# 更新:
results = db.execute(text(query)).fetchall()
# 更新:
return [{"category": r[0] or "未分类", "count": r[1]} for r in results]
# 更新:
# 更新:
# 更新:
@router.get("/stats/dragons-today")
# 更新:
def get_dragons_stats_today(
# 更新:
db: Session = Depends(get_coolbot_db)
# 更新:
):
# 更新:
"""获取今日龙钞详细统计数据(按号码分类)- 就高不就低"""
# 更新:
from sqlalchemy import text
# 更新:
# 更新:
# 1. 带4包含"带4"、"带四"、"通货"
# 更新:
dai4 = db.execute(text("""
# 更新:
SELECT
# 更新:
COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
# 更新:
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE
# 更新:
AND category LIKE '%%'
# 更新:
AND (
# 更新:
content LIKE '%带4%' OR title LIKE '%带4%'
# 更新:
OR content LIKE '%带四%' OR title LIKE '%带四%'
# 更新:
OR content LIKE '%通货%' OR title LIKE '%通货%'
# 更新:
)
# 更新:
""")).fetchone()
# 更新:
# 更新:
# 2. 无4包含"无4"、"无四",排除"无47"、"无247"、"无四七"、"永恒"
# 更新:
wu4 = db.execute(text("""
# 更新:
SELECT
# 更新:
COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
# 更新:
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE
# 更新:
AND category LIKE '%%'
# 更新:
AND (
# 更新:
content LIKE '%无4%' OR title LIKE '%无4%'
# 更新:
OR content LIKE '%无四%' OR title LIKE '%无四%'
# 更新:
)
# 更新:
AND content NOT LIKE '%无47%' AND title NOT LIKE '%无47%'
# 更新:
AND content NOT LIKE '%无247%' AND title NOT LIKE '%无247%'
# 更新:
AND content NOT LIKE '%永恒%' AND title NOT LIKE '%永恒%'
# 更新:
AND content NOT LIKE '%无四七%' AND title NOT LIKE '%无四七%'
# 更新:
""")).fetchone()
# 更新:
# 更新:
# 3. 无47包含"无47"、"永恒"、"无四七",排除"无247"
# 更新:
wu47 = db.execute(text("""
# 更新:
SELECT
# 更新:
COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
# 更新:
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE
# 更新:
AND category LIKE '%%'
# 更新:
AND (
# 更新:
content LIKE '%无47%' OR title LIKE '%无47%'
# 更新:
OR content LIKE '%永恒%' OR title LIKE '%永恒%'
# 更新:
OR content LIKE '%无四七%' OR title LIKE '%无四七%'
# 更新:
)
# 更新:
AND content NOT LIKE '%无247%' AND title NOT LIKE '%无247%'
# 更新:
""")).fetchone()
# 更新:
# 更新:
# 4. 无247包含"无247"、"天马"、"金山",排除"无347"
# 更新:
wu247 = db.execute(text("""
# 更新:
SELECT
# 更新:
COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
# 更新:
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE
# 更新:
AND category LIKE '%%'
# 更新:
AND (
# 更新:
content LIKE '%无247%' OR title LIKE '%无247%'
# 更新:
OR content LIKE '%天马%' OR title LIKE '%天马%'
# 更新:
OR content LIKE '%金山%' OR title LIKE '%金山%'
# 更新:
)
# 更新:
AND content NOT LIKE '%无347%' AND title NOT LIKE '%无347%'
# 更新:
""")).fetchone()
# 更新:
# 更新:
# 5. 无347包含"无347"、"钻石"、"金马"、"魅力"、"朦胧"
# 更新:
wu347 = db.execute(text("""
# 更新:
SELECT
# 更新:
COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
# 更新:
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE
# 更新:
AND category LIKE '%%'
# 更新:
AND (
# 更新:
content LIKE '%无347%' OR title LIKE '%无347%'
# 更新:
OR content LIKE '%钻石%' OR title LIKE '%钻石%'
# 更新:
OR content LIKE '%金马%' OR title LIKE '%金马%'
# 更新:
OR content LIKE '%魅力%' OR title LIKE '%魅力%'
# 更新:
OR content LIKE '%朦胧%' OR title LIKE '%朦胧%'
# 更新:
)
# 更新:
""")).fetchone()
# 更新:
# 更新:
return {
# 更新:
"dai4": {"total": dai4[0] or 0, "deals": dai4[1] or 0, "wants": dai4[2] or 0},
# 更新:
"wu4": {"total": wu4[0] or 0, "deals": wu4[1] or 0, "wants": wu4[2] or 0},
# 更新:
"wu47": {"total": wu47[0] or 0, "deals": wu47[1] or 0, "wants": wu47[2] or 0},
# 更新:
"wu247": {"total": wu247[0] or 0, "deals": wu247[1] or 0, "wants": wu247[2] or 0},
# 更新:
"wu347": {"total": wu347[0] or 0, "deals": wu347[1] or 0, "wants": wu347[2] or 0}
# 更新:
}
# 更新:
# 更新:

View File

@ -19,7 +19,6 @@ class UserBase(BaseModel):
class UserCreate(UserBase): class UserCreate(UserBase):
password: str = Field(..., min_length=6) password: str = Field(..., min_length=6)
invite_code: Optional[str] = Field(None, alias="inviteCode") # 填写的邀请码(选填)
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
@ -35,23 +34,6 @@ class UserUpdate(BaseModel):
class UserResponse(UserBase): class UserResponse(UserBase):
# 新增字段
f99_94_level: Optional[str] = Field(None, alias="level")
f99_95_ai_count: Optional[int] = Field(0, alias="aiCount")
f99_96_search_count: Optional[int] = Field(0, alias="searchCount")
f99_97_collection_count: Optional[int] = Field(0, alias="collectionCount")
f01_06_phone_verified: Optional[bool] = Field(False, alias="phoneVerified")
f99_98_login_count: Optional[int] = Field(0, alias="loginCount")
f99_99_last_login: Optional[datetime] = Field(None, alias="lastLogin")
f01_07_gender: Optional[str] = Field(None, alias="gender")
f01_08_birthday: Optional[datetime] = Field(None, alias="birthday")
f01_09_region: Optional[str] = Field(None, alias="region")
f01_10_realname_verified: Optional[bool] = Field(False, alias="realnameVerified")
f99_100_points: Optional[int] = Field(0, alias="points")
f01_11_balance: Optional[float] = Field(0, alias="balance")
f01_12_total_amount: Optional[float] = Field(0, alias="totalAmount")
f01_13_invite_code: Optional[str] = Field(None, alias="inviteCode")
f99_101_invited_count: Optional[int] = Field(0, alias="invitedCount")
f99_90_id: str = Field(..., alias="id") f99_90_id: str = Field(..., alias="id")
f01_01_name: str = Field(..., alias="username") f01_01_name: str = Field(..., alias="username")
role: str role: str

View File

@ -5,6 +5,10 @@ import string
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from dotenv import load_dotenv
# 加载环境变量
load_dotenv()
# 阿里云短信配置 # 阿里云短信配置
SMS_CONFIG = { SMS_CONFIG = {
@ -14,25 +18,19 @@ SMS_CONFIG = {
"template_code": os.getenv("SMS_TEMPLATE_CODE", "SMS_501590956"), "template_code": os.getenv("SMS_TEMPLATE_CODE", "SMS_501590956"),
} }
# 验证码缓存生产环境建议用Redis # 验证码缓存
# 格式: { phone: { code: "123456", expire: 1234567890 } }
VERIFICATION_CODES = {} VERIFICATION_CODES = {}
def generate_code(length: int = 6) -> str: def generate_code(length: int = 6) -> str:
"""生成6位数字验证码"""
return ''.join(random.choices(string.digits, k=length)) return ''.join(random.choices(string.digits, k=length))
def send_verification_code(phone: str) -> dict: def send_verification_code(phone: str) -> dict:
"""发送短信验证码"""
from alibabacloud_dysmsapi20170525 import models from alibabacloud_dysmsapi20170525 import models
from alibabacloud_dysmsapi20170525.client import Client from alibabacloud_dysmsapi20170525.client import Client
from alibabacloud_tea_openapi import models as open_models from alibabacloud_tea_openapi import models as open_models
try: try:
# 生成验证码 code = generate_code()
code = generate_code(6)
# 配置客户端 # 配置客户端
config = open_models.Config( config = open_models.Config(
@ -57,10 +55,9 @@ def send_verification_code(phone: str) -> dict:
# 检查结果 # 检查结果
if response.body.code == "OK": if response.body.code == "OK":
# 保存验证码
VERIFICATION_CODES[phone] = { VERIFICATION_CODES[phone] = {
"code": code, "code": code,
"expire": int(time.time()) + 300 # 5分钟有效 "expire": int(time.time()) + 300
} }
return { return {
"success": True, "success": True,
@ -72,35 +69,23 @@ def send_verification_code(phone: str) -> dict:
"success": False, "success": False,
"message": f"发送失败: {response.body.message}" "message": f"发送失败: {response.body.message}"
} }
except Exception as e: except Exception as e:
return { return {
"success": False, "success": False,
"message": f"发送失败: {str(e)}" "message": f"发送失败: {str(e)}"
} }
def verify_code(phone: str, code: str) -> bool: def verify_code(phone: str, code: str) -> bool:
"""验证验证码"""
if phone not in VERIFICATION_CODES: if phone not in VERIFICATION_CODES:
return False return False
stored = VERIFICATION_CODES[phone] cached = VERIFICATION_CODES[phone]
if int(time.time()) > cached["expire"]:
# 检查是否过期
if int(time.time()) > stored["expire"]:
del VERIFICATION_CODES[phone] del VERIFICATION_CODES[phone]
return False return False
# 验证码匹配 if cached["code"] == code:
if stored["code"] == code:
# 验证成功,删除验证码
del VERIFICATION_CODES[phone] del VERIFICATION_CODES[phone]
return True return True
return False return False
def check_code_exists(phone: str) -> bool:
"""检查是否已发送过验证码"""
return phone in VERIFICATION_CODES

View File

@ -1,132 +0,0 @@
# 号码分类工具
# 按MEMORY.md最新规则 (2026-04-02)
# 优先级:数字越小越有价值
CATEGORIES = [
# 1. 圆圆号:无.123457只能用0689
{"name": "圆圆号", "cannot_use": ".123457", "must_have": ""},
# 2. 倒置号无23457可用01689必须有1
{"name": "倒置号", "cannot_use": "23457", "must_have": "1"},
# 3. 金马王无12347可用05689必须有5
{"name": "金马王", "cannot_use": "12347", "must_have": "5"},
# 4. 金马号无2347可用015689必须有1和5
{"name": "金马号", "cannot_use": "2347", "must_have": "15"},
# 5. 金山王无12457可用03689必须有3
{"name": "金山王", "cannot_use": "12457", "must_have": "3"},
# 6. 天马王无1247可用035689必须有3和5
{"name": "天马王", "cannot_use": "1247", "must_have": "35"},
# 7. 金山号无2457可用013689必须有1和3
{"name": "金山号", "cannot_use": "2457", "must_have": "13"},
# 8. 天马号无247可用0135689必须有1、3和5
{"name": "天马号", "cannot_use": "247", "must_have": "135"},
# 9. 朦胧王无13457
{"name": "朦胧王", "cannot_use": "13457", "must_have": ""},
# 10. 朦胧号无3457
{"name": "朦胧号", "cannot_use": "3457", "must_have": ""},
# 11. 如意号无1347
{"name": "如意号", "cannot_use": "1347", "must_have": ""},
# 12. 钻石号无347
{"name": "钻石号", "cannot_use": "347", "must_have": ""},
# 13. 永恒号无47
{"name": "永恒号", "cannot_use": "47", "must_have": ""},
# 14. 无4号不包含4
{"name": "无4号", "cannot_use": "4", "must_have": ""},
# 15. 通货含4
{"name": "通货", "cannot_use": "", "must_have": "4"},
]
def extract_digits(serial: str) -> dict:
"""提取冠字号中的数字部分"""
if not serial:
return {"digits": "", "type": "single"}
nums = serial.replace("J", "").replace(",", "").replace(".", "").strip()
nums = "".join(c for c in nums if c.isdigit())
if nums.endswith("01"):
return {"digits": nums[:-2], "type": "hundred"}
elif nums.endswith("1"):
return {"digits": nums[:-1], "type": "ten"}
else:
return {"digits": nums, "type": "single"}
def matches_category(digits: str, category: dict) -> bool:
cannot_use = category.get("cannot_use", "")
must_have = category.get("must_have", "")
# 检查不能用的数字
for n in cannot_use:
if n in digits:
return False
# 检查必须有的数字
if must_have:
for n in must_have:
if n not in digits:
return False
return True
def get_number_category(serial: str) -> str:
"""号码分类函数"""
if not serial:
return ""
# 提取数字
info = extract_digits(serial)
digits = info["digits"]
if not digits:
return ""
# 根据类型取对应位数
digits_type = info["type"]
if digits_type == "hundred":
# 标百看后6位
check_digits = digits[-6:] if len(digits) >= 6 else digits
elif digits_type == "ten":
# 标十看后7位
check_digits = digits[-7:] if len(digits) >= 7 else digits
else:
# 散钞看全部
check_digits = digits
# 按优先级匹配分类
for category in CATEGORIES:
if matches_category(check_digits, category):
return category["name"]
return "通货"
def get_number_category_color(category: str) -> str:
"""获取分类颜色"""
colors = {
"圆圆号": "#ef4444", # 红
"倒置号": "#f97316", # 橙
"金马王": "#eab308", # 黄
"金马号": "#84cc16", # 绿
"金山王": "#22c55e", # 深绿
"天马王": "#14b8a6", # 青
"金山号": "#06b6d4", # 蓝
"天马号": "#0ea5e9", # 浅蓝
"朦胧王": "#6366f1", # 靛蓝
"朦胧号": "#8b5cf6", # 紫
"如意号": "#a855f7", # 深紫
"钻石号": "#d946ef", # 品红
"永恒号": "#ec4899", # 粉红
"无4号": "#64748b", # 灰
"通货": "#9ca3af", # 浅灰
}
return colors.get(category, "#9ca3af")
def get_category_priority(category: str) -> int:
"""获取分类优先级(数字越小越高级)"""
for i, cat in enumerate(CATEGORIES, 1):
if cat["name"] == category:
return i
return 999

View File

@ -1,35 +0,0 @@
# 版本更新记录
## v1.2.98 (2026-04-19)
### 前端更新
- **Admin.jsx**
- 功能:修复管理员用户列表行情数显示问题
- 改进:新增 dealCount 字段显示用户发布的成交行情数量
- 代码:将"配号"改为"📈 行情: X条"
### 后端更新
- **users.py**
- 功能修复管理员用户列表行情数API
- 改进:新增 dealCount 字段,从 Information 表统计用户发布的 deal 数量
- 代码:新增 Information 模型导入,统计查询
### Vite配置更新
- **vite.config.js**
- 功能:修复前端构建版本号不更新问题
- 改进:将版本更新改为 closeBundle hook 执行
- 代码:重构 updateHtmlTitle 函数
---
## v1.2.97 (2026-04-XX)
### 更新内容
- (待记录)
---
## v1.2.96 (2026-04-XX)
### 更新内容
- (待记录)

View File

@ -1 +1 @@
1.2.98 VERSION=1.2.12

View File

@ -1,48 +0,0 @@
{
"version": "1.2.98",
"updated": "2026-04-19",
"modules": {
"frontend": {
"version": "1.2.98",
"pages": {
"Add": "1.2.90",
"Admin": "1.2.98",
"Detail": "1.2.90",
"Edit": "1.2.90",
"Home": "1.2.95",
"List": "1.2.93",
"Login": "1.2.85",
"News": "1.2.80",
"News_YichensBoard": "1.2.75",
"Settings": "1.2.75",
"Stats": "1.2.70",
"YichensBoard": "1.2.70"
},
"config": {
"version": "1.2.98"
}
},
"backend": {
"version": "1.2.98",
"routers": {
"auth": "1.2.90",
"collections": "1.2.95",
"deal": "1.2.85",
"information": "1.2.92",
"news": "1.2.80",
"ocr": "1.2.75",
"operations": "1.2.70",
"seek": "1.2.70",
"users": "1.2.98",
"yichens": "1.2.70"
},
"app": {
"core": "1.2.0",
"models": "1.2.0",
"schemas": "1.2.0",
"services": "1.2.0",
"utils": "1.2.0"
}
}
}
}

View File

@ -1,8 +0,0 @@
DATABASE_URL=postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong
SECRET_KEY=production-secret-key-b-env
OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
SMS_SIGN_NAME=苏州算力
SMS_TEMPLATE_CODE=SMS_501590956

View File

@ -1 +0,0 @@
VERSION=1.2.97

View File

@ -1 +0,0 @@
1.2.98

View File

@ -4,10 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<title>甲辰收藏 v=1.2.97</title> <title>甲辰收藏 v1.2.11</title>
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="/static/icons/favicon.png" /> <link rel="icon" type="image/x-icon" href="/static/icons/favicon.ico" />
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png" />
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }

View File

@ -1,12 +1,12 @@
{ {
"name": "jiachenlong-frontend", "name": "jiachenlong-frontend",
"version": "1.2.82", "version": "1.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jiachenlong-frontend", "name": "jiachenlong-frontend",
"version": "1.2.82", "version": "1.0.1",
"dependencies": { "dependencies": {
"axios": "^1.7.9", "axios": "^1.7.9",
"react": "^18.3.1", "react": "^18.3.1",
@ -15,7 +15,7 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"vite": "^6.4.2" "vite": "^6.0.7"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@ -2047,9 +2047,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.2", "version": "6.4.1",
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "jiachenlong-frontend", "name": "jiachenlong-frontend",
"version": "1.2.82", "version": "1.2.12",
"private": true, "private": true,
"description": "甲辰藏品管理系统 - 移动端前端", "description": "甲辰藏品管理系统 - 移动端前端",
"scripts": { "scripts": {
@ -9,13 +9,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.9",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.1.0" "react-router-dom": "^7.1.0",
"axios": "^1.7.9"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"vite": "^6.4.2" "vite": "^6.0.7"
} }
} }

View File

@ -1 +0,0 @@
v=1.2.80

View File

@ -1,20 +0,0 @@
const fs = require('fs');
const path = require('path');
const src = path.join(__dirname, 'static', 'images');
const dst = path.join(__dirname, 'dist', 'static', 'images');
if (!fs.existsSync(dst)) {
fs.mkdirSync(dst, { recursive: true });
}
if (fs.existsSync(src)) {
fs.readdirSync(src).forEach(f => {
const srcFile = path.join(src, f);
const dstFile = path.join(dst, f);
fs.copyFileSync(srcFile, dstFile);
console.log('Copied:', f);
});
}
console.log('Logo复制完成');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

View File

@ -1,26 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 80">
<defs>
<linearGradient id="goldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFE4B5"/>
<stop offset="25%" stop-color="#FFD700"/>
<stop offset="50%" stop-color="#FFA500"/>
<stop offset="75%" stop-color="#DAA520"/>
<stop offset="100%" stop-color="#B8860B"/>
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feFlood flood-color="#FFD700" flood-opacity="0.6"/>
<feComposite in2="blur" operator="in"/>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="shadow">
<feDropShadow dx="2" dy="3" stdDeviation="2" flood-color="#000" flood-opacity="0.5"/>
</filter>
</defs>
<!-- Main title -->
<text x="0" y="45" font-family="'Kaiti SC', 'STKaiti', 'KaiTi', 'SimKai', serif" font-size="52" font-weight="bold" fill="url(#goldGrad)" filter="url(#shadow)">甲辰收藏</text>
<!-- Subtitle -->
<text x="0" y="72" font-family="'Kaiti SC', 'STKaiti', 'KaiTi', 'SimKai', serif" font-size="20" fill="#DAA520" letter-spacing="4">生肖纪念钞管理系统</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

View File

@ -4,7 +4,6 @@ import Settings from './pages/Settings'
import List from './pages/List' import List from './pages/List'
import Add from './pages/Add' import Add from './pages/Add'
import Stats from './pages/Stats' import Stats from './pages/Stats'
import News from './pages/News'
import Login from './pages/Login' import Login from './pages/Login'
import Detail from './pages/Detail' import Detail from './pages/Detail'
import Edit from './pages/Edit' import Edit from './pages/Edit'
@ -29,7 +28,6 @@ export default function App() {
const getComponent = () => { const getComponent = () => {
const basePath = path.split('?')[0] const basePath = path.split('?')[0]
if (basePath === '/') return <Home /> if (basePath === '/') return <Home />
if (basePath === '/news') return <News />
if (basePath === '/stats') return <Stats /> if (basePath === '/stats') return <Stats />
if (basePath === '/list') return <List /> if (basePath === '/list') return <List />
if (basePath === '/add') return <Add /> if (basePath === '/add') return <Add />
@ -50,7 +48,6 @@ export default function App() {
console.error('Parse user error:', e) console.error('Parse user error:', e)
} }
const isAdmin = user && user.role === 'admin' const isAdmin = user && user.role === 'admin'
const isEditor = user && user.role === 'editor'
// //
if (!token) { if (!token) {
@ -85,10 +82,9 @@ export default function App() {
}}> }}>
{[ {[
{ path: '/', icon: '🏠', label: '首页' }, { path: '/', icon: '🏠', label: '首页' },
{ path: '/news', icon: '📰', label: '行情' },
{ path: '/stats', icon: '📊', label: '统计' }, { path: '/stats', icon: '📊', label: '统计' },
{ path: '/list', icon: '📚', label: '我的' }, { path: '/list', icon: '📚', label: '藏品' },
{ path: '/add', icon: '🎯', label: '录入' }, { path: '/add', icon: '🎯', label: '添加' },
...(isAdmin ? [{ path: '/admin', icon: '⚙️', label: '管理' }] : []) ...(isAdmin ? [{ path: '/admin', icon: '⚙️', label: '管理' }] : [])
].map(tab => ( ].map(tab => (
<div <div
@ -112,4 +108,3 @@ export default function App() {
</div> </div>
) )
} }
// Fri Apr 10 03:44:24 PM CST 2026

View File

@ -3,7 +3,7 @@
// 修改版本号只需修改 ../../VERSION 文件中的 VERSION=xxx // 修改版本号只需修改 ../../VERSION 文件中的 VERSION=xxx
// 从环境变量读取vite.config.js 注入) // 从环境变量读取vite.config.js 注入)
export const APP_VERSION = import.meta.env.APP_VERSION || '1.2.82' export const APP_VERSION = import.meta.env.APP_VERSION || '1.0.0'
// 版本信息 // 版本信息
export const VERSION_INFO = { export const VERSION_INFO = {

View File

@ -1,13 +1,7 @@
/** // - AI //
* Add - 添加藏品页面
* Version: 1.2.90x
* 更新
*/
import React, { useState, useRef } from 'react' import React, { useState, useRef } from 'react'
import { APP_VERSION } from '../config/version' import { APP_VERSION } from '../config/version'
const API_BASE = localStorage.getItem('API_BASE') || '' 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 numberCategoryOptions = [{value:'无2347',label:'无2347'},{value:'无347',label:'无347'},{value:'无247',label:'无247'},{value:'无47',label:'无47'},{value:'带7号',label:'带7号'},{value:'带4号',label:'带4号'},{value:'其他',label:'其他'}];
// //
const convertField = (obj) => { const convertField = (obj) => {
@ -71,63 +65,18 @@ const getDefaultForm = () => ({
name: '藏品', code: '', category: '自持', rarity: '通货', prefixSerial: '', name: '藏品', code: '', category: '自持', rarity: '通货', prefixSerial: '',
version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张', version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张',
material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false, material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false,
gradingCompany: '爱藏', gradingScore: '67+', threeStar: false, specialMark: '', gradingCompany: '', gradingScore: '', threeStar: false, specialMark: '',
serialFeature: '', numberCategory: '', issuer: '中国人民银行', issueYear: '2024', serialFeature: '', numberCategory: '', issuer: '中国人民银行', issueYear: '2024',
costPrice: '', targetPrice: '', goalPrice: '', repairFee: '', gradingFee: '', remark: '' costPrice: '', targetPrice: '', goalPrice: '', repairFee: '', gradingFee: '', remark: ''
}) })
export default function Add() { export default function Add() {
//
const [dealForm, setDealForm] = useState({
serial: '',
category: '',
packaging: '标十',
price: '',
platform: '抖音',
seller: '',
buyer: '',
date: new Date().toISOString().split('T')[0],
isGraded: false,
gradingCompany: '爱藏',
gradingScore: '67+'
})
const [batchDefaultPackaging, setBatchDefaultPackaging] = useState('')
const [batchDefaultDate, setBatchDefaultDate] = useState('')
const [batchDefaultPlatform, setBatchDefaultPlatform] = useState('抖音')
const [dealMode, setDealMode] = useState('single') // single-, batch-
const [batchText, setBatchText] = useState('')
const [batchResult, setBatchResult] = useState([])
const [parsing, setParsing] = useState(false)
const [savingDeal, setSavingDeal] = useState(false)
//
const autoCategory = (serial) => {
const digits = serial.replace('J', '').replace(/[^0-9]/g, '').slice(0, 9)
if (!digits) return ''
const d = digits
if (!d.includes('1') && !d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '圆圆号'
if (!d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '倒置号'
if (!d.includes('1') && !d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '金马王'
if (!d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '金马号'
if (!d.includes('1') && !d.includes('2') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '金山王'
if (!d.includes('1') && !d.includes('2') && !d.includes('4') && !d.includes('7')) return '天马王'
if (!d.includes('2') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '金山号'
if (!d.includes('2') && !d.includes('4') && !d.includes('7')) return '天马号'
if (!d.includes('1') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '朦胧王'
if (!d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '朦胧号'
if (!d.includes('1') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '如意号'
if (!d.includes('3') && !d.includes('4') && !d.includes('7')) return '钻石号'
if (!d.includes('4') && !d.includes('7')) return '永恒号'
if (!d.includes('4')) return '带7号'
return '带4号'
}
// URL // URL
const params = new URLSearchParams(window.location.hash.split('?')[1] || '') const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const modeParam = params.get('mode') const modeParam = params.get('mode')
const defaultTab = modeParam === 'manual' ? 'manual' : 'ai' const defaultTab = modeParam === 'manual' ? 'manual' : 'ai'
const [activeTab, setActiveTab] = useState(defaultTab) // ai, manual, deal const [activeTab, setActiveTab] = useState(defaultTab) // ai, manual, batch
const [form, setForm] = useState(getDefaultForm()) const [form, setForm] = useState(getDefaultForm())
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@ -209,8 +158,8 @@ export default function Add() {
else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347' 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('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('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47'
else if (digits.includes('7') && !digits.includes('4')) cat = '带7号' else if (digits.includes('7') && !digits.includes('4')) cat = '无4'
else if (digits.includes('4')) cat = '带4' else if (digits.includes('4')) cat = '带4'
else cat = '其他' else cat = '其他'
} }
setForm(prev => ({ ...prev, numberCategory: cat })) setForm(prev => ({ ...prev, numberCategory: cat }))
@ -325,8 +274,8 @@ export default function Add() {
else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347' 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('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('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47'
else if (digits.includes('7') && !digits.includes('4')) cat = '带7号' else if (digits.includes('7') && !digits.includes('4')) cat = '无4'
else if (digits.includes('4')) cat = '带4' else if (digits.includes('4')) cat = '带4'
else cat = '其他' else cat = '其他'
formWithCategory.numberCategory = cat formWithCategory.numberCategory = cat
} }
@ -456,15 +405,15 @@ export default function Add() {
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => setActiveTab('ai')} <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' }}> 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 识别 🤖 AI 识别
</button> </button>
<button onClick={() => setActiveTab('manual')} <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' }}> 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>
<button onClick={() => setActiveTab('deal')} <button onClick={() => setActiveTab('batch')} disabled
style={{ flex: 1, padding: '10px', background: activeTab === 'deal' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'deal' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}> 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> </button>
</div> </div>
</div> </div>
@ -486,7 +435,7 @@ export default function Add() {
style={{ padding: '12px 24px', background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', border: '1px solid #ef4444', borderRadius: '8px', fontSize: '14px', cursor: 'pointer' }}>🗑 重新选择</button> 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} <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' }}> 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 ? '🔍 识别中...' : '开始识别'} {recognizing ? '🔍 识别中...' : '🤖 开始识别'}
</button> </button>
</div> </div>
</div> </div>
@ -708,380 +657,16 @@ export default function Add() {
</div> </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>
{activeTab === 'deal' && ( <div style={{ fontSize: '13px' }}>敬请期待后续版本</div>
<div style={{ padding: '16px' }}>
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '20px', marginBottom: '16px' }}>
<div style={{ color: '#fbbf24', fontSize: '16px', fontWeight: 'bold', marginBottom: '16px' }}>成交行情录入</div>
{/* 单条/批量切换 */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<button onClick={() => setDealMode('single')}
style={{ flex: 1, padding: '10px', background: dealMode === 'single' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: dealMode === 'single' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
单条录入
</button>
<button onClick={() => setDealMode('batch')}
style={{ flex: 1, padding: '10px', background: dealMode === 'batch' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: dealMode === 'batch' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
批量录入
</button>
</div>
{/* 单条录入 */}
{dealMode === 'single' && (
<div>
<div style={{ marginBottom: '12px' }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>冠字号 *</div>
<input value={dealForm.serial} onChange={(e) => setDealForm({...dealForm, serial: e.target.value.toUpperCase(), category: autoCategory(e.target.value)})}
placeholder="J0xxxxxxxx"
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
</div>
{dealForm.category && (
<div style={{ background: 'rgba(251,191,36,0.2)', padding: '10px', borderRadius: '8px', textAlign: 'center', marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8' }}>号码分类</div>
<div style={{ fontSize: '14px', color: '#fbbf24', fontWeight: 'bold' }}>{dealForm.category}</div>
</div>
)}
<div style={{ marginBottom: '12px' }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>包装类型</div>
<div style={{ display: 'flex', gap: '8px' }}>
{['单张', '标十', '标百'].map(p => (
<button key={p} onClick={() => setDealForm({...dealForm, packaging: p})}
style={{ flex: 1, padding: '10px', background: dealForm.packaging === p ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: dealForm.packaging === p ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
{p}
</button>
))}
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>成交价格 *</div>
<input type="number" value={dealForm.price} onChange={(e) => setDealForm({...dealForm, price: e.target.value})}
placeholder="请输入成交价格"
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>成交平台 *</div>
<select value={dealForm.platform} onChange={(e) => setDealForm({...dealForm, platform: e.target.value})}
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }}>
<option value="淘宝">淘宝</option>
<option value="咸鱼">咸鱼</option>
<option value="抖音">抖音</option>
<option value="快手">快手</option>
<option value="微拍堂">微拍堂</option>
<option value="拼多多">拼多多</option>
<option value="一尘">一尘</option>
<option value="爱藏">爱藏</option>
<option value="其他">其他</option>
</select>
</div>
<div style={{ display: 'flex', gap: '12px', marginBottom: '12px' }}>
<div style={{ flex: 1 }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>出售者</div>
<input value={dealForm.seller} onChange={(e) => setDealForm({...dealForm, seller: e.target.value})}
placeholder="请输入出售者"
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>购买者</div>
<input value={dealForm.buyer} onChange={(e) => setDealForm({...dealForm, buyer: e.target.value})}
placeholder="请输入购买者"
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>成交日期 *</div>
<input type="date" value={dealForm.date} onChange={(e) => setDealForm({...dealForm, date: e.target.value})}
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
</div>
{/* 评级字段 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>评级可选</div>
<div style={{ display: 'flex', gap: '8px' }}>
<select value={dealForm.gradingCompany || ''} onChange={(e) => setDealForm({...dealForm, gradingCompany: e.target.value})}
style={{ flex: 1, padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '14px' }}>
<option value="">评级机构</option>
<option value="PCGS">PCGS</option>
<option value="PMG">PMG</option>
<option value="ACG">ACG</option>
<option value="爱藏">爱藏</option>
</select>
<input value={dealForm.gradingScore || ''} onChange={(e) => setDealForm({...dealForm, gradingScore: e.target.value})}
placeholder="评级分数"
style={{ flex: 1, padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '14px' }} />
</div>
</div>
<button onClick={async () => {
if (!dealForm.serial || !dealForm.price || !dealForm.platform || !dealForm.date) {
alert('请填写所有必填项')
return
}
setSavingDeal(true)
try {
const token = localStorage.getItem('token')
//
// 7 J0 + 0 + ( J00123456)
// 8 J0 + ( J01234567)
// 9 J0 + 8 ( J02345678)
const rawSerial = dealForm.serial.replace('J', '').replace(/\D/g, '')
let normalizedSerial = ''
if (rawSerial.length === 7) {
normalizedSerial = 'J0' + '0' + rawSerial
} else if (rawSerial.length === 8) {
normalizedSerial = 'J0' + rawSerial
} else if (rawSerial.length >= 9) {
normalizedSerial = 'J0' + rawSerial.slice(1, 9) // 8
} else {
normalizedSerial = 'J0' + rawSerial
}
const tailNumber = rawSerial.slice(-2)
const sizeType = (dealForm.packaging === '标十' && ['01','11','21','31','41','51','61','71','81','91'].includes(tailNumber)) ? '小号' :
(dealForm.packaging === '标百' && ['001','011','021','031','041','051','061','071','081','091'].includes(rawSerial.slice(-3))) ? '小号' : '大号'
const response = await fetch(`${API_BASE}/api/deal`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: `${normalizedSerial}${dealForm.price}`,
content: `分类: ${dealForm.category || '未分类'}\n尾号: ${tailNumber || '-'}\n大小号: ${sizeType || '-'}\n包装: ${dealForm.packaging}\n平台: ${dealForm.platform}\n价格: ¥${dealForm.price}\n出售者: ${dealForm.seller || '-'}\n购买者: ${dealForm.buyer || '-'}\n日期: ${dealForm.date}\n评级: ${dealForm.gradingCompany ? dealForm.gradingCompany + ' ' + dealForm.gradingScore : '未评级'}`,
deal_price: parseFloat(dealForm.price),
deal_date: dealForm.date,
packaging: dealForm.packaging,
category: dealForm.category,
tail_number: tailNumber,
size_type: sizeType,
platform: dealForm.platform,
seller: dealForm.seller || '',
buyer: dealForm.buyer || ''
})
})
if (response.ok) {
alert('行情录入成功!')
setDealForm({ serial: '', category: '', packaging: '标十', price: '', platform: '抖音', seller: '', buyer: '', date: new Date().toISOString().split('T')[0] })
} else {
const data = await response.json()
alert('录入失败: ' + (data.detail || '未知错误'))
}
} catch (e) {
alert('提交失败: ' + e.message)
} finally {
setSavingDeal(false)
}
}}
disabled={savingDeal}
style={{ width: '100%', padding: '14px', background: savingDeal ? '#64748b' : '#fbbf24', color: savingDeal ? '#94a3b8' : '#1e293b', border: 'none', borderRadius: '10px', fontSize: '16px', fontWeight: '600', cursor: savingDeal ? 'not-allowed' : 'pointer' }}>
{savingDeal ? '提交中...' : '提交行情'}
</button>
</div>
)}
{/* 批量录入 */}
{dealMode === 'batch' && (
<div>
{/* 日期和平台 */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '10px', color: '#64748b', marginBottom: '4px' }}>默认日期可选</div>
<input type="date" value={batchDefaultDate || ''} onChange={(e) => setBatchDefaultDate(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: '12px' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '10px', color: '#64748b', marginBottom: '4px' }}>成交平台可选</div>
<select value={batchDefaultPlatform || ''} onChange={(e) => setBatchDefaultPlatform(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: '12px' }}>
<option value="">请选择</option>
<option value="淘宝">淘宝</option>
<option value="咸鱼">咸鱼</option>
<option value="抖音">抖音</option>
<option value="快手">快手</option>
<option value="微拍堂">微拍堂</option>
<option value="拼多多">拼多多</option>
<option value="一尘">一尘</option>
<option value="爱藏">爱藏</option>
<option value="其他">其他</option>
</select>
</div>
</div>
{/* 包装类型选择 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '10px', color: '#64748b', marginBottom: '6px' }}>批量默认包装类型可选</div>
<div style={{ display: 'flex', gap: '6px' }}>
<button onClick={() => setBatchDefaultPackaging(batchDefaultPackaging === '单张' ? '' : '单张')}
style={{ flex: 1, padding: '8px', background: batchDefaultPackaging === '单张' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: batchDefaultPackaging === '单张' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '12px', cursor: 'pointer' }}>
单张
</button>
<button onClick={() => setBatchDefaultPackaging(batchDefaultPackaging === '标十' ? '' : '标十')}
style={{ flex: 1, padding: '8px', background: batchDefaultPackaging === '标十' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: batchDefaultPackaging === '标十' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '12px', cursor: 'pointer' }}>
标十
</button>
<button onClick={() => setBatchDefaultPackaging(batchDefaultPackaging === '标百' ? '' : '标百')}
style={{ flex: 1, padding: '8px', background: batchDefaultPackaging === '标百' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: batchDefaultPackaging === '标百' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '12px', cursor: 'pointer' }}>
标百
</button>
</div>
</div>
<textarea value={batchText} onChange={(e) => setBatchText(e.target.value)}
placeholder="粘贴批量行情文本..." style={{ width: '100%', minHeight: '120px', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '14px' }} />
<button onClick={async () => {
if (!batchText.trim()) { alert('请先粘贴行情文本'); return }
setParsing(true)
try {
const response = await fetch(`${API_BASE}/api/information/batch-parse-local`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: batchText, defaultPackaging: batchDefaultPackaging, defaultDate: batchDefaultDate, defaultPlatform: batchDefaultPlatform })
})
const data = await response.json()
if (data.success) { setBatchResult(data.data || []); alert(`解析成功${data.data.length}`) }
else { alert('解析失败: ' + (data.error || '未知错误')) }
} catch (e) { alert('请求失败: ' + e.message) }
finally { setParsing(false) }
}}
disabled={parsing} style={{ width: '100%', marginTop: '8px', padding: '10px', background: parsing ? '#64748b' : '#22c55e', color: '#fff', border: 'none', borderRadius: '8px' }}>
{parsing ? '解析中...' : '解析文本'}
</button>
{batchResult.length > 0 && (
<div style={{ marginTop: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<div style={{ color: '#94a3b8', fontSize: '12px' }}>解析结果 ({batchResult.length})</div>
<button onClick={() => { setBatchResult([]); setBatchText('') }} style={{ padding: '4px 8px', background: 'rgba(239,68,68,0.2)', color: '#ef4444', border: 'none', borderRadius: '4px', fontSize: '11px', cursor: 'pointer' }}>清空</button>
</div>
<div style={{ maxHeight: '350px', overflowY: 'auto' }}>
{batchResult.map((item, idx) => (
<div key={idx} style={{ padding: '8px', background: 'rgba(255,255,255,0.05)', marginBottom: '6px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.1)' }}>
{/* 第一行:冠字号 | 价格 | 分类 | 包装 */}
<div style={{ display: 'flex', gap: '6px', marginBottom: '4px' }}>
<div style={{ flex: '0 0 35%' }}>
<input value={batchResult[idx].serial} onChange={(e) => {
const newResult = [...batchResult]
newResult[idx].serial = e.target.value
setBatchResult(newResult)
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fbbf24', fontSize: '12px' }} />
</div>
<div style={{ flex: '0 0 25%' }}>
<input type="number" value={batchResult[idx].price} onChange={(e) => {
const newResult = [...batchResult]
newResult[idx].price = parseFloat(e.target.value) || 0
setBatchResult(newResult)
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#22c55e', fontSize: '12px' }} />
</div>
<div style={{ flex: '0 0 20%' }}>
<select value={batchResult[idx].category} onChange={(e) => {
const newResult = [...batchResult]
newResult[idx].category = e.target.value
setBatchResult(newResult)
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fff', fontSize: '10px' }}>
{['圆圆号','倒置号','金马王','金马号','金山王','天马王','金山号','天马号','朦胧王','朦胧号','如意号','钻石号','永恒号','带7号','带4号'].map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div style={{ flex: '0 0 20%' }}>
<select value={batchResult[idx].packaging || '单张'} onChange={(e) => {
const newResult = [...batchResult]
newResult[idx].packaging = e.target.value
setBatchResult(newResult)
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fff', fontSize: '10px' }}>
<option value="单张">单张</option>
<option value="标十">标十</option>
<option value="标百">标百</option>
</select>
</div>
</div>
{/* 第二行:评级机构 | 分数 | 出售者 | 删除 */}
<div style={{ display: 'flex', gap: '6px' }}>
<div style={{ flex: '0 0 22%' }}>
<select value={batchResult[idx].grading_company || ''} onChange={(e) => {
const newResult = [...batchResult]
newResult[idx].grading_company = e.target.value
newResult[idx].is_graded = !!e.target.value
setBatchResult(newResult)
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fff', fontSize: '10px' }}>
<option value="">未评级</option>
<option value="PCGS">PCGS</option>
<option value="PMG">PMG</option>
<option value="ACG">ACG</option>
<option value="爱藏">爱藏</option>
</select>
</div>
<div style={{ flex: '0 0 18%' }}>
<input value={batchResult[idx].grade || ''} onChange={(e) => {
const newResult = [...batchResult]
newResult[idx].grade = e.target.value
setBatchResult(newResult)
}} placeholder="分数" style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#06b6d4', fontSize: '11px' }} />
</div>
<div style={{ flex: '0 0 30%' }}>
<input value={batchResult[idx].seller || ''} onChange={(e) => {
const newResult = [...batchResult]
newResult[idx].seller = e.target.value
setBatchResult(newResult)
}} placeholder="出售者" style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fff', fontSize: '11px' }} />
</div>
<div style={{ flex: '0 0 30%', textAlign: 'right' }}>
<button onClick={() => {
const newResult = batchResult.filter((_, i) => i !== idx)
setBatchResult(newResult)
}} style={{ padding: '4px 8px', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '4px', fontSize: '10px', cursor: 'pointer' }}>删除</button>
</div>
</div>
</div>
))}
</div>
<button onClick={async () => {
setSavingDeal(true)
let count = 0; const token = localStorage.getItem('token')
for (const item of batchResult) {
try {
//
const digits = (item.serial || '').replace('J', '').replace(/[^0-9]/g, '')
let tail_number = '', size_type = ''
if ((item.packaging === '标十' || item.packaging === '标百') && digits.length >= 2) {
tail_number = item.packaging === '标十' ? digits.slice(-2) : digits.slice(-3)
size_type = (item.packaging === '标十' && ['01','11','21','31','41','51'].includes(tail_number)) || (item.packaging === '标百' && ['101','201','301','401','501'].includes(tail_number)) ? '小号' : '大号'
}
await fetch(`${API_BASE}/api/deal`, {
method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: `${item.serial}${item.price}`,
content: `分类: ${item.category || '-'}\n尾号: ${tail_number || '-'}\n大小号: ${size_type || '-'}\n包装: ${item.packaging || '单张'}\n平台: ${item.platform || '-'}\n价格: ¥${item.price}\n出售者: ${item.seller || '-'}\n购买者: ${item.buyer || '-'}\n日期: ${item.deal_date || new Date().toISOString().split('T')[0]}`,
deal_price: parseFloat(item.price),
deal_date: item.deal_date || new Date().toISOString().split('T')[0],
packaging: item.packaging || '单张',
category: item.category || '',
tail_number: tail_number,
size_type: size_type,
platform: item.platform || '-',
seller: item.seller || '',
buyer: item.buyer || ''
})
})
count++
} catch(e) { console.error(e) }
}
alert(`完成${count}`); setBatchResult([]); setBatchText(''); setSavingDeal(false)
}} disabled={savingDeal} style={{ width: '100%', marginTop: '8px', padding: '10px', background: savingDeal?'#64748b':'#fbbf24', color: savingDeal?'#94a3b8':'#1e293b', border:'none', borderRadius:'8px' }}>
{savingDeal ? '提交中...' : `批量录入${batchResult.length}`}
</button>
</div>
)}
</div>
)}
</div>
</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 style={{ position: 'fixed', bottom: '16px', right: '16px', color: 'rgba(255,255,255,0.2)', fontSize: '11px', zIndex: 100 }}>v{APP_VERSION}</div>
</div> </div>
) )

View File

@ -1,9 +1,3 @@
/**
* Admin - 管理员用户管理页面
* Version: 1.2.98 (2026-04-19)
* 更新新增 dealCount 字段显示用户发布的行情数量
*/
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version' import { APP_VERSION } from '../config/version'
@ -132,18 +126,7 @@ export default function Admin() {
const updateData = { const updateData = {
username: editingUser.username, username: editingUser.username,
email: editingUser.email, email: editingUser.email,
phone: editingUser.phone, role: editingUser.role
phoneVerified: editingUser.phoneVerified,
role: editingUser.role,
user_code: editingUser.user_code,
level: editingUser.level,
loginCount: editingUser.loginCount,
aiCount: editingUser.aiCount,
searchCount: editingUser.searchCount,
points: editingUser.points,
balance: editingUser.balance,
totalAmount: editingUser.totalAmount,
phoneVerified: editingUser.phoneVerified
} }
// //
@ -203,56 +186,26 @@ export default function Admin() {
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{users.map((user) => ( {users.map((user) => (
<div key={user.id} style={{ background: 'rgba(30, 41, 59, 0.8)', borderRadius: '12px', padding: '16px' }}> <div key={user.id} style={{ background: 'rgba(30, 41, 59, 0.8)', borderRadius: '12px', padding: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ color: '#fbbf24', fontSize: '16px', fontWeight: 'bold' }}>{user.username}</div> <div style={{ color: '#fbbf24', fontSize: '16px', fontWeight: 'bold' }}>{user.username}</div>
<div style={{ <div style={{
padding: '2px 8px', borderRadius: '4px', padding: '2px 8px', borderRadius: '4px',
background: user.role === 'admin' ? 'rgba(16, 185, 129, 0.2)' : (user.role === 'editor' ? 'rgba(245, 158, 11, 0.2)' : 'rgba(148, 163, 184, 0.2)'), background: user.role === 'admin' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(148, 163, 184, 0.2)',
color: user.role === 'admin' ? '#10b981' : (user.role === 'editor' ? '#f59e0b' : '#94a3b8'), color: user.role === 'admin' ? '#10b981' : '#94a3b8',
fontSize: '12px' fontSize: '12px'
}}> }}>
{user.role === 'admin' ? '👑 管理员' : (user.role === 'editor' ? '📝 信息员' : '👤 用户')} {user.role === 'admin' ? '👑 管理员' : '👤 用户'}
</div>
<div style={{ background: 'rgba(59, 130, 246, 0.2)', padding: '2px 8px', borderRadius: '4px', color: '#60a5fa', fontSize: '12px' }}>
编码: {user.user_code || '-'}
</div>
<div style={{ background: 'rgba(168, 85, 247, 0.2)', padding: '2px 8px', borderRadius: '4px', color: '#a855f7', fontSize: '12px' }}>
等级: {user.level || '青铜'}
</div> </div>
</div> </div>
<div style={{ color: '#64748b', fontSize: '13px', marginTop: '8px' }}> <div style={{ color: '#64748b', fontSize: '13px', marginTop: '4px' }}>
📧 {user.email || '未设置'} | 📱 {user.phone || '未设置'} | 📱已认证: {user.phoneVerified ? '✓' : '✗'} 📧 {user.email || '未设置'} | 📱 {user.phone || '未设置'}
</div> </div>
</div> </div>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
<div style={{ color: '#94a3b8', fontSize: '12px' }}>藏品数</div> <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.collectionCount || 0}</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', flexWrap: 'wrap', gap: '8px', marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<div style={{ background: 'rgba(16, 185, 129, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#10b981' }}>
🔑 登录: {user.loginCount || 0}
</div>
<div style={{ background: 'rgba(59, 130, 246, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#3b82f6' }}>
🤖 AI识别: {user.aiCount || 0}
</div>
<div style={{ background: 'rgba(20, 184, 166, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#14b8a6' }}>
📈 行情: {user.dealCount || 0}
</div>
<div style={{ background: 'rgba(245, 158, 11, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#f59e0b' }}>
🔍 寻号: {user.searchCount || 0}
</div>
<div style={{ background: 'rgba(168, 85, 247, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#a855f7' }}>
💎 积分: {user.points || 0}
</div>
<div style={{ background: 'rgba(236, 72, 153, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#ec4899' }}>
💰 余额: {user.balance || 0}
</div>
<div style={{ background: 'rgba(34, 197, 94, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#22c55e' }}>
📊 累计: {user.totalAmount || 0}
</div> </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={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
@ -261,7 +214,7 @@ export default function Admin() {
</div> </div>
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<button <button
onClick={() => setEditingUser({ ...user, email: user.email || '', phone: user.phone || '', phoneVerified: user.phoneVerified !== false, newPassword: '', confirmPassword: '' })} onClick={() => setEditingUser({ ...user })}
style={{ style={{
padding: '6px 12px', padding: '6px 12px',
borderRadius: '6px', borderRadius: '6px',
@ -347,7 +300,6 @@ export default function Admin() {
> >
<option value="user">普通用户</option> <option value="user">普通用户</option>
<option value="admin">管理员</option> <option value="admin">管理员</option>
<option value="editor">信息员</option>
</select> </select>
</div> </div>
@ -399,28 +351,6 @@ export default function Admin() {
/> />
</div> </div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>手机号</label>
<input
type="text"
value={editingUser.phone || ''}
onChange={(e) => setEditingUser({ ...editingUser, phone: 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: 'flex', alignItems: 'center', color: '#94a3b8', fontSize: '13px', marginBottom: '6px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editingUser.phoneVerified === true}
onChange={(e) => setEditingUser({ ...editingUser, phoneVerified: e.target.checked })}
style={{ marginRight: '8px' }}
/>
手机号已验证
</label>
</div>
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>角色</label> <label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>角色</label>
<select <select
@ -430,7 +360,6 @@ export default function Admin() {
> >
<option value="user">普通用户</option> <option value="user">普通用户</option>
<option value="admin">管理员</option> <option value="admin">管理员</option>
<option value="editor">信息员</option>
</select> </select>
</div> </div>
</div> </div>
@ -461,113 +390,6 @@ export default function Admin() {
/> />
</div> </div>
</div> </div>
{/* 第三部分:扩展信息 */}
<div style={{ marginBottom: '24px' }}>
<h3 style={{ color: '#a855f7', fontSize: '15px', fontWeight: 'bold', marginBottom: '16px', paddingBottom: '8px', borderBottom: '1px solid rgba(168, 85, 247, 0.3)' }}>📊 扩展信息</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>用户编码</label>
<input
type="text"
value={editingUser.user_code || ''}
onChange={(e) => setEditingUser({ ...editingUser, user_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: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>会员等级</label>
<select
value={editingUser.level || '青铜'}
onChange={(e) => setEditingUser({ ...editingUser, level: 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' }}
>
<option value="青铜">青铜</option>
<option value="白银">白银</option>
<option value="黄金">黄金</option>
<option value="铂金">铂金</option>
<option value="钻石号">钻石号</option>
<option value="永恒号">永恒号</option>
<option value="带7号">带7号</option>
<option value="带4号">带4号</option>
</select>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>登录次数</label>
<input
type="number"
value={editingUser.loginCount || 0}
onChange={(e) => setEditingUser({ ...editingUser, loginCount: parseInt(e.target.value) || 0 })}
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: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>AI识别次数</label>
<input
type="number"
value={editingUser.aiCount || 0}
onChange={(e) => setEditingUser({ ...editingUser, aiCount: parseInt(e.target.value) || 0 })}
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: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>配号次数</label>
<input
type="number"
value={editingUser.searchCount || 0}
onChange={(e) => setEditingUser({ ...editingUser, searchCount: parseInt(e.target.value) || 0 })}
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: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>寻号次数</label>
<input
type="number"
value={editingUser.searchCount || 0}
onChange={(e) => setEditingUser({ ...editingUser, searchCount: parseInt(e.target.value) || 0 })}
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: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>积分</label>
<input
type="number"
value={editingUser.points || 0}
onChange={(e) => setEditingUser({ ...editingUser, points: parseInt(e.target.value) || 0 })}
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: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>余额</label>
<input
type="number"
step="0.01"
value={editingUser.balance || 0}
onChange={(e) => setEditingUser({ ...editingUser, balance: parseFloat(e.target.value) || 0 })}
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: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>累计金额</label>
<input
type="number"
step="0.01"
value={editingUser.totalAmount || 0}
onChange={(e) => setEditingUser({ ...editingUser, totalAmount: parseFloat(e.target.value) || 0 })}
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>
<div style={{ display: 'flex', gap: '12px' }}> <div style={{ display: 'flex', gap: '12px' }}>
<button <button

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

@ -1,9 +1,3 @@
/**
* Detail - 藏品详情页面
* Version: 1.2.90x
* 更新
*/
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
export default function Detail() { export default function Detail() {

View File

@ -1,9 +1,3 @@
/**
* Edit - 编辑藏品页面
* Version: 1.2.90x
* 更新
*/
import React, { useState, useRef, useEffect } from 'react' import React, { useState, useRef, useEffect } from 'react'
// Input Add.jsx // Input Add.jsx
@ -52,7 +46,7 @@ const categoryOptions = [
{ value: '其他', label: '其他' } { value: '其他', label: '其他' }
] ]
const numberCategoryOptions = [{value:'无2347',label:'无2347'},{value:'无347',label:'无347'},{value:'无247',label:'无247'},{value:'无47',label:'无47'},{value:'带7号',label:'带7号'},{value:'带4',label:'带4'},{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 = [ const rarityOptions = [
{ value: '通货', label: '通货' }, { value: '通货', label: '通货' },
{ value: '特色', label: '特色' }, { value: '特色', label: '特色' },
@ -163,8 +157,8 @@ export default function Edit() {
if (digits) { if (digits) {
if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347' 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('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347'
else if (digits.includes('4')) cat = '带4' else if (digits.includes('4')) cat = '带4'
else if (digits.includes('7')) cat = '带7号' else if (digits.includes('7')) cat = '无4'
else if (!digits.includes('4') && !digits.includes('7')) cat = '无47' else if (!digits.includes('4') && !digits.includes('7')) cat = '无47'
else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247' else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247'
else cat = '其他' else cat = '其他'

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

View File

@ -1,9 +1,3 @@
/**
* Home - 首页
* Version: 1.2.95x
* 更新
*/
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version' import { APP_VERSION } from '../config/version'
@ -11,10 +5,6 @@ export default function Home() {
const [user, setUser] = useState(null) const [user, setUser] = useState(null)
const [stats, setStats] = useState({ totalCount: 0, totalCost: 0, totalRevenue: 0, expectedProfit: 0, totalProfit: 0, gradedCount: 0 }) const [stats, setStats] = useState({ totalCount: 0, totalCost: 0, totalRevenue: 0, expectedProfit: 0, totalProfit: 0, gradedCount: 0 })
const [recentCollections, setRecentCollections] = useState([]) const [recentCollections, setRecentCollections] = useState([])
const [yichensStats, setYichensStats] = useState({ total: 0, deals: 0, wants: 0, dragons: 0, horses: 0, tianma: 0 })
const [seekStats, setSeekStats] = useState({ seekCount: 0, matchedCount: 0 })
const [recentPosts, setRecentPosts] = useState([])
const [dragonStats, setDragonStats] = useState({})
const currentPath = window.location.hash.slice(1) || '/' const currentPath = window.location.hash.slice(1) || '/'
useEffect(() => { useEffect(() => {
@ -64,31 +54,6 @@ export default function Home() {
} }
}, []) }, [])
//
useEffect(() => {
fetch('/api/yichens/stats/dragons-today').then(res => res.json()).then(data => {
setDragonStats(data || {})
}).catch(() => {})
fetch('/api/yichens/stats/today').then(res => res.json()).then(data => {
setYichensStats(data || {})
}).catch(() => {})
//
fetch('/api/yichens/stats/dragons-today').then(res => res.json()).then(data => {
setDragonStats(data || {})
}).catch(() => {})
fetch('/api/yichens/posts?limit=20&offset=0').then(res => res.json()).then(data => {
setRecentPosts(data.posts || data || [])
}).catch(() => {})
//
fetch('/api/information/seek/stats').then(res => res.json()).then(data => {
setSeekStats(data || {})
}).catch(() => {})
}, [])
// //
const isAdmin = user && user.role === 'admin' const isAdmin = user && user.role === 'admin'
@ -102,16 +67,16 @@ export default function Home() {
const formatMoney = (val) => { const formatMoney = (val) => {
if (!val || val === 0) return '0' if (!val || val === 0) return '0'
const v = val / 10000 const v = val / 10000
return v.toFixed(1) return v.toFixed(4)
} }
const statCards = [ const statCards = [
{ label: '藏品数', value: stats.totalCount, color: '#3b82f6' }, { label: '藏品数', value: stats.totalCount, color: '#3b82f6', icon: '📦' },
{ label: '总成本', value: formatMoney(stats.totalCost) + '万', color: '#22c55e' }, { label: '总成本', value: formatMoney(stats.totalCost) + '万', color: '#22c55e', icon: '💰' },
{ label: '总收入', value: formatMoney(stats.totalRevenue) + '万', color: '#06b6d4' }, { label: '总收入', value: formatMoney(stats.totalRevenue) + '万', color: '#06b6d4', icon: '📈' },
{ label: '评级数', value: stats.gradedCount, color: '#8b5cf6' }, { label: '评级数', value: stats.gradedCount, color: '#8b5cf6', icon: '⭐' },
{ label: '预期利润', value: formatMoney(stats.expectedProfit) + '万', color: stats.expectedProfit >= 0 ? '#f59e0b' : '#ef4444' }, { label: '预期利润', value: formatMoney(stats.expectedProfit) + '万', color: stats.expectedProfit >= 0 ? '#f59e0b' : '#ef4444', icon: '🎯' },
{ label: '总利润', value: formatMoney(stats.totalProfit) + '万', color: stats.totalProfit >= 0 ? '#10b981' : '#f43f5e' } { label: '总利润', value: formatMoney(stats.totalProfit) + '万', color: stats.totalProfit >= 0 ? '#10b981' : '#f43f5e', icon: '💵' }
] ]
return ( return (
@ -131,9 +96,9 @@ export default function Home() {
backdropFilter: 'blur(10px)' backdropFilter: 'blur(10px)'
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<img src="/static/images/jiachenlong-logo.png" alt="甲辰收藏" style={{ width: '48px', height: '48px', borderRadius: '50%', objectFit: 'cover' }} /> <img src="/static/images/jiachenlong-logo.png?v=1.2.4" alt="甲辰收藏" style={{ width: '48px', height: '48px', borderRadius: '50%', objectFit: 'cover' }} />
<div> <div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '600' }}>{user?.username || '用户'} <span style={{ color: 'rgba(255,255,255,0.4)', fontSize: '11px', fontWeight: 'normal' }}>ID:{user?.user_code || '-'}</span></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 style={{ color: 'rgba(255,255,255,0.6)', fontSize: '12px' }}>欢迎回来 👋</div>
</div> </div>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '12px' }}> <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '12px' }}>
@ -166,7 +131,7 @@ export default function Home() {
onMouseOut={e => { e.currentTarget.style.transform = 'translateY(0)' }} onMouseOut={e => { e.currentTarget.style.transform = 'translateY(0)' }}
onClick={() => window.location.hash = '#/stats'} 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: 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 style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>{card.label}</div>
</div> </div>
@ -180,171 +145,41 @@ export default function Home() {
<div onClick={() => window.location.hash = '#/add?mode=ocr'} style={{ <div onClick={() => window.location.hash = '#/add?mode=ocr'} style={{
background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)', background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
borderRadius: '12px', borderRadius: '12px',
padding: '12px 16px', padding: '16px',
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', gap: '12px'
textAlign: 'center'
}}> }}>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>藏品录入</div> <div style={{ fontSize: '24px' }}>📷</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>AI识别添加</div> <div>
</div> <div style={{ color: '#fff', fontSize: '15px', fontWeight: '600' }}>AI识别</div>
<div onClick={() => window.location.hash = '#/add?mode=deal'} style={{ <div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '11px' }}>拍照识别藏品</div>
background: 'linear-gradient(135deg, #ec4899 0%, #db2777 100%)', </div>
borderRadius: '12px',
padding: '12px 16px',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center'
}}>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>行情录入</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>录入成交行情</div>
</div>
<div onClick={() => window.location.hash = '#/news?type=seek'} style={{
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
borderRadius: '12px',
padding: '12px 16px',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center'
}}>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>发布寻号</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>发布寻配需求</div>
</div> </div>
<div onClick={() => window.location.hash = '#/add?mode=manual'} style={{ <div onClick={() => window.location.hash = '#/add?mode=manual'} style={{
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
borderRadius: '12px', borderRadius: '12px',
padding: '12px 16px', padding: '16px',
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', gap: '12px'
textAlign: 'center'
}}> }}>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>发布藏品</div> <div style={{ fontSize: '24px' }}></div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>手动发布</div>
</div>
</div>
</div>
{/* 寻配号数据 */}
<div style={{ marginBottom: '20px' }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', marginBottom: '12px', paddingLeft: '4px' }}>🔍 寻配号数据</div>
<div onClick={() => window.location.hash = '#/news?type=seek'} style={{
background: 'linear-gradient(135deg, rgba(245,158,11,0.15) 0%, rgba(245,158,11,0.05) 100%)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(245,158,11,0.2)',
cursor: 'pointer'
}}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px', textAlign: 'center' }}>
<div> <div>
<div style={{ color: '#fbbf24', fontSize: '18px', fontWeight: '700' }}>{seekStats.seekCount || 0}</div> <div style={{ color: '#fff', fontSize: '15px', fontWeight: '600' }}>手动录入</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>寻号需求</div> <div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '11px' }}>添加新藏品</div>
</div>
<div>
<div style={{ color: '#34d399', fontSize: '18px', fontWeight: '700' }}>{seekStats.userMatchedCount || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>我的匹配</div>
</div>
<div>
<div style={{ color: '#a78bfa', fontSize: '18px', fontWeight: '700' }}>{seekStats.totalMatchedCount || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>总共匹配</div>
</div> </div>
</div> </div>
</div> </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(3, 1fr)', gap: '8px' }}>
<div style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.15) 0%, rgba(59,130,246,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(59,130,246,0.2)', textAlign: 'center' }}>
<div style={{ color: '#60a5fa', fontSize: '20px', fontWeight: '700' }}>{yichensStats.total || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>总帖子</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(16,185,129,0.15) 0%, rgba(16,185,129,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(16,185,129,0.2)', textAlign: 'center' }}>
<div style={{ color: '#34d399', fontSize: '20px', fontWeight: '700' }}>{yichensStats.deals || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>出售</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(245,158,11,0.15) 0%, rgba(245,158,11,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(245,158,11,0.2)', textAlign: 'center' }}>
<div style={{ color: '#fbbf24', fontSize: '20px', fontWeight: '700' }}>{yichensStats.wants || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>求购</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(139,92,246,0.15) 0%, rgba(139,92,246,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(139,92,246,0.2)', textAlign: 'center' }}>
<div style={{ color: '#a78bfa', fontSize: '20px', fontWeight: '700' }}>{yichensStats.dragons || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>龙钞</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(236,72,153,0.15) 0%, rgba(236,72,153,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(236,72,153,0.2)', textAlign: 'center' }}>
<div style={{ color: '#f472b6', fontSize: '20px', fontWeight: '700' }}>{yichensStats.horses || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>马钞</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(20,184,166,0.15) 0%, rgba(20,184,166,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(20,184,166,0.2)', textAlign: 'center' }}>
<div style={{ color: '#2dd4bf', fontSize: '20px', fontWeight: '700' }}>{yichensStats.tianma || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>天马</div>
</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={{ background: 'linear-gradient(180deg, rgba(99,102,241,0.15) 0%, rgba(99,102,241,0.05) 100%)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(99,102,241,0.2)' }}>
{/* 表头 */}
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '8px' }}>
<div></div>
<div style={{ color: '#22c55e', fontSize: '14px', fontWeight: '600', textAlign: 'center' }}>出售</div>
<div style={{ color: '#f97316', fontSize: '14px', fontWeight: '600', textAlign: 'center' }}>求购</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', fontWeight: '600', textAlign: 'center' }}>合计</div>
</div>
{/* 数据行 */}
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
<div style={{ color: '#ef4444', fontSize: '14px', fontWeight: '500' }}>带4</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.dai4?.deals || 0}</div>
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.dai4?.wants || 0}</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.dai4?.deals || 0) + (dragonStats.dai4?.wants || 0)}</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
<div style={{ color: '#06b6d4', fontSize: '14px', fontWeight: '500' }}>带7号</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu4?.deals || 0}</div>
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu4?.wants || 0}</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu4?.deals || 0) + (dragonStats.wu4?.wants || 0)}</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
<div style={{ color: '#3b82f6', fontSize: '14px', fontWeight: '500' }}>无47</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu47?.deals || 0}</div>
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu47?.wants || 0}</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu47?.deals || 0) + (dragonStats.wu47?.wants || 0)}</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
<div style={{ color: '#a855f7', fontSize: '14px', fontWeight: '500' }}>无247</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu247?.deals || 0}</div>
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu247?.wants || 0}</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu247?.deals || 0) + (dragonStats.wu247?.wants || 0)}</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px' }}>
<div style={{ color: '#ec4899', fontSize: '14px', fontWeight: '500' }}>无347</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu347?.deals || 0}</div>
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu347?.wants || 0}</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu347?.deals || 0) + (dragonStats.wu347?.wants || 0)}</div>
</div>
</div>
</div>
{/* 最新一尘发帖 */}
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', paddingLeft: '4px' }}> <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 style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px' }}>最近藏品</div>
<div onClick={() => window.location.hash = '#/news'} style={{ color: '#3b82f6', fontSize: '12px', cursor: 'pointer' }}>查看全部 </div> <div onClick={() => window.location.hash = '#/list'} style={{ color: '#3b82f6', fontSize: '12px', cursor: 'pointer' }}>查看全部 </div>
</div> </div>
<div style={{ <div style={{
background: 'rgba(255,255,255,0.03)', background: 'rgba(255,255,255,0.03)',
@ -352,30 +187,24 @@ export default function Home() {
border: '1px solid rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.05)',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
{recentPosts.length === 0 ? ( {recentCollections.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>暂无帖子</div> <div style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>暂无藏品</div>
) : ( ) : (
recentPosts.map((item, idx) => ( recentCollections.map((item, idx) => (
<div key={item.post_id || idx} onClick={() => window.open(item.url, '_blank')} style={{ <div key={item.id || idx} onClick={() => window.location.hash = '#/detail?id=' + item.id} style={{
padding: '12px 16px', padding: '12px 16px',
borderBottom: idx < recentPosts.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none', borderBottom: idx < recentCollections.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none',
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center' alignItems: 'center'
}}> }}>
<div style={{ flex: 1 }}> <div>
<div style={{ color: '#fff', fontSize: '14px' }}>{item.title || '无标题'}</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.category || '-'} · {item.author_username || '未知'} · {item.post_time?.substring(0, 16) || ''}</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>
<div style={{ <div style={{ color: item.costPrice ? '#22c55e' : 'rgba(255,255,255,0.3)', fontSize: '13px' }}>
color: item.post_type === 'deal' ? '#10b981' : item.post_type === 'want' ? '#f59e0b' : '#8b5cf6', {item.costPrice ? '¥' + item.costPrice : '-'}
fontSize: '12px',
padding: '2px 8px',
borderRadius: '4px',
background: item.post_type === 'deal' ? 'rgba(16,185,129,0.2)' : item.post_type === 'want' ? 'rgba(245,158,11,0.2)' : 'rgba(139,92,246,0.2)'
}}>
{item.post_type === 'deal' ? '出售' : item.post_type === 'want' ? '求购' : '其他'}
</div> </div>
</div> </div>
)) ))
@ -399,13 +228,13 @@ export default function Home() {
{(isAdmin ? [ {(isAdmin ? [
{ icon: '🏠', label: '首页', hash: '#/', idx: 0 }, { icon: '🏠', label: '首页', hash: '#/', idx: 0 },
{ icon: '📚', label: '藏品', hash: '#/list', idx: 1 }, { icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
{ icon: '', label: '录入', hash: '#/ocr', idx: 2 }, { icon: '', label: '添加', hash: '#/ocr', idx: 2 },
{ icon: '📊', label: '统计', hash: '#/stats', idx: 3 }, { icon: '📊', label: '统计', hash: '#/stats', idx: 3 },
{ icon: '⚙️', label: '管理', hash: '#/admin', idx: 4 } { icon: '⚙️', label: '管理', hash: '#/admin', idx: 4 }
] : [ ] : [
{ icon: '🏠', label: '首页', hash: '#/', idx: 0 }, { icon: '🏠', label: '首页', hash: '#/', idx: 0 },
{ icon: '📚', label: '藏品', hash: '#/list', idx: 1 }, { icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
{ icon: '', label: '录入', hash: '#/ocr', idx: 2 }, { icon: '', label: '添加', hash: '#/ocr', idx: 2 },
{ icon: '📊', label: '统计', hash: '#/stats', idx: 3 } { icon: '📊', label: '统计', hash: '#/stats', idx: 3 }
]).map((item) => ( ]).map((item) => (
<div key={item.idx} onClick={() => window.location.hash = item.hash} style={{ <div key={item.idx} onClick={() => window.location.hash = item.hash} style={{

View File

@ -1,12 +1,5 @@
/**
* List - 藏品列表页面
* Version: 1.2.93x
* 更新
*/
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version' import { APP_VERSION } from '../config/version'
const API_BASE = localStorage.getItem('API_BASE') || ''
export default function List() { export default function List() {
const [collections, setCollections] = useState([]) const [collections, setCollections] = useState([])
@ -24,12 +17,6 @@ export default function List() {
const [isAdmin, setIsAdmin] = useState(false) const [isAdmin, setIsAdmin] = useState(false)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pagination, setPagination] = useState({ total: 0, pages: 1 }) const [pagination, setPagination] = useState({ total: 0, pages: 1 })
const [activeTab, setActiveTab] = useState('collections') // collections-, deals-
const [dealSearch, setDealSearch] = useState('')
const [dealSortField, setDealSortField] = useState('created_at')
const [dealSortOrder, setDealSortOrder] = useState('desc')
const [myDeals, setMyDeals] = useState([])
const [dealsLoading, setDealsLoading] = useState(false)
useEffect(() => { useEffect(() => {
// //
@ -74,56 +61,6 @@ export default function List() {
} }
}, []) }, [])
//
const fetchMyDeals = async () => {
setDealsLoading(true)
const token = localStorage.getItem('token')
const userStr = localStorage.getItem('user')
if (!token || !userStr) {
setDealsLoading(false)
return
}
try {
const user = JSON.parse(userStr)
const res = await fetch(`${API_BASE}/api/deal/list?user_only=true&page_size=500`, {
headers: { 'Authorization': 'Bearer ' + token }
})
const data = await res.json()
const list = data.data || data || []
setMyDeals(Array.isArray(list) ? list : [])
} catch (e) {
console.error('获取行情失败:', e)
}
setDealsLoading(false)
}
// tab
useEffect(() => {
if (activeTab === 'deals') {
fetchMyDeals()
}
}, [activeTab])
//
const filteredDeals = myDeals.filter(deal => {
if (!dealSearch) return true
const s = dealSearch.toLowerCase().trim()
const fields = [deal.title, deal.content, deal.category, deal.packaging, deal.grading_company, deal.grading_score, deal.deal_no, deal.seller, deal.platform, deal.buyer, deal.deal_price?.toString(), deal.deal_date].filter(v => v).map(v => v.toString().toLowerCase())
return fields.some(f => f.includes(s))
}).sort((a, b) => {
let aVal = a[dealSortField] || ''
let bVal = b[dealSortField] || ''
if (dealSortField === 'created_at') {
aVal = new Date(a.created_at).getTime()
bVal = new Date(b.created_at).getTime()
} else if (dealSortField === 'deal_price') {
aVal = a.deal_price || 0
bVal = b.deal_price || 0
}
if (dealSortOrder === 'asc') return aVal > bVal ? 1 : -1
return aVal < bVal ? 1 : -1
})
const fetchCollections = async () => { const fetchCollections = async () => {
setLoading(true) setLoading(true)
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
@ -371,7 +308,7 @@ export default function List() {
bVal = rarityOrder[bVal] || 0 bVal = rarityOrder[bVal] || 0
} else if (sortField === 'numberCategory') { } else if (sortField === 'numberCategory') {
// //
const numberCategoryOrder = { '无2347': 1, '无347': 2, '无247': 3, '无47': 4, '带7号': 5, '带4号': 6, '其他': 7 } const numberCategoryOrder = { '无2347': 1, '无347': 2, '无247': 3, '无47': 4, '无4': 5, '带4': 6, '其他': 7 }
aVal = numberCategoryOrder[aVal] || 99 aVal = numberCategoryOrder[aVal] || 99
bVal = numberCategoryOrder[bVal] || 99 bVal = numberCategoryOrder[bVal] || 99
} }
@ -414,7 +351,7 @@ export default function List() {
} }
const getNumberCategoryColor = (cat) => { const getNumberCategoryColor = (cat) => {
const colors = { '无2347': '#8b5cf6', '无347': '#ef4444', '无247': '#fbbf24', '无47': '#3b82f6', '带7号': '#06b6d4', '带4': '#22c55e', '其他': '#64748b' }; const colors = { '无2347': '#8b5cf6', '无347': '#ef4444', '无247': '#fbbf24', '无47': '#3b82f6', '无4': '#06b6d4', '带4': '#22c55e', '其他': '#64748b' };
return colors[cat] || '#64748b'; return colors[cat] || '#64748b';
}; };
@ -518,18 +455,7 @@ export default function List() {
<div style={{ minHeight: '100vh', overflowY: 'auto', background: '#0f172a', paddingBottom: '70px' }}> <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={{ 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={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<div style={{ color: '#fff', fontSize: '20px' }}> <div style={{ color: '#fff', fontSize: '20px', }}>我的藏品 <span style={{ fontSize: '14px', color: '#fbbf24' }}>({filteredCollections.length})</span></div>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => setActiveTab('collections')}
style={{ padding: '6px 16px', background: activeTab === 'collections' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'collections' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
我的藏品 ({filteredCollections.length})
</button>
<button onClick={() => setActiveTab('deals')}
style={{ padding: '6px 16px', background: activeTab === 'deals' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'deals' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
我的行情 ({filteredDeals.length})
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{filter && filterType && ( {filter && filterType && (
<button <button
@ -556,8 +482,6 @@ export default function List() {
</div> </div>
)} )}
{activeTab === 'collections' && (
<>
{/* 搜索框 - 全字段搜索 */} {/* 搜索框 - 全字段搜索 */}
<div style={{ position: 'relative', marginBottom: '16px' }}> <div style={{ position: 'relative', marginBottom: '16px' }}>
<input type="text" <input type="text"
@ -633,11 +557,9 @@ export default function List() {
</div> </div>
))} ))}
</div> </div>
</>
)}
{/* 分页组件 - 仅在藏品tab下显示 */} {/* 分页组件 */}
{activeTab === 'collections' && pagination.pages > 1 && ( {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' }}> <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> <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>
@ -654,49 +576,6 @@ export default function List() {
</div> </div>
<div style={{ padding: '16px' }}> <div style={{ padding: '16px' }}>
{/* 行情tab内容 */}
{activeTab === 'deals' && (
<div>
{/* 行情搜索框 */}
<div style={{ marginBottom: '12px' }}>
<input type="text"
placeholder="🔍 搜索行情..."
value={dealSearch}
onChange={e => setDealSearch(e.target.value)}
style={{
width: '100%',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
color: '#fff',
padding: '10px 12px',
borderRadius: '8px',
fontSize: '14px',
outline: 'none',
boxSizing: 'border-box'
}}
/>
</div>
{dealsLoading ? (
<div style={{ textAlign: 'center', padding: '60px', color: '#64748b' }}>加载中...</div>
) : filteredDeals.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<div style={{ fontSize: '50px', opacity: 0.3 }}>📊</div>
<div style={{ color: '#64748b', marginTop: '16px' }}>{dealSearch ? '没有匹配的行情' : '暂无行情记录'}</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{filteredDeals.map(deal => (
<DealListItem key={deal.id} deal={deal} onRefresh={fetchMyDeals} />
))}
</div>
)}
</div>
)}
{/* 藏品tab内容 */}
{activeTab === 'collections' && (
<>
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: '60px', color: '#64748b' }}>加载中...</div> <div style={{ textAlign: 'center', padding: '60px', color: '#64748b' }}>加载中...</div>
) : filteredCollections.length === 0 ? ( ) : filteredCollections.length === 0 ? (
@ -718,237 +597,7 @@ export default function List() {
) : ( ) : (
filteredCollections.map(item => <ListItem key={item.id} item={item} />) filteredCollections.map(item => <ListItem key={item.id} item={item} />)
)} )}
</>
)}
</div> </div>
</div> </div>
) )
} }
//
function DealListItem({ deal, onRefresh }) {
const [expanded, setExpanded] = useState(false)
const [editing, setEditing] = useState(false)
const [editForm, setEditForm] = useState({})
const API_BASE = localStorage.getItem('API_BASE') || ''
const openEdit = () => {
// content
let platform = '', seller = '', buyer = ''
if (deal.content) {
const platformMatch = deal.content.match(/平台:\s*([^\n]+)/)
const sellerMatch = deal.content.match(/出售者:\s*([^\n]+)/)
const buyerMatch = deal.content.match(/购买者:\s*([^\n]+)/)
if (platformMatch) platform = platformMatch[1].trim()
if (sellerMatch) seller = sellerMatch[1].trim()
if (buyerMatch) buyer = buyerMatch[1].trim()
}
setEditForm({
title: deal.title,
content: deal.content,
deal_price: deal.deal_price,
deal_date: deal.deal_date ? (typeof deal.deal_date === 'string' ? deal.deal_date.split('T')[0] : '') : '',
packaging: deal.packaging || '单张',
is_graded: deal.is_graded || false,
grading_company: deal.grading_company || '',
grading_score: deal.grading_score || '',
category: deal.category || '',
deal_no: deal.deal_no || '',
platform: platform,
seller: seller,
buyer: buyer
})
setEditing(true)
}
const saveEdit = async () => {
const token = localStorage.getItem('token')
try {
await fetch(`${API_BASE}/api/deal/${deal.id}`, {
method: 'PUT',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(editForm)
})
setEditing(false)
onRefresh()
} catch(e) { alert('保存失败') }
}
const deleteDeal = async () => {
if (!confirm('确定删除这条行情?')) return
const token = localStorage.getItem('token')
try {
await fetch(`${API_BASE}/api/deal/${deal.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
})
onRefresh()
} catch(e) { alert('删除失败') }
}
return (
<div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: '10px', padding: '12px', border: '1px solid rgba(255,255,255,0.1)' }}>
{/* 简要展示 - 两行显示关键信息 */}
<div onClick={() => setExpanded(!expanded)} style={{ cursor: 'pointer', padding: '10px', background: 'rgba(0,0,0,0.2)', borderRadius: '8px' }}>
{/* 第一行:成交日期(月-日)+ 冠字号 + 包装 + 评级分数 + 价格 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px', flexWrap: 'wrap', gap: '4px' }}>
{deal.deal_date && <span style={{ color: '#64748b', fontSize: '11px' }}>{deal.deal_date.slice(5)}</span>}
<span style={{ color: '#fbbf24', fontSize: '13px', fontFamily: 'monospace' }}>{deal.title?.split('-')[0] || '-'}</span>
{deal.packaging && <span style={{ background: 'rgba(139,92,246,0.15)', color: '#a78bfa', fontSize: '10px', padding: '2px 6px', borderRadius: '4px' }}>{deal.packaging}</span>}
{deal.grading_company && <span style={{ background: 'rgba(6,182,212,0.15)', color: '#06b6d4', fontSize: '10px', padding: '2px 6px', borderRadius: '4px' }}>{deal.grading_score}</span>}
<span style={{ color: '#22c55e', fontSize: '14px', fontWeight: 'bold' }}>¥{deal.deal_price?.toLocaleString()}</span>
</div>
{/* 第二行:编号 + 分类 + 大小号 + 展开箭头 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
{deal.deal_no && <span style={{ color: '#fff', fontSize: '10px' }}>{deal.deal_no}</span>}
{deal.category && <span style={{ background: 'rgba(59,130,246,0.15)', color: '#60a5fa', fontSize: '10px', padding: '2px 6px', borderRadius: '4px' }}>{deal.category}</span>}
{deal.content && (() => {
const sizeMatch = deal.content.match(/大小号:\s*([^\n]+)/)
return sizeMatch && <span style={{ background: 'rgba(34,197,94,0.15)', color: '#22c55e', fontSize: '10px', padding: '2px 6px', borderRadius: '4px' }}>{sizeMatch[1]}</span>
})()}
</div>
<span style={{ color: '#64748b', fontSize: '12px' }}>{expanded ? '▲ 收起' : '▼展开'}</span>
</div>
</div>
{/* 展开详情 */}
{expanded && (
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
{editing ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* 冠字号 */}
<div>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>冠字号</div>
<input value={editForm.title?.split('-')[0] || ''} onChange={e => setEditForm({...editForm, title: `${e.target.value}${editForm.deal_price}`})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '14px', fontFamily: 'monospace' }} />
</div>
{/* 价格和日期 */}
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>价格</div>
<input type="number" value={editForm.deal_price} onChange={e => {
const val = parseFloat(e.target.value) || 0
const serial = editForm.title?.split('-')[0] || ''
setEditForm({...editForm, deal_price: val, title: `${serial}${val}`})
}} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#22c55e', fontSize: '14px' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>日期</div>
<input type="date" value={editForm.deal_date} onChange={e => setEditForm({...editForm, deal_date: e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '14px' }} />
</div>
</div>
{/* 包装和分类 */}
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>包装</div>
<select value={editForm.packaging} onChange={e => setEditForm({...editForm, packaging: e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
<option value="单张">单张</option>
<option value="标十">标十</option>
<option value="标百">标百</option>
</select>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>分类</div>
<input value={editForm.category || ''} onChange={e => setEditForm({...editForm, category: e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
</div>
{/* 评级 */}
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>评级机构</div>
<select value={editForm.grading_company || ''} onChange={e => setEditForm({...editForm, grading_company: e.target.value, is_graded: !!e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
<option value="">未评级</option>
<option value="PCGS">PCGS</option>
<option value="PMG">PMG</option>
<option value="ACG">ACG</option>
<option value="爱藏">爱藏</option>
</select>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>评级分数</div>
<input value={editForm.grading_score || ''} onChange={e => setEditForm({...editForm, grading_score: e.target.value})} placeholder="如: PC69, 67+" style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#06b6d4', fontSize: '13px' }} />
</div>
</div>
{/* 平台和出售者购买者 */}
<div>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>平台</div>
<select value={editForm.platform || ''} onChange={e => setEditForm({...editForm, platform: e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
<option value="">请选择</option>
<option value="淘宝">淘宝</option>
<option value="咸鱼">咸鱼</option>
<option value="抖音">抖音</option>
<option value="快手">快手</option>
<option value="微拍堂">微拍堂</option>
<option value="拼多多">拼多多</option>
<option value="一尘">一尘</option>
<option value="爱藏">爱藏</option>
<option value="其他">其他</option>
</select>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>出售者</div>
<input value={editForm.seller || ''} onChange={e => setEditForm({...editForm, seller: e.target.value})} placeholder="请输入出售者" style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>购买者</div>
<input value={editForm.buyer || ''} onChange={e => setEditForm({...editForm, buyer: e.target.value})} placeholder="请输入购买者" style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
</div>
{/* 备注 */}
<div>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>备注</div>
<textarea value={editForm.content || ''} onChange={e => setEditForm({...editForm, content: e.target.value})} rows={2} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px', boxSizing: 'border-box' }} />
</div>
{/* 保存取消按钮 */}
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={saveEdit} style={{ flex: 1, padding: '12px', background: '#22c55e', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>保存</button>
<button onClick={() => setEditing(false)} style={{ flex: 1, padding: '12px', background: '#64748b', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>取消</button>
</div>
</div>
) : (
<div>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '8px' }}>
{deal.deal_no && <div>编号: <span style={{ color: '#fbbf24' }}>{deal.deal_no}</span></div>}
<div>冠字号: <span style={{ color: '#fbbf24' }}>{deal.title?.split('-')[0] || '-'}</span></div>
<div>价格: <span style={{ color: '#22c55e' }}>¥{deal.deal_price?.toLocaleString()}</span></div>
<div>日期: {deal.deal_date}</div>
<div>包装: {deal.packaging || '单张'}</div>
<div>分类: {deal.category || '-'}</div>
<div>评级: {deal.grading_company ? `${deal.grading_company} ${deal.grading_score || ''}` : '未评级'}</div>
{deal.content && (() => {
const platformMatch = deal.content.match(/平台:\s*([^\n]+)/)
const sellerMatch = deal.content.match(/出售者:\s*([^\n]+)/)
const buyerMatch = deal.content.match(/购买者:\s*([^\n]+)/)
const sizeMatch = deal.content.match(/大小号:\s*([^\n]+)/)
const tailMatch = deal.content.match(/尾号:\s*([^\n]+)/)
return (
<div style={{ marginTop: '4px' }}>
{platformMatch && <div>平台: {platformMatch[1]}</div>}
{sellerMatch && <div>出售者: {sellerMatch[1]}</div>}
{buyerMatch && buyerMatch[1].trim() !== '-' && <div>购买者: {buyerMatch[1]}</div>}
{tailMatch && <div>尾号: {tailMatch[1]}</div>}
{sizeMatch && <div>大小号: <span style={{ color: '#60a5fa' }}>{sizeMatch[1]}</span></div>}
</div>
)
})()}
{deal.content && <div style={{ marginTop: '8px', color: '#64748b', fontSize: '11px' }}>{deal.content}</div>}
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<button onClick={openEdit} style={{ flex: 1, padding: '8px', background: 'rgba(59,130,246,0.2)', color: '#60a5fa', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '12px' }}>编辑</button>
<button onClick={deleteDeal} style={{ flex: 1, padding: '8px', background: 'rgba(239,68,68,0.2)', color: '#ef4444', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '12px' }}>删除</button>
</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@ -1,9 +1,3 @@
/**
* Login - 登录页面
* Version: 1.2.85x
* 更新
*/
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version' import { APP_VERSION } from '../config/version'
import { api } from '../utils/api' import { api } from '../utils/api'
@ -24,8 +18,7 @@ export default function Login() {
confirmPassword: '', confirmPassword: '',
email: '', email: '',
phone: '', phone: '',
verifyCode: '', verifyCode: ''
inviteCode: ''
}) })
const [registerLoading, setRegisterLoading] = useState(false) const [registerLoading, setRegisterLoading] = useState(false)
const [registerError, setRegisterError] = useState('') const [registerError, setRegisterError] = useState('')
@ -155,9 +148,6 @@ export default function Login() {
if (registerData.email) { if (registerData.email) {
payload.email = registerData.email payload.email = registerData.email
} }
if (registerData.inviteCode) {
payload.inviteCode = registerData.inviteCode
}
const res = await fetch('/api/auth/register', { const res = await fetch('/api/auth/register', {
method: 'POST', method: 'POST',
@ -175,7 +165,7 @@ export default function Login() {
alert('注册成功!请登录') alert('注册成功!请登录')
setShowRegister(false) setShowRegister(false)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '', inviteCode: '' }) setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '' })
setCodeSent(false) setCodeSent(false)
setCodeCountdown(0) setCodeCountdown(0)
generateCaptcha() generateCaptcha()
@ -594,7 +584,7 @@ export default function Login() {
</div> </div>
{/* 确认密码 */} {/* 确认密码 */}
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '24px' }}>
<input <input
type="password" type="password"
value={registerData.confirmPassword} value={registerData.confirmPassword}
@ -613,26 +603,6 @@ export default function Login() {
/> />
</div> </div>
{/* 邀请码(选填) */}
<div style={{ marginBottom: '24px' }}>
<input
type="text"
value={registerData.inviteCode}
onChange={(e) => setRegisterData(prev => ({ ...prev, inviteCode: 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' }}> <div style={{ marginBottom: '20px', display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
@ -656,7 +626,7 @@ export default function Login() {
<button <button
onClick={() => { onClick={() => {
setShowRegister(false) setShowRegister(false)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '', inviteCode: '' }) setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '' })
setRegisterError('') setRegisterError('')
setCodeSent(false) setCodeSent(false)
setCodeCountdown(0) setCodeCountdown(0)

File diff suppressed because it is too large Load Diff

View File

@ -1,224 +0,0 @@
/**
* News_YichensBoard - 一尘帖子页面
* Version: 1.2.75x
* 更新
*/
import React, { useState, useEffect } from 'react'
export default function YichensBoard() {
const [todayStats, setTodayStats] = useState({ total: 0, deals: 0, wants: 0, others: 0 })
const [todayCategory, setTodayCategory] = useState([])
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(false)
const [expandedPosts, setExpandedPosts] = useState({})
const [postTypeFilter, setPostTypeFilter] = useState('all')
const [categoryFilter, setCategoryFilter] = useState('')
const [page, setPage] = useState(1)
const [totalPosts, setTotalPosts] = useState(0)
const [searchKeyword, setSearchKeyword] = useState('')
const API_BASE = localStorage.getItem('API_BASE') || ''
const today = new Date()
const dateStr = today.getFullYear() + '/' + (today.getMonth() + 1) + '/' + today.getDate()
useEffect(() => { fetchTodayStats(); fetchPosts(1, '') }, [])
const fetchTodayStats = async () => {
try {
const res = await fetch(API_BASE + '/api/yichens/stats/today')
setTodayStats(await res.json())
} catch(e) { console.error(e) }
}
const fetchTodayCategory = async () => {
try {
const res = await fetch(API_BASE + '/api/yichens/stats/today-category')
setTodayCategory(await res.json())
} catch(e) { console.error(e) }
}
const fetchPosts = async (p, cat) => {
setLoading(true)
const currentPage = p !== undefined ? p : page
const currentCat = cat !== undefined ? cat : categoryFilter
let url = API_BASE + '/api/yichens/posts?limit=390&offset=' + ((currentPage - 1) * 390)
if (postTypeFilter === 'deal') url += '&post_type=deal'
else if (postTypeFilter === 'want') url += '&post_type=want'
else if (postTypeFilter === 'other') url += '&post_type=normal'
try {
const res = await fetch(url)
let data = await res.json() || []
if (currentCat) {
if (currentCat === '龙') {
data = data.filter(p => p.category && (p.category.includes('龙') || p.category.includes('纪念钞')))
} else if (currentCat === '蛇') {
data = data.filter(p => p.category && p.category.includes('蛇'))
} else if (currentCat === '马') {
data = data.filter(p => p.category && p.category.includes('马'))
} else if (currentCat === '其他') {
data = data.filter(p => p.category && !p.category.includes('龙') && !p.category.includes('纪念钞') && !p.category.includes('蛇') && !p.category.includes('马'))
}
}
// -
if (searchKeyword) {
const kw = searchKeyword.trim()
if (kw) {
data = data.filter(p =>
(p.title && p.title.includes(kw)) ||
(p.content && p.content.includes(kw)) ||
(p.category && p.category.includes(kw)) ||
(p.contact && p.contact.includes(kw))
)
}
}
setPosts(data)
setTotalPosts(todayStats.total || 0)
} catch { setPosts([]) }
setLoading(false)
}
useEffect(() => { if (todayStats.total > 0) fetchTodayCategory() }, [todayStats])
//
useEffect(() => {
setPage(1)
fetchPosts(1, '')
}, [searchKeyword])
const togglePost = id => setExpandedPosts(p => ({ ...p, [id]: !p[id] }))
const StatCard = ({ label, value, color, onClick }) => (
<div onClick={onClick} style={{
background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)',
borderRadius: 12, padding: '12px 8px', border: '1px solid #374151',
cursor: onClick ? 'pointer' : 'default',
textAlign: 'center'
}}>
<div style={{ color: '#9ca3af', fontSize: 11, marginBottom: 4 }}>{label}</div>
<div style={{ color: color || '#fff', fontSize: 20, fontWeight: 'bold' }}>{value}</div>
</div>
)
return (
<div style={{ padding: '0' }}>
<div style={{ marginBottom: 16 }}>
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 10 }}>
📈 连体钞/纪念钞 <span style={{ color: '#9ca3af', fontWeight: 400 }}>{dateStr}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
<StatCard label='全部' value={todayStats.total} color='#3b82f6' onClick={() => { setPostTypeFilter('all'); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }} />
<StatCard label='出售' value={todayStats.deals} color='#10b981' onClick={() => { setPostTypeFilter('deal'); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }} />
<StatCard label='求购' value={todayStats.wants} color='#f59e0b' onClick={() => { setPostTypeFilter('want'); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }} />
<StatCard label='其他' value={todayStats.others || 0} color='#8b5cf6' onClick={() => { setPostTypeFilter('other'); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }} />
</div>
</div>
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 12, marginBottom: 20, border: '1px solid #374151' }}>
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 8 }}>📊 今日分类统计</div>
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
{todayCategory.map(cat => (
<span key={cat.category} style={{ padding: '4px 10px', background: 'rgba(59,130,246,0.2)', borderRadius: 16, color: '#93c5fd', fontSize: 11, whiteSpace: 'nowrap' }}>
{cat.category} ({cat.count})
</span>
))}
</div>
</div>
{/* 搜索框 */}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
placeholder="搜索标题、内容、分类..."
value={searchKeyword}
onChange={(e) => { setSearchKeyword(e.target.value); setPage(1); fetchPosts(1, '') }}
style={{ flex: 1, padding: '10px 14px', borderRadius: 8, border: '1px solid #374151', background: '#1f2937', color: '#fff', fontSize: 13, outline: 'none' }}
/>
<button
onClick={() => { setSearchKeyword(''); setPage(1); fetchPosts(1, '') }}
style={{ padding: '10px 16px', borderRadius: 8, border: 'none', background: '#4b5563', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
清空
</button>
</div>
{searchKeyword && <div style={{ color: '#9ca3af', fontSize: 12, marginTop: 6 }}>搜索: "{searchKeyword}"找到 {posts.length} 条结果</div>}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
{[{key:'all',label:'全部'},{key:'deal',label:'出售'},{key:'want',label:'求购'},{key:'other',label:'其他'}].map(k => (
<button key={k.key} onClick={() => { setPostTypeFilter(k.key==='other'?'other':k.key); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }}
style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
background: postTypeFilter===k.key ? 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
{k.label}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
{[{key:'',label:'全部'},{key:'龙',label:'龙钞'},{key:'蛇',label:'蛇钞'},{key:'马',label:'马钞'},{key:'其他',label:'其他'}].map(k => (
<button key={k.key} onClick={() => { setCategoryFilter(k.key); setPage(1); fetchPosts(1, k.key) }}
style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
background: categoryFilter===k.key ? 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
{k.label}
</button>
))}
</div>
{loading ? <div style={{textAlign:'center',color:'#9ca3af',padding:40}}>加载中...</div> : (
<div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{posts.map(post => (
<div key={post.post_id} style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 16, border: '1px solid #374151' }}>
<div onClick={() => togglePost(post.post_id)} style={{ cursor: 'pointer' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ color: '#fff', fontSize: 15, fontWeight: 500, flex: 1 }}>{post.title||'无标题'}</span>
<div style={{ display: 'flex', gap: 4 }}>
<span style={{ color: post.post_type==='deal'?'#10b981':post.post_type==='want'?'#f59e0b':post.post_type==='normal'?'#8b5cf6':'#9ca3af', fontSize: 12 }}>
{post.post_type==='deal'?'出售':post.post_type==='want'?'求购':post.post_type==='normal'?'其他':'普通'}
</span>
<span style={{ padding: '2px 8px', borderRadius: 4, fontSize: 11, background: post.category?.includes('龙') ? 'rgba(251,191,36,0.4)' : post.category?.includes('马') ? 'rgba(180,83,9,0.4)' : post.category?.includes('蛇') ? 'rgba(249,168,212,0.4)' : 'rgba(139,92,246,0.4)', color: post.category?.includes('龙') ? '#fde047' : post.category?.includes('马') ? '#d97706' : post.category?.includes('蛇') ? '#fbcfe8' : '#c4b5fd' }}>
{post.category || '-'}
</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#9ca3af', fontSize: 12 }}>
<span>{post.author_username||'未知'}</span>
<span>{post.post_time?.substring(0,16)||''}</span>
</div>
</div>
{expandedPosts[post.post_id] && post.content && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid #374151' }}>
<div style={{ color: '#e2e8f0', fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 300, overflowY: 'auto' }}>
{post.content}
</div>
{post.url && <a href={post.url} target='_blank' rel='noopener noreferrer' style={{ display:'inline-block', marginTop:12, padding:'8px 16px', background:'rgba(59,130,246,0.2)', borderRadius:8, color:'#60a5fa', textDecoration:'none', fontSize:13 }}>查看原帖</a>}
</div>
)}
</div>
))}
</div>
{/* 分页按钮移到页面底部 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: '#1e293b', borderRadius: 12, marginTop: 16 }}>
<div style={{ color: '#9ca3af', fontSize: 12 }}>
{totalPosts} 条帖子{Math.ceil(totalPosts / 390)} 当前第 {page}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{page > 1 ? (
<button onClick={() => { const newPage = page - 1; setPage(newPage); fetchPosts(newPage, categoryFilter) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>上一页</button>
) : (
<span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>上一页</span>
)}
{posts.length >= 390 ? (
<button onClick={() => { const newPage = page + 1; setPage(newPage); fetchPosts(newPage, categoryFilter) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>下一页</button>
) : (
<span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>下一页</span>
)}
</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

@ -1,9 +1,3 @@
/**
* Settings - 设置页面
* Version: 1.2.75x
* 更新
*/
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'

View File

@ -1,9 +1,4 @@
/** // -
* Stats - 统计页面
* Version: 1.2.70x
* 更新
*/
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version' import { APP_VERSION } from '../config/version'
@ -108,7 +103,7 @@ export default function Stats() {
status: { 'in_collection': '#22c55e', 'selling': '#f59e0b', 'sold': '#ef4444', 'grading': '#8b5cf6', 'repairing': '#f97316', 'transit': '#06b6d4', 'seeking': '#ec4899' }, status: { 'in_collection': '#22c55e', 'selling': '#f59e0b', 'sold': '#ef4444', 'grading': '#8b5cf6', 'repairing': '#f97316', 'transit': '#06b6d4', 'seeking': '#ec4899' },
category: { '自持': '#22c55e', '寄存': '#3b82f6', '寄售': '#f59e0b', '共有': '#8b5cf6', '寻号': '#06b6d4', '生肖钞': '#22c55e', '其他': '#64748b' }, category: { '自持': '#22c55e', '寄存': '#3b82f6', '寄售': '#f59e0b', '共有': '#8b5cf6', '寻号': '#06b6d4', '生肖钞': '#22c55e', '其他': '#64748b' },
profitLoss: { 'profit': '#22c55e', 'loss': '#ef4444' }, profitLoss: { 'profit': '#22c55e', 'loss': '#ef4444' },
numberCategory: { '无2347': '#8b5cf6', '无347': '#ef4444', '无247': '#fbbf24', '无47': '#3b82f6', '带7号': '#06b6d4', '带4': '#22c55e', '其他': '#64748b' }, numberCategory: { '无2347': '#8b5cf6', '无347': '#ef4444', '无247': '#fbbf24', '无47': '#3b82f6', '无4': '#06b6d4', '带4': '#22c55e', '其他': '#64748b' },
version: {}, version: {},
gradingCompany: {}, gradingCompany: {},
gradingScore: {}, gradingScore: {},
@ -151,7 +146,7 @@ export default function Stats() {
return Number(val).toLocaleString('zh-CN') return Number(val).toLocaleString('zh-CN')
} }
const numberCategoryOrder = ['无2347', '无347', '无247', '无47', '带7号', '带4号', '其他'] const numberCategoryOrder = ['无2347', '无347', '无247', '无47', '无4', '带4', '其他']
const getSortedData = (data, type) => { const getSortedData = (data, type) => {
if (type === 'numberCategory') { if (type === 'numberCategory') {
return [...data].sort((a, b) => { return [...data].sort((a, b) => {

View File

@ -1,261 +0,0 @@
/**
* YichensBoard - 一尘看板页面
* Version: 1.2.70x
* 更新
*/
import React, { useState, useEffect } from 'react'
export default function YichensBoard() {
const [todayStats, setTodayStats] = useState({ total: 0, deals: 0, wants: 0, others: 0 })
const [todayCategory, setTodayCategory] = useState([])
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [expandedPosts, setExpandedPosts] = useState({})
const [postTypeFilter, setPostTypeFilter] = useState('all')
const [categoryFilter, setCategoryFilter] = useState('')
const [page, setPage] = useState(1)
const [totalPosts, setTotalPosts] = useState(0)
const [searchKeyword, setSearchKeyword] = useState('')
const today = new Date()
const dateStr = today.getFullYear() + '/' + (today.getMonth() + 1) + '/' + today.getDate()
useEffect(() => {
console.log('YichensBoard: 开始加载数据')
fetchTodayStats()
}, [])
const fetchTodayStats = async () => {
try {
console.log('YichensBoard: 请求 /api/yichens/stats/today')
const res = await fetch('/api/yichens/stats/today')
if (!res.ok) throw new Error('stats API error: ' + res.status)
const data = await res.json()
console.log('YichensBoard: stats data', data)
setTodayStats(data)
} catch(e) {
console.error('YichensBoard: fetchTodayStats error', e)
setError(e.message)
}
}
const fetchPosts = async (p, cat, kw) => {
setLoading(true)
setError(null)
const currentPage = p !== undefined ? p : page
const currentCat = cat !== undefined ? cat : categoryFilter
const searchKw = kw !== undefined ? kw : searchKeyword
let url = '/api/yichens/posts?limit=390&offset=' + ((currentPage - 1) * 390)
if (postTypeFilter === 'deal') url += '&post_type=deal'
else if (postTypeFilter === 'want') url += '&post_type=want'
else if (postTypeFilter === 'other') url += '&post_type=normal'
if (searchKw && searchKw.trim()) url += '&keyword=' + encodeURIComponent(searchKw.trim())
try {
console.log('YichensBoard: 请求 posts', url)
const res = await fetch(url)
if (!res.ok) throw new Error('posts API error: ' + res.status)
let data = await res.json() || []
// data
if (!Array.isArray(data)) {
data = data.posts || data.data || []
}
console.log('YichensBoard: posts data count', data.length)
if (currentCat && Array.isArray(data)) {
if (currentCat === '龙') {
data = data.filter(p => p.category && (p.category.includes('龙') || p.category.includes('纪念钞')))
} else if (currentCat === '蛇') {
data = data.filter(p => p.category && p.category.includes('蛇'))
} else if (currentCat === '马') {
data = data.filter(p => p.category && p.category.includes('马'))
} else if (currentCat === '其他') {
data = data.filter(p => p.category && !p.category.includes('龙') && !p.category.includes('纪念钞') && !p.category.includes('蛇') && !p.category.includes('马'))
}
}
// data
if (!Array.isArray(data)) {
data = data.posts || data.data || []
}
console.log('YichensBoard: posts data count', data.length)
if (currentCat && Array.isArray(data)) {
if (currentCat === '龙') {
data = data.filter(p => p.category && (p.category.includes('龙') || p.category.includes('纪念钞')))
} else if (currentCat === '蛇') {
data = data.filter(p => p.category && p.category.includes('蛇'))
} else if (currentCat === '马') {
data = data.filter(p => p.category && p.category.includes('马'))
} else if (currentCat === '其他') {
data = data.filter(p => p.category && !p.category.includes('龙') && !p.category.includes('纪念钞') && !p.category.includes('蛇') && !p.category.includes('马'))
}
}
// {posts:[], total:xxx} [{},{}]
const postsArray = Array.isArray(data) ? data : (data.posts || [])
const totalCount = data.total || todayStats.total || postsArray.length
setPosts(postsArray)
setTotalPosts(totalCount)
} catch(e) {
console.error('YichensBoard: fetchPosts error', e)
setError(e.message)
setPosts([])
} finally {
setLoading(false)
}
}
useEffect(() => {
if (todayStats.total > 0) {
fetchPosts(1, categoryFilter, searchKeyword)
}
}, [todayStats, categoryFilter, postTypeFilter])
useEffect(() => {
setPage(1)
fetchPosts(1, categoryFilter, searchKeyword)
}, [searchKeyword])
const togglePost = id => setExpandedPosts(p => ({ ...p, [id]: !p[id] }))
const StatCard = ({ label, value, color, onClick }) => (
<div onClick={() => { setLoading(true); if (onClick) onClick(); }}
style={{
background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)',
borderRadius: 12, padding: '12px 8px', border: '1px solid #374151',
cursor: onClick ? 'pointer' : 'default',
textAlign: 'center'
}}>
<div style={{ color: '#9ca3af', fontSize: 11, marginBottom: 4 }}>{label}</div>
<div style={{ color: color || '#fff', fontSize: 20, fontWeight: 'bold' }}>{value}</div>
</div>
)
if (error) {
return (
<div style={{ padding: 20, textAlign: 'center', color: '#ef4444' }}>
<div>加载失败: {error}</div>
<button onClick={() => { setError(null); fetchTodayStats(); }} style={{ marginTop: 10, padding: '8px 16px', background: '#3b82f6', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}>
重试
</button>
</div>
)
}
return (
<div style={{ padding: '0' }}>
<div style={{ marginBottom: 16 }}>
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 10 }}>
📈 连体钞/纪念钞 <span style={{ color: '#9ca3af', fontWeight: 400 }}>{dateStr}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
<StatCard label='全部' value={todayStats.total} color='#3b82f6' onClick={() => { setPostTypeFilter('all'); setCategoryFilter(''); setPage(1); }} />
<StatCard label='出售' value={todayStats.deals} color='#10b981' onClick={() => { setPostTypeFilter('deal'); setCategoryFilter(''); setPage(1); }} />
<StatCard label='求购' value={todayStats.wants} color='#f59e0b' onClick={() => { setPostTypeFilter('want'); setCategoryFilter(''); setPage(1); }} />
<StatCard label='其他' value={todayStats.others || 0} color='#8b5cf6' onClick={() => { setPostTypeFilter('other'); setCategoryFilter(''); setPage(1); }} />
</div>
</div>
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 12, marginBottom: 20, border: '1px solid #374151' }}>
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 8 }}>📊 今日分类统计</div>
<div style={{ color: '#9ca3af', fontSize: 12 }}>: {todayStats.dragons || 0} | : {todayStats.horses || 0} | : {todayStats.snakes || 0} | 天马: {todayStats.tianma || 0}</div>
</div>
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
placeholder="搜索标题、内容、分类..."
value={searchKeyword}
onChange={(e) => { const kw = e.target.value; setSearchKeyword(kw); setPage(1); fetchPosts(1, '', kw) }}
style={{ flex: 1, padding: '10px 14px', borderRadius: 8, border: '1px solid #374151', background: '#1f2937', color: '#fff', fontSize: 13, outline: 'none' }}
/>
<button
onClick={() => { setSearchKeyword(''); setPage(1); fetchPosts(1, '') }}
style={{ padding: '10px 16px', borderRadius: 8, border: 'none', background: '#4b5563', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
清空
</button>
</div>
{searchKeyword && <div style={{ color: '#9ca3af', fontSize: 12, marginTop: 6 }}>搜索: "{searchKeyword}"共找到 {totalPosts} 条结果</div>}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
{[{key:'all',label:'全部'},{key:'deal',label:'出售'},{key:'want',label:'求购'},{key:'other',label:'其他'}].map(k => (
<button key={k.key} onClick={() => { setPostTypeFilter(k.key==='other'?'other':k.key); setCategoryFilter(''); setPage(1); }}
style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
background: postTypeFilter===k.key ? 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
{k.label}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
{[{key:'',label:'全部'},{key:'龙',label:'龙钞'},{key:'蛇',label:'蛇钞'},{key:'马',label:'马钞'},{key:'其他',label:'其他'}].map(k => (
<button key={k.key} onClick={() => { setCategoryFilter(k.key); setPage(1); }}
style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
background: categoryFilter===k.key ? 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
{k.label}
</button>
))}
</div>
{loading ? <div style={{textAlign:'center',color:'#9ca3af',padding:40}}>加载中...</div> : (
<div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{posts.length === 0 ? (
<div style={{ textAlign: 'center', color: '#9ca3af', padding: 40 }}>暂无帖子数据</div>
) : (
posts.map(post => (
<div key={post.post_id} style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 16, border: '1px solid #374151' }}>
<div onClick={() => togglePost(post.post_id)} style={{ cursor: 'pointer' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ color: '#fff', fontSize: 15, fontWeight: 500, flex: 1 }}>{post.title||'无标题'}</span>
<div style={{ display: 'flex', gap: 4 }}>
<span style={{ color: post.post_type==='deal'?'#10b981':post.post_type==='want'?'#f59e0b':post.post_type==='normal'?'#8b5cf6':'#9ca3af', fontSize: 12 }}>
{post.post_type==='deal'?'出售':post.post_type==='want'?'求购':post.post_type==='normal'?'其他':'普通'}
</span>
<span style={{ padding: '2px 8px', borderRadius: 4, fontSize: 11, background: post.category?.includes('龙') ? 'rgba(251,191,36,0.4)' : post.category?.includes('马') ? 'rgba(180,83,9,0.4)' : post.category?.includes('蛇') ? 'rgba(249,168,212,0.4)' : 'rgba(139,92,246,0.4)', color: post.category?.includes('龙') ? '#fde047' : post.category?.includes('马') ? '#d97706' : post.category?.includes('蛇') ? '#fbcfe8' : '#c4b5fd' }}>
{post.category || '-'}
</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#9ca3af', fontSize: 12 }}>
<span>{post.author_username||'未知'}</span>
<span>{post.post_time?.substring(0,16)||''}</span>
</div>
</div>
{expandedPosts[post.post_id] && post.content && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid #374151' }}>
<div style={{ color: '#e2e8f0', fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 300, overflowY: 'auto' }}>
{post.content}
</div>
{post.url && <a href={post.url} target='_blank' rel='noopener noreferrer' style={{ display:'inline-block', marginTop:12, padding:'8px 16px', background:'rgba(59,130,246,0.2)', borderRadius:8, color:'#60a5fa', textDecoration:'none', fontSize:13 }}>查看原帖</a>}
</div>
)}
</div>
))
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: '#1e293b', borderRadius: 12, marginTop: 16 }}>
<div style={{ color: '#9ca3af', fontSize: 12 }}>
{totalPosts} 条结果{Math.ceil(totalPosts / 390)} 当前第 {page}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{page > 1 ? (
<button onClick={() => { const newPage = page - 1; setPage(newPage); fetchPosts(newPage, categoryFilter, searchKeyword) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>上一页</button>
) : (
<span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>上一页</span>
)}
{posts.length >= 390 && totalPosts > page * 390 ? (
<button onClick={() => { const newPage = page + 1; setPage(newPage); fetchPosts(newPage, categoryFilter, searchKeyword) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>下一页</button>
) : (
<span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>下一页</span>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -1,128 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import Table, MetaData
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, date
from app.core.database import get_db, engine
from app.models.models import User
from app.routers.auth import get_current_user
router = APIRouter(prefix="/api/news", tags=["资讯"])
metadata = MetaData()
# 分类表
categories_table = Table('news_categories', metadata, autoload_with=engine)
news_table = Table('news', metadata, autoload_with=engine)
user_posts_table = Table('user_posts', metadata, autoload_with=engine)
users_table = Table('users', metadata, autoload_with=engine)
deals_table = Table('deals', metadata, autoload_with=engine)
notifications_table = Table('notifications', metadata, autoload_with=engine)
# ============ 获取分类 ============
@router.get("/categories")
def get_categories(db: Session = Depends(get_db)):
results = db.query(categories_table).order_by(categories_table.c.sort_order).all()
return [dict(r._mapping) for r in results]
# ============ 获取资讯 ============
@router.get("")
def get_news(
category_id: Optional[int] = None,
page: int = 1,
limit: int = 20,
db: Session = Depends(get_db)
):
query = db.query(news_table)
if category_id:
query = query.filter(news_table.c.category_id == category_id)
offset = (page - 1) * limit
results = query.order_by(news_table.c.created_at.desc()).offset(offset).limit(limit).all()
return [dict(r._mapping) for r in results]
# ============ 获取用户发布 ============
@router.get("/posts")
def get_posts(
post_type: Optional[str] = None,
status: str = "active",
page: int = 1,
limit: int = 20,
db: Session = Depends(get_db)
):
query = db.query(user_posts_table).filter(user_posts_table.c.status == status)
if post_type:
query = query.filter(user_posts_table.c.post_type == post_type)
offset = (page - 1) * limit
results = query.order_by(user_posts_table.c.created_at.desc()).offset(offset).limit(limit).all()
return [dict(r._mapping) for r in results]
# ============ 创建发布 ============
class PostCreate(BaseModel):
post_type: str
title: str
content: Optional[str] = None
zodiac_type: Optional[str] = None
packaging: Optional[str] = None
@router.post("/posts")
def create_post(
post: PostCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
result = db.execute(user_posts_table.insert().values(
user_id=current_user.f99_90_id,
post_type=post.post_type,
title=post.title,
content=post.content,
zodiac_type=post.zodiac_type,
packaging=post.packaging,
status="pending"
))
db.commit()
return {"success": True, "id": result.inserted_primary_key[0]}
# ============ 成交数据 ============
@router.get("/deals")
def get_deals(
zodiac_type: Optional[str] = None,
limit: int = 20,
db: Session = Depends(get_db)
):
query = db.query(deals_table)
if zodiac_type:
query = query.filter(deals_table.c.zodiac_type == zodiac_type)
results = query.order_by(deals_table.c.deal_date.desc()).limit(limit).all()
return [dict(r._mapping) for r in results]
# ============ 通知 ============
@router.get("/notifications")
def get_notifications(limit: int = 10, db: Session = Depends(get_db)):
results = db.query(notifications_table).filter(
notifications_table.c.is_published == True
).order_by(notifications_table.c.created_at.desc()).limit(limit).all()
return [dict(r._mapping) for r in results]
# ============ 首页数据 ============
@router.get("/home")
def get_home(db: Session = Depends(get_db)):
# 推荐发布
posts = db.query(user_posts_table).filter(
user_posts_table.c.status == "active"
).order_by(user_posts_table.c.created_at.desc()).limit(10).all()
# 成交
deals = db.query(deals_table).order_by(
deals_table.c.deal_date.desc()
).limit(10).all()
# 通知
notices = db.query(notifications_table).filter(
notifications_table.c.is_published == True
).order_by(notifications_table.c.created_at.desc()).limit(5).all()
return {
"posts": [dict(p._mapping) for p in posts],
"deals": [dict(d._mapping) for d in deals],
"notices": [dict(n._mapping) for n in notices]
}

View File

@ -1,129 +0,0 @@
/**
* 号码分类工具
* 根据冠字号自动分类为圆圆号倒置号金马王金马号金山王天马王金山号天马号朦胧号如意号钻石号永恒号带7号带4号
*/
// 分类定义(按优先级排序)
const CATEGORIES = [
{ name: '圆圆号', mustNot: ['1','2','3','4','5','7'], mustHave: [] },
{ name: '倒置号', mustNot: ['2','3','4','5','7'], mustHave: ['1'] },
{ name: '金马王', mustNot: ['1','2','3','4','7'], mustHave: ['5'] },
{ name: '金马号', mustNot: ['2','3','4','7'], mustHave: ['1','5'] },
{ name: '金山王', mustNot: ['1','2','4','5','7'], mustHave: ['3'] },
{ name: '天马王', mustNot: ['1','2','4','7'], mustHave: ['3','5'] },
{ name: '金山号', mustNot: ['2','4','5','7'], mustHave: ['1','3'] },
{ name: '天马号', mustNot: ['2','4','7'], mustHave: ['1','3','5'] },
{ name: '朦胧号', mustNot: ['3','4','5','7'], mustHave: [] },
{ name: '如意号', mustNot: ['1','3','4','7'], mustHave: [] },
{ name: '钻石号', mustNot: ['3','4','7'], mustHave: [] },
{ name: '永恒号', mustNot: ['4','7'], mustHave: [] },
{ name: '带7号', mustNot: ['4'], mustHave: ['7'] },
{ name: '带4号', mustNot: [], mustHave: ['4'] },
]
/**
* 提取冠字号中的数字部分
* @param {string} serial - 冠字号 J0123456789 J0123456781 J0123456701
* @returns {object} - { digits: 数字串, type: 'single'|'ten'|'hundred' }
*/
export function extractDigits(serial) {
if (!serial) return { digits: '', type: 'single' }
// 去掉J取数字部分
const nums = serial.replace(/J/g, '').replace(/\D/g, '')
// 判断类型
if (nums.endsWith('01')) {
// 标百去掉最后2位
return { digits: nums.slice(0, -2), type: 'hundred' }
} else if (nums.endsWith('1')) {
// 标十去掉最后1位
return { digits: nums.slice(0, -1), type: 'ten' }
} else {
// 单张:全部数字
return { digits: nums, type: 'single' }
}
}
/**
* 号码分类函数
* @param {string} serial - 冠字号
* @returns {string} - 分类名称
*/
export function getNumberCategory(serial) {
const { digits } = extractDigits(serial)
if (!digits || digits.length < 7) {
return '其他'
}
// 按优先级匹配
for (const cat of CATEGORIES) {
if (matchesCategory(digits, cat)) {
return cat.name
}
}
return '其他'
}
/**
* 检查数字是否匹配分类条件
* @param {string} digits - 数字串
* @param {object} category - 分类定义
* @returns {boolean}
*/
function matchesCategory(digits, category) {
const mustNot = category.mustNot
const mustHave = category.mustHave
// 1. 检查必须不含的数字
for (const n of mustNot) {
if (digits.includes(n)) {
return false
}
}
// 2. 检查必须含有的数字
for (const n of mustHave) {
if (!digits.includes(n)) {
return false
}
}
return true
}
/**
* 获取分类颜色
* @param {string} category - 分类名称
* @returns {string} - 颜色 hex
*/
export function getNumberCategoryColor(category) {
const colors = {
'圆圆号': '#8b5cf6', // 紫
'倒置号': '#ec4899', // 粉
'金马王': '#f59e0b', // 金
'金马号': '#ef4444', // 红
'金山王': '#14b8a6', // 青
'天马王': '#06b6d4', // 蓝
'金山号': '#0d9488', // 绿松石
'天马号': '#22c55e', // 绿
'朦胧号': '#6366f1', // 靛蓝
'如意号': '#a855f7', // 紫红
'钻石号': '#eab308', // 黄
'永恒号': '#3b82f6', // <20><><EFBFBD>
'带7号': '#f97316', // 橙
'带4号': '#64748b', // 灰
'其他': '#94a3b8',
}
return colors[category] || colors['其他']
}
/**
* 分类优先级用于排序
*/
export const NUMBER_CATEGORY_ORDER = CATEGORIES.map(c => c.name)
// 可选值列表(用于下拉框)
export const NUMBER_CATEGORY_OPTIONS = CATEGORIES.map(c => ({ value: c.name, label: c.name }))

View File

@ -1,240 +0,0 @@
# Logo 使用规范
**版本**: v1.0.0
**更新日期**: 2026-03-16
**状态**: ✅ 官方指定 Logo
---
## 🐉 官方 Logo
### 主 Logo
**文件**: `jiachenlong-logo.png`
**位置**:
- 本地:`/static/images/jiachenlong-logo.png`
- 前端服务器:`/var/www/html/static/images/jiachenlong-logo.png`
**规格**:
- 格式PNG
- 大小606KB
- 尺寸:正方形(适合圆形裁剪)
- 颜色:橙色(中国传统色)
- 设计:龙型环绕 + "甲辰收藏"文字
---
## 📋 使用场景
### 1. 登录页面
**文件**: `frontend/src/pages/Login.jsx`
```jsx
<img
src="/static/images/jiachenlong-logo.png"
alt="甲辰收藏"
style={{
width: '200px',
height: '200px',
borderRadius: '50%',
boxShadow: '0 0 40px rgba(251, 191, 36, 0.4)',
background: '#fff'
}}
/>
```
### 2. 首页
**文件**: `frontend/src/pages/Home.jsx`
```jsx
<img
src="/static/images/jiachenlong-logo.png"
alt="甲辰收藏"
style={{
width: '48px',
height: '48px',
borderRadius: '50%',
objectFit: 'cover'
}}
/>
```
### 3. 藏品详情页
**文件**: `frontend/src/pages/Detail.jsx`
```jsx
<img
src="/static/images/jiachenlong-logo.png"
alt="甲辰收藏"
onError={(e) => {
e.target.src = '/static/images/jiachenlong-logo.png';
}}
/>
```
---
## 🎨 样式规范
### 圆形样式(推荐)
```css
.logo {
width: 200px;
height: 200px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 0 40px rgba(251, 191, 36, 0.4);
background: #fff;
}
```
### 小尺寸(导航栏等)
```css
.logo-small {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
```
### 中等尺寸
```css
.logo-medium {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
}
```
---
## 📦 部署规范
### 部署脚本
**文件**: `scripts/deploy.sh`
部署脚本会自动:
1. ✅ 检查 Logo 文件是否存在
2. ✅ 部署前端构建文件
3. ✅ 部署 Logo 到服务器
4. ✅ 重启 Nginx
### 部署命令
```bash
# 测试环境
./scripts/deploy.sh 1.0.0 test
# 生产环境
./scripts/deploy.sh 1.0.0 production
```
### 手动部署
```bash
# 1. 构建前端
cd frontend
npm run build
# 2. 部署到服务器
scp -r dist/* root@8.149.137.26:/var/www/html/
scp static/images/jiachenlong-logo.png root@8.149.137.26:/var/www/html/static/images/
# 3. 重启 Nginx
ssh root@8.149.137.26 "nginx -s reload"
```
---
## ⚠️ 注意事项
### 必须遵守
1. ✅ **统一使用** `jiachenlong-logo.png`
2. ✅ **禁止使用** 旧版 `logo.jpg`、`dragon-logo.jpg`、`title_logo.svg`
3. ✅ **保持比例** - 始终使用正方形容器
4. ✅ **圆形裁剪** - 使用 `border-radius: 50%`
5. ✅ **白色背景** - Logo 需要白色背景衬托
### 禁止行为
- ❌ 不要修改 Logo 颜色
- ❌ 不要拉伸变形
- ❌ 不要添加其他效果
- ❌ 不要使用其他 Logo 文件
---
## 📁 文件位置
### 本地开发
```
jiachenlong/
└── static/
└── images/
└── jiachenlong-logo.png # ✅ 官方 Logo
```
### 前端服务器
```
/var/www/html/
└── static/
└── images/
└── jiachenlong-logo.png # ✅ 官方 Logo
```
---
## 🔄 更新流程
如需更新 Logo
1. **替换文件**
```bash
cp new-logo.png /static/images/jiachenlong-logo.png
```
2. **重新构建**
```bash
cd frontend
npm run build
```
3. **部署到服务器**
```bash
./scripts/deploy.sh 1.0.1 production
```
4. **验证部署**
```bash
curl http://8.149.137.26/static/images/jiachenlong-logo.png -o /tmp/logo-check.png
```
---
## 📊 Logo 对比
| 文件 | 状态 | 说明 |
|------|------|------|
| `jiachenlong-logo.png` | ✅ **官方指定** | 橙色圆形龙型 Logo |
| `logo.jpg` | ❌ 废弃 | 旧版 Logo |
| `dragon-logo.jpg` | ❌ 废弃 | 旧版龙型 Logo |
| `title_logo.svg` | ❌ 废弃 | 旧版 SVG Logo |
---
**所有部署必须使用 `jiachenlong-logo.png`**
**最后更新**: 2026-03-16

View File

@ -1,36 +0,0 @@
# 图片资源
本目录存放项目的所有图片资源。
## 📁 文件列表
- `logo.jpg` - 系统主 Logo106KB, 512x512
## 🎨 使用方式
### 前端访问
```jsx
<img src="/static/images/logo.jpg" alt="logo" />
```
### 后端访问FastAPI
```python
from fastapi.staticfiles import StaticFiles
app.mount("/static", StaticFiles(directory="static"), name="static")
```
## 📐 建议尺寸
- **Logo**: 512x512 或更大(用于缩放)
- **背景图**: 1920x1080全屏背景
- **头像**: 200x200用户头像
## 📦 格式建议
- **Logo**: PNG透明背景或 JPG
- **照片**: JPG压缩比好
- **图标**: SVG矢量可缩放或 PNG
---
**最后更新**: 2026-03-16

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

View File

@ -1,26 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 80">
<defs>
<linearGradient id="goldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFE4B5"/>
<stop offset="25%" stop-color="#FFD700"/>
<stop offset="50%" stop-color="#FFA500"/>
<stop offset="75%" stop-color="#DAA520"/>
<stop offset="100%" stop-color="#B8860B"/>
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feFlood flood-color="#FFD700" flood-opacity="0.6"/>
<feComposite in2="blur" operator="in"/>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="shadow">
<feDropShadow dx="2" dy="3" stdDeviation="2" flood-color="#000" flood-opacity="0.5"/>
</filter>
</defs>
<!-- Main title -->
<text x="0" y="45" font-family="'Kaiti SC', 'STKaiti', 'KaiTi', 'SimKai', serif" font-size="52" font-weight="bold" fill="url(#goldGrad)" filter="url(#shadow)">甲辰收藏</text>
<!-- Subtitle -->
<text x="0" y="72" font-family="'Kaiti SC', 'STKaiti', 'KaiTi', 'SimKai', serif" font-size="20" fill="#DAA520" letter-spacing="4">生肖纪念钞管理系统</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -6,13 +6,10 @@ import { join } from 'path'
// 从 config/VERSION 文件读取版本号 // 从 config/VERSION 文件读取版本号
function getVersion() { function getVersion() {
try { try {
const versionFile = join(__dirname, 'config', 'VERSION') const versionFile = join(__dirname, '..', 'config', 'VERSION')
const content = readFileSync(versionFile, 'utf-8').trim() const content = readFileSync(versionFile, 'utf-8')
// 移除 VERSION= 前缀 const match = content.match(/^VERSION=(.*)$/m)
if (content.startsWith('VERSION=')) { return match ? match[1].trim() : '0.0.0'
return content.substring(7).trim()
}
return content || '0.0.0'
} catch (e) { } catch (e) {
console.error('读取 VERSION 文件失败:', e.message) console.error('读取 VERSION 文件失败:', e.message)
return '0.0.0' return '0.0.0'
@ -20,42 +17,42 @@ function getVersion() {
} }
const APP_VERSION = getVersion() const APP_VERSION = getVersion()
console.log('📦 构建版本v' + APP_VERSION) console.log(`📦 构建版本v${APP_VERSION}`)
// 构建后执行 - 更新 dist/index.html 的 title // 构建时自动更新 index.html 的 title
function updateHtmlTitle() { function updateHtmlTitle() {
return { try {
name: 'update-html-title', const htmlPath = join(__dirname, 'index.html')
closeBundle() { let htmlContent = readFileSync(htmlPath, 'utf-8')
try { // 替换 <title>甲辰收藏 vXXX</title>
const htmlPath = join(__dirname, 'dist', 'index.html') htmlContent = htmlContent.replace(
let htmlContent = readFileSync(htmlPath, 'utf-8') /<title>甲辰收藏 v[\d.]+<\/title>/,
// 替换 <title>甲辰收藏 vXXX</title> `<title>甲辰收藏 v${APP_VERSION}</title>`
htmlContent = htmlContent.replace( )
/<title>甲辰收藏 v[\d.]+<\/title>/, writeFileSync(htmlPath, htmlContent, 'utf-8')
'<title>甲辰收藏 v' + APP_VERSION + '</title>' console.log(`✅ 已更新 index.html title: 甲辰收藏 v${APP_VERSION}`)
) } catch (e) {
writeFileSync(htmlPath, htmlContent, 'utf-8') console.error('更新 index.html 失败:', e.message)
console.log('✅ 已更新 dist/index.html title: 甲辰收藏 v' + APP_VERSION)
} catch (e) {
console.error('更新 dist/index.html 失败:', e.message)
}
}
} }
} }
// 构建前执行
updateHtmlTitle()
export default defineConfig({ export default defineConfig({
plugins: [react(), updateHtmlTitle()], plugins: [react()],
define: { define: {
'import.meta.env.APP_VERSION': JSON.stringify(APP_VERSION) 'import.meta.env.APP_VERSION': JSON.stringify(APP_VERSION)
}, },
build: { build: {
rollupOptions: { rollupOptions: {
output: { output: {
entryFileNames: 'assets/[name]-[hash]-[name].js', entryFileNames: `assets/[name]-[hash]-[name].js`,
chunkFileNames: 'assets/[name]-[hash].js', chunkFileNames: `assets/[name]-[hash].js`,
assetFileNames: 'assets/[name]-[hash].[ext]' assetFileNames: `assets/[name]-[hash].[ext]`
} }
} },
// 禁用缓存
manifest: true
} }
}) })

View File

@ -1,39 +0,0 @@
#!/bin/bash
# 版本一致性检查脚本
# 用法: ./scripts/check_version.sh
echo "========================================="
echo " 版本一致性检查"
echo "========================================="
CONFIG_FILE="/root/.openclaw/workspace/jiachenlong/config/VERSION.json"
VERSION=$(grep -o '"version": "[^"]*"' $CONFIG_FILE | cut -d'"' -f4)
UPDATED=$(grep -o '"updated": "[^"]*"' $CONFIG_FILE | cut -d'"' -f4)
echo ""
echo "当前版本: $VERSION"
echo "更新时间: $UPDATED"
echo ""
# 检查前端页面版本
echo "--- 前端页面 (最后修改时间) ---"
for file in /root/.openclaw/workspace/jiachenlong/frontend/src/pages/*.jsx; do
name=$(basename $file .jsx)
mtime=$(date -r "$file" "+%Y-%m-%d %H:%M")
echo "$name: $mtime"
done
echo ""
echo "--- 后端路由 (最后修改时间) ---"
for file in /root/.openclaw/workspace/jiachenlong/backend/app/routers/*.py; do
name=$(basename $file .py)
if [ "$name" != "__init__" ]; then
mtime=$(date -r "$file" "+%Y-%m-%d %H:%M")
echo "$name: $mtime"
fi
done
echo ""
echo "========================================="
echo " 检查完成"
echo "========================================="

View File

@ -1,13 +1,12 @@
#!/bin/bash #!/bin/bash
cd /root/jiachenlong/backend cd /root/jiachenlong/backend
export DATABASE_URL='postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong' export DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/zodiac
export SECRET_KEY=production-secret-key-b-env-20260401 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=LTAI5t86bc1nNKVNyYv4Af6x export SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
export SMS_ACCESS_KEY_SECRET=92EVAIE3GECr214c9UaSF6TSYJvDLY export SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
export SMS_SIGN_NAME=苏州双人旁 export SMS_SIGN_NAME=苏州算力
export SMS_TEMPLATE_CODE=SMS_505015231 export SMS_TEMPLATE_CODE=SMS_501590956
export DASHSCOPE_API_KEY=sk-9389024a37da4f7bb455ac9a6b28776f 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 --workers 1 > /tmp/uvicorn.log 2>&1 & echo '后端服务已启动'
echo 'B后端服务已启动'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

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>