Compare commits

..

No commits in common. "main" and "v1.1.21" have entirely different histories.

76 changed files with 2753 additions and 13346 deletions

203
README.md
View File

@ -1,100 +1,115 @@
# 甲辰藏品管理系统
> 生肖纪念钞收藏管理系统
**版本**: v1.0.0
**代号**: 新生
**发布日期**: 2026-03-16
## 版本信息
| 项目 | 内容 |
|------|------|
| **版本** | v1.2.38 |
| **代号** | 最终优化版本 |
| **发布日期** | 2026-03-31 |
## 技术栈
- **后端**: FastAPI + PostgreSQL + 阿里云OSS + 阿里云短信
- **前端**: React + Vite + Tailwind CSS
- **部署**: Nginx
## 核心功能
- 用户管理(管理员/普通用户)
- 藏品管理CRUD、多条件筛选
- OCR识别阿里云视觉智能
- 统计分析
- 图片上传阿里云OSS
- 短信验证码
- 用户协议
- 密码强度验证
## 项目结构
```
jiachenlong/
├── backend/ # FastAPI后端
│ ├── app/
│ │ ├── routers/ # API路由
│ │ ├── services/ # 业务服务
│ │ └── modules/ # 业务模块
│ └── config/
├── frontend/ # React前端
│ ├── src/pages/ # 页面组件
│ └── dist/ # 构建产物
└── config/ # 版本配置
```
## 版本历史
| 版本 | 日期 | 说明 |
|------|------|------|
| 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.1 | 2026-03-20 | 密码强度验证、用户协议 |
| v1.2.0 | 2026-03-20 | 管理员查看用户藏品 |
## 快速开始
```bash
# 后端
cd backend && pip install -r requirements.txt
python -m uvicorn app.main:app --reload
# 前端
cd frontend && npm install && npm run dev
```
## API接口
### 认证
- POST /api/auth/register - 注册
- POST /api/auth/login - 登录
- GET /api/auth/me - 当前用户
### 藏品
- GET /api/collections - 列表
- POST /api/collections - 创建
- PUT /api/collections/{id} - 更新
- DELETE /api/collections/{id} - 删除
## 开发团队
| 角色 | 姓名 | 职责 |
|------|------|------|
| 产品负责人 | 酷博特 | 项目总负责 |
| 项目经理 | 龙大 | 项目整体管理 |
| 开发 | 龙二 | 本地开发 |
| 测试 | 龙A | 生产A环境负责人 |
| 测试 | 龙B | 生产B环境负责人 |
| 运维 | 龙运 | 运维负责人 |
| 运维 | 龙镜 | 镜像版本管理负责人 |
## 文档
- [部署手册](https://qcn9r1i8r51d.feishu.cn/docx/ZvEadXZ7XoxUtYxGLoIcMCfgn7g)
- [Git仓库](http://47.253.189.47:3000/coolbot/jiachenlong)
生肖纪念钞收藏管理系统 - 支持藏品管理、OCR 识别、统计分析等功能。
---
© 2026 甲辰收藏
## 📁 项目结构
```
jiachenlong/
├── backend/ # FastAPI 后端服务
├── frontend/ # React 移动端前端
├── static/ # 静态资源(图片、图标、字体)
├── config/ # 配置文件
├── docs/ # 文档
├── scripts/ # 部署脚本
└── README.md # 本文件
```
---
## 🚀 快速开始
### 后端启动
```bash
cd backend
# 安装依赖
pip install -r requirements.txt
# 配置环境变量(修改数据库密码等)
vi .env
# 启动服务
python -m uvicorn app.main:app --port 3000 --host 0.0.0.0
```
### 前端启动
```bash
cd frontend
# 安装依赖
npm install
# 开发模式
npm run dev
# 生产构建
npm run build
```
### 配置文件
所有配置文件在 `config/` 目录:
- `config/VERSION` - 版本号配置
- `config/docker-compose.yml` - Docker 部署配置
- `.gitignore` - Git 忽略配置(根目录)
### 静态资源
所有静态资源在 `static/` 目录:
- `static/images/` - 图片资源Logo、背景图等
- `static/icons/` - 图标资源favicon、应用图标等
- `static/fonts/` - 字体文件
---
## 📋 文档
所有文档都在 `docs/` 目录下:
- `docs/DEPLOYMENT_v1.0.0.md` - 部署指南
- `docs/RELEASE_v1.0.0.md` - 发布说明
- `docs/ERROR_CODES.md` - 错误码
- `docs/部署手册.md` - 完整部署手册
- `docs/BACKEND_SERVICE_GUIDE.md` - 后端服务指南
- `docs/TEST_REPORT.md` - 测试报告
---
## 🛠️ 技术栈
**后端**:
- FastAPI + SQLAlchemy
- PostgreSQL
- JWT 认证
- DashScope OCR
**前端**:
- React + Vite
- 移动端适配
- 暗色主题
---
## 📊 核心功能
- ✅ 用户管理(管理员/普通用户)
- ✅ 藏品管理CRUD
- ✅ OCR 识别
- ✅ 统计分析
- ✅ 图片上传
- ✅ 移动端适配
---
**开发团队**: 酷博特 + 菜鸟小 D 🤖

View File

@ -1,26 +0,0 @@
# 数据库配置
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/zodiac
# JWT配置
SECRET_KEY=your-production-secret-key-change-this
ACCESS_TOKEN_EXPIRE_MINUTES=60
# 阿里云 DashScope OCR API必须配置
DASHSCOPE_API_KEY=sk-9389024a37da4f7bb455ac9a6b28776f
# 阿里云 OSS配置
OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
OSS_BUCKET_NAME=jiachenlong-oss
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
OSS_PUBLIC_URL=https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com
# 阿里云短信配置(必须配置!)
SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
SMS_SIGN_NAME=苏州算力
SMS_TEMPLATE_CODE=SMS_501590956
# 服务配置
PORT=3000
HOST=0.0.0.0

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
# 配置
SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
raise ValueError("SECRET_KEY environment variable is not set. Please configure it in production!")
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7天
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
# HTTP Bearer 认证
security = HTTPBearer(auto_error=False)
@ -59,10 +57,14 @@ def decode_access_token(token: str) -> Optional[dict]:
def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(lambda: SessionLocal())
) -> Optional[User]:
"""获取当前用户可返回None"""
) -> User:
"""获取当前用户"""
if not credentials:
return None
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未提供认证信息",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
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()
if not user:
return None
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在",
headers={"WWW-Authenticate": "Bearer"},
)
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,32 @@
import os
import time
from sqlalchemy import create_engine, event
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
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",
"postgresql://postgres:postgres@127.0.0.1:5432/zodiac"
"postgresql://postgres:postgres@localhost:5432/zodiac"
)
# 增强版数据库引擎配置
# 数据库引擎配置
engine = create_engine(
DATABASE_URL,
# 连接池配置
poolclass=QueuePool,
pool_size=20, # 常规连接数
max_overflow=40, # 允许超出的连接数(高并发时)
pool_timeout=30, # 获取连接超时时间(秒)
pool_recycle=1800, # 连接回收时间30分钟避免连接过期
pool_pre_ping=True, # 每次获取连接前检查连接是否有效
echo=False,
# 连接参数优化
connect_args={
"connect_timeout": 10,
"application_name": "zodiac-api",
"options": "-c statement_timeout=30000" # 查询超时30秒
}
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
echo=False
)
# 添加连接事件监听器
@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)
Base = declarative_base()
def get_db() -> Generator:
"""获取数据库会话,带错误处理"""
def get_db():
"""获取数据库会话"""
db = SessionLocal(expire_on_commit=False)
try:
yield db
except Exception as e:
logger.error(f"数据库会话错误: {e}")
db.rollback()
raise
finally:
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 ocr as ocr_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 文件读取
def get_version():
@ -54,11 +50,10 @@ app = FastAPI(
# 设置全局错误处理器
setup_error_handlers(app)
# CORS 配置 - 生产环境限制域名
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://47.103.29.111,http://120.55.81.21,https://socoolbot.com").split(",")
# CORS 配置
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_origins=["*"], # 生产环境应该限制域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@ -69,10 +64,10 @@ uploads_dir = "uploads"
os.makedirs(uploads_dir, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
# 挂载项目静态资源目录
static_dir = Path(__file__).parent.parent / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# 挂载项目静态资源目录(可选,生产环境建议用 Nginx
# static_dir = Path(__file__).parent.parent.parent / "static"
# if static_dir.exists():
# app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# 添加日志中间件
app.middleware("http")(logging_middleware)
@ -84,10 +79,6 @@ app.include_router(operations.router)
app.include_router(ocr_router.router) # OCR 识别
app.include_router(users_router.router) # 当前用户接口
app.include_router(users_router.admin_router) # 管理员用户管理
app.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("/")
@ -110,6 +101,3 @@ if __name__ == "__main__":
import uvicorn
port = int(os.getenv("PORT", "3000"))
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.sql import func
from app.core.database import Base
@ -16,7 +16,6 @@ class User(Base):
# f99 系统字段
f99_90_id = Column(String(36), primary_key=True, default=generate_uuid)
user_code = Column(String(10), unique=True, nullable=True, index=True) # 用户编码
f99_91_user_id = Column(String(36), unique=True, nullable=False, index=True)
f01_01_name = Column(String(255), unique=True, nullable=False, index=True) # username
email = Column(String(255), unique=True, nullable=True, index=True)
@ -26,26 +25,9 @@ class User(Base):
bio = Column(Text, nullable=True)
password = Column(String(255), nullable=False)
role = Column(String(50), default="user")
# 时间字段
last_login = Column(DateTime(timezone=True), nullable=True) # 最后登录时间
f99_92_created_at = Column(DateTime(timezone=True), server_default=func.now())
f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 新增字段
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")
operations = relationship("Operation", back_populates="user", cascade="all, delete-orphan")
@ -73,7 +55,6 @@ class Collection(Base):
f02_11_version = Column(String(100), nullable=True, index=True)
f02_12_packaging = Column(String(100), nullable=True, index=True)
f02_13_rarity = Column(String(50), nullable=True, index=True) # 珍惜度
f02_14_number_category = Column(String(20), nullable=True, index=True) # 号码分类
# f03 评级信息
f03_20_is_graded = Column(Boolean, default=False, index=True)
@ -147,122 +128,3 @@ class CustomField(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
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,167 @@
# auth - 认证路由
# Version: 1.2.90
# 更新:
from fastapi import APIRouter
# Version: 1.2.x
# 更新:
# 认证路由 - 使用字段编码
from fastapi import APIRouter, Depends, HTTPException, status, Body
# 更新:
from fastapi.security import OAuth2PasswordRequestForm
# 更新:
from sqlalchemy.orm import Session
# 更新:
from app.core.database import get_db
# 更新:
from app.core.auth import verify_password, create_access_token, get_password_hash, get_current_user
# 更新:
from app.models.models import User
# 更新:
from app.schemas.schemas import Token, UserCreate, UserResponse
# 更新:
# 更新:
router = APIRouter(prefix="/api/auth", tags=["认证"])
# 更新:
# 更新:
# 更新:
# 更新:
# 更新:
def generate_user_code(db):
# 更新:
"""生成用户编码从201开始按自然数顺序递增跳过已存在的"""
# 更新:
# 查找最大的user_code
# 更新:
max_code = db.query(User.user_code).filter(User.user_code != None).order_by(User.user_code.desc()).first()
# 更新:
if max_code and max_code[0]:
# 更新:
try:
# 更新:
num = int(max_code[0]) + 1
# 更新:
if num < 201:
# 更新:
num = 201
# 更新:
# 检查是否已存在,如果存在则继续递增
# 更新:
while db.query(User).filter(User.user_code == str(num)).first():
# 更新:
num += 1
# 更新:
return str(num)
# 更新:
except:
# 更新:
pass
# 更新:
return "201"
# 更新:
# 更新:
@router.post("/register", response_model=UserResponse)
# 更新:
def register(user_data: UserCreate, db: Session = Depends(get_db)):
# 更新:
"""用户注册"""
# 更新:
# 检查用户名是否已存在
# 更新:
existing_user = db.query(User).filter(User.f01_01_name == user_data.f01_01_name).first()
# 更新:
if existing_user:
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_400_BAD_REQUEST,
# 更新:
detail="f01_01_name: 用户名已存在"
# 更新:
)
# 更新:
# 更新:
# 检查邮箱是否已存在
# 更新:
# 更新:
# 检查手机号是否已存在
# 更新:
if user_data.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:
# 更新:
existing_email = db.query(User).filter(User.email == user_data.email).first()
# 更新:
if existing_email:
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_400_BAD_REQUEST,
# 更新:
detail="E00041:该邮箱已被注册,请更换邮箱"
# 更新:
detail="邮箱已被注册"
)
# 更新:
# 更新:
# 处理邀请码
# 更新:
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
# 更新:
hashed_password = get_password_hash(user_data.password)
# 更新:
generated_code = generate_user_code(db)
# 更新:
user = User(
# 更新:
f99_90_id=str(uuid.uuid4()),
# 更新:
f99_91_user_id=str(uuid.uuid4()),
# 更新:
user_code=generated_code,
# 更新:
f99_91_user_id=str(uuid.uuid4()), # 生成唯一 user_id
f01_01_name=user_data.f01_01_name,
# 更新:
email=user_data.email,
# 更新:
phone=user_data.phone,
# 更新:
avatar=user_data.avatar,
# 更新:
address=user_data.address,
# 更新:
bio=user_data.bio,
# 更新:
password=hashed_password,
# 更新:
role="user"
# 更新:
)
# 更新:
# 更新:
db.add(user)
# 更新:
db.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.refresh(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
# 更新:
}
# 更新:
return user
# 更新:
# 更新:
@router.post("/login", response_model=Token)
# 更新:
def login(
# 更新:
form_data: OAuth2PasswordRequestForm = Depends(),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""用户登录 - 支持用户名或用户编码登录"""
# 更新:
# 先尝试用户名登录
# 更新:
"""用户登录"""
# 查找用户
user = db.query(User).filter(User.f01_01_name == form_data.username).first()
# 更新:
# 如果用户名不存在,尝试用户编码登录
# 更新:
if not user:
# 更新:
user = db.query(User).filter(User.user_code == form_data.username).first()
# 更新:
if not user:
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_401_UNAUTHORIZED,
# 更新:
detail="E00011: 用户名或密码错误",
# 更新:
headers={"WWW-Authenticate": "Bearer"},
# 更新:
)
# 更新:
# 更新:
# 验证密码
# 更新:
if not verify_password(form_data.password, user.password):
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_401_UNAUTHORIZED,
# 更新:
detail="E00011: 用户名或密码错误",
# 更新:
headers={"WWW-Authenticate": "Bearer"},
# 更新:
)
# 更新:
# 更新:
# 更新登录次数和最后登录时间
# 更新:
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
# 更新:
access_token = create_access_token(data={"sub": user.f99_90_id})
# 更新:
# 更新:
return {
# 更新:
"access_token": access_token,
# 更新:
"token_type": "bearer"
# 更新:
}
# 更新:
# 更新:
# 更新:
@router.get("/me", response_model=UserResponse)
# 更新:
def get_current_user_info(
# 更新:
current_user: User = Depends(lambda: None)
# 更新:
):
# 更新:
"""获取当前用户信息"""
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_501_NOT_IMPLEMENTED,
# 更新:
detail="请使用正确的依赖注入"
# 更新:
)
# 更新:
# 更新:
# 更新:
@router.post("/change-password")
# 更新:
def change_password(
# 更新:
old_password: str = Body(...),
# 更新:
new_password: str = Body(...),
# 更新:
current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""修改当前用户密码"""
# 更新:
from app.core.auth import verify_password, get_password_hash
# 更新:
# 更新:
# 在当前session中重新查询用户
# 更新:
user = db.query(User).filter(User.f99_90_id == current_user.f99_90_id).first()
# 更新:
if not user:
# 更新:
raise HTTPException(status_code=404, detail="用户不存在")
# 更新:
# 更新:
# 验证旧密码
# 更新:
if not verify_password(old_password, user.password):
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_400_BAD_REQUEST,
# 更新:
detail="当前密码错误"
# 更新:
)
# 更新:
# 更新:
# 更新密码
# 更新:
user.password = get_password_hash(new_password)
# 更新:
db.commit()
# 更新:
# 更新:
return {"message": "密码修改成功"}
# 更新:
# 更新:
# 更新:
# ============ 短信验证码接口 ============
# 更新:
# 更新:
@router.post("/send-verification-code")
# 更新:
def send_verification_code(
# 更新:
phone: str = Body(..., min_length=11, max_length=11),
# 更新:
purpose: str = Body("register") # register | login | reset_password
# 更新:
):
# 更新:
"""发送短信验证码"""
# 更新:
from app.services.sms import send_verification_code as send_sms
# 更新:
# 更新:
# 验证手机号格式
# 更新:
if not phone.startswith("1") or len(phone) != 11:
# 更新:
return {"success": False, "message": "手机号格式不正确"}
# 更新:
# 更新:
result = send_sms(phone)
# 更新:
# 更新:
if result["success"]:
# 更新:
return {
# 更新:
"success": True,
# 更新:
"message": f"验证码已发送到 {phone[:3]}****{phone[7:]}",
# 更新:
"expire": result.get("expire", 300)
# 更新:
}
# 更新:
else:
# 更新:
return result
# 更新:
# 更新:
# 更新:
@router.post("/verify-code")
# 更新:
def verify_code(
# 更新:
phone: str = Body(...),
# 更新:
code: str = Body(..., min_length=6, max_length=6)
# 更新:
):
# 更新:
"""验证短信验证码(仅验证,不执行后续操作)"""
# 更新:
from app.services.sms import verify_code as check_code
# 更新:
# 更新:
is_valid = check_code(phone, code)
# 更新:
# 更新:
if is_valid:
# 更新:
return {"success": True, "message": "验证成功"}
# 更新:
else:
# 更新:
return {"success": False, "message": "验证码错误或已过期"}
# 更新:

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

View File

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

View File

@ -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 fastapi import APIRouter, Depends, HTTPException, status, Query, Body
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(
current_user: User = Depends(get_current_user)
):
"""获取当前登录用户信息"""
return {
"id": current_user.f99_90_id,
"username": current_user.f01_01_name,
"f99_90_id": current_user.f99_90_id,
"f01_01_name": current_user.f01_01_name,
"email": current_user.email,
@ -30,27 +25,11 @@ def get_current_user_info(
"address": current_user.address,
"bio": current_user.bio,
"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_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(
user_update: UserUpdate,
current_user: User = Depends(get_current_user),
@ -100,46 +79,24 @@ def get_users(
db: Session = Depends(get_db)
):
"""获取用户列表(仅管理员)"""
if current_user.role not in ["admin", "editor"]:
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="无权访问")
total = db.query(User).count()
from sqlalchemy import case
users = db.query(User).order_by(
case(
(User.user_code == None, 1),
else_=0
),
User.user_code.asc()
).offset((page-1)*limit).limit(limit).all()
users = db.query(User).offset((page-1)*limit).limit(limit).all()
user_list = []
for u in users:
# 统计每个用户的藏品数量
count = db.query(Collection).filter(Collection.f99_91_user_id == u.f99_90_id).count()
# 统计每个用户发布的行情数量info_type='deal'
deal_count = db.query(Information).filter(
Information.user_id == u.f99_90_id,
Information.info_type == "deal"
).count()
user_list.append({
"id": u.f99_90_id,
"username": u.f01_01_name,
"email": u.email,
"phone": u.phone,
"role": u.role,
"user_code": u.user_code,
"created_at": u.f99_92_created_at.isoformat() if u.f99_92_created_at else None,
"collectionCount": 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,
"collection_count": count
})
return user_list
@ -152,7 +109,7 @@ def get_user(
db: Session = Depends(get_db)
):
"""获取单个用户信息"""
if current_user.role not in ["admin", "editor"]:
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="无权访问")
user = db.query(User).filter(User.id == user_id).first()
@ -177,7 +134,7 @@ def get_user_collections(
db: Session = Depends(get_db)
):
"""获取指定用户的藏品列表"""
if current_user.role not in ["admin", "editor"]:
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="无权访问")
collections = db.query(Collection).filter(
@ -194,7 +151,7 @@ def get_user_collection_count(
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: 仅管理员可访问")
count = db.query(Collection).filter(Collection.user_id == user_id).count()
@ -208,18 +165,11 @@ def update_user(
email: Optional[str] = Body(None),
role: Optional[str] = Body(None),
password: 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),
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: 仅管理员可访问")
user = db.query(User).filter(User.f99_90_id == user_id).first()
@ -234,45 +184,6 @@ def update_user(
if role is not None:
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:
# 只有非空字符串才检查唯一性
user_code_str = user_code.strip() if user_code else ''
if user_code_str:
existing = db.query(User).filter(
User.user_code == user_code_str,
User.f99_90_id != user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="E00052: 该用户编码已被其他用户使用")
user.user_code = user_code_str
else:
user.user_code = None
# 更新密码
if password and password.strip():
from app.core.auth import get_password_hash
@ -297,7 +208,7 @@ def delete_user(
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: 仅管理员可访问")
# 不能删除自己

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):
password: str = Field(..., min_length=6)
invite_code: Optional[str] = Field(None, alias="inviteCode") # 填写的邀请码(选填)
class UserUpdate(BaseModel):
@ -35,27 +34,9 @@ class UserUpdate(BaseModel):
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")
f01_01_name: str = Field(..., alias="username")
role: str
user_code: Optional[str] = None
f99_92_created_at: Optional[datetime] = Field(None, alias="created_at")
f99_93_updated_at: Optional[datetime] = Field(None, alias="updated_at")
@ -77,7 +58,6 @@ class CollectionBase(BaseModel):
f02_11_version: Optional[str] = Field(None, alias="version")
f02_12_packaging: Optional[str] = Field(None, alias="packaging")
f02_13_rarity: Optional[str] = Field(None, alias="rarity")
f02_14_number_category: Optional[str] = Field(None, alias="numberCategory")
# f03 评级信息
f03_20_is_graded: Optional[bool] = Field(False, alias="isGraded")
@ -124,7 +104,6 @@ class CollectionUpdate(BaseModel):
f02_11_version: Optional[str] = Field(None, alias="version")
f02_12_packaging: Optional[str] = Field(None, alias="packaging")
f02_13_rarity: Optional[str] = Field(None, alias="rarity")
f02_14_number_category: Optional[str] = Field(None, alias="numberCategory")
# f03 评级信息
f03_20_is_graded: Optional[bool] = Field(None, alias="isGraded")

View File

@ -8,10 +8,10 @@ from typing import Optional
# 阿里云短信配置
SMS_CONFIG = {
"access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5tQAx5niD7JQVqGE5acE"),
"access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "QsQFAEKBkaNynIoKyvdIi3BUyWVZu1"),
"sign_name": os.getenv("SMS_SIGN_NAME", "苏州算力"),
"template_code": os.getenv("SMS_TEMPLATE_CODE", "SMS_501590956"),
"access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"),
"access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"),
"sign_name": "阿里云",
"template_code": "100001",
}
# 验证码缓存生产环境建议用Redis

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,14 @@
1.2.98
# 甲辰藏品管理系统 - 版本配置
# Version Configuration for Zodiac Collection Management System
# 当前版本号 (语义化版本:主版本。次版本.修订版)
VERSION=1.1.21
# 版本代号 (可选)
VERSION_CODENAME="新生"
# 发布日期
RELEASE_DATE=2026-03-18
# 版本说明
VERSION_NOTES="性能优化 - Nginx反向代理、图片OSS直连、自动压缩、上传限制20MB"

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,50 +0,0 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: jiachenlong-db-test
restart: unless-stopped
environment:
POSTGRES_DB: zodiac
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
backend:
image: python:3.11-slim
container_name: jiachenlong-backend-test
restart: unless-stopped
working_dir: /app
command: >
bash -c "pip install fastapi uvicorn sqlalchemy psycopg2-binary pydantic python-jose bcrypt python-multipart pillow dashscope alibabacloud-dysmsapi20170525 -q && uvicorn app.main:app --host 0.0.0.0 --port 3000"
ports:
- "3000:3000"
volumes:
- /root/jiachenlong/backend:/app
environment:
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/zodiac
- SECRET_KEY=test-secret-key-for-sms
- ACCESS_TOKEN_EXPIRE_MINUTES=60
- OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
- OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
- OSS_BUCKET=jiachenlong-oss
- OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
- SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
- SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
- SMS_SIGN_NAME=苏州算力
- SMS_TEMPLATE_CODE=SMS_501590956
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:

View File

@ -1,145 +0,0 @@
# v1.2.4 版本问题修复说明
**版本**: v1.2.4-stable
**发布日期**: 2026-03-20
**维护人**: 甲辰生产
---
## 问题列表及解决方案
### 问题1: OCR识别后保存藏品失败
**现象**: OCR识别成功后点击保存藏品返回404错误
**原因**:
- OCR代码只尝试小写扩展名(jpg/jpeg/png/gif)
- 用户上传的图片文件扩展名是大写的.JPG
**解决方案**:
修改 `backend/app/routers/ocr.py` 第299行
```python
# 修改前
temp_extensions = [jpg, jpeg, png, gif]
# 修改后
temp_extensions = [jpg, jpeg, png, gif, JPG, JPEG, PNG, GIF]
```
---
### 问题2: 用户协议页面乱码
**现象**: 点击用户协议显示乱码,只有一个标题
**原因**:
- 协议文件只有一个空的HTML骨架
- 没有实际内容
**解决方案**:
创建完整的用户协议HTML文件 `/var/www/frontend/user_agreement.html`,包含完整的中文协议内容(服务条款、用户责任、数据安全、免责声明等)
---
### 问题3: 用户协议页面无返回按钮
**现象**: 打开用户协议后无法返回
**解决方案**:
在用户协议页面添加返回按钮:
```html
<button class="back-btn" onclick="window.close()">← 返回</button>
```
---
### 问题4: 首页Logo显示不出来
**现象**: 登录页Logo正常登录后首页Logo显示404
**原因**:
- 部署时static目录没有复制到正确位置
- 浏览器缓存了旧的JS文件
**解决方案**:
1. 确保部署时复制static目录
```bash
cp -r /root/jiachenlong/static /var/www/frontend/
cp -r /root/jiachenlong/static /var/www/mobile/
```
2. 首页Logo添加版本号防止缓存
```jsx
// 修改Home.jsx
<img src="/static/images/jiachenlong-logo.png?v=1.2.4" ... />
```
---
### 问题5: 版本号显示重复
**现象**: 标题显示"vv1.2.4"v重复
**原因**:
- VERSION文件中版本号为"v1.2.4"带v前缀
- vite.config.js中又自动添加了"v"前缀
**解决方案**:
修改VERSION文件去掉v前缀
```
# 修改前
VERSION=v1.2.4
# 修改后
VERSION=1.2.4
```
---
## 部署检查清单
### 前端部署
```bash
# 1. 确保VERSION文件格式正确不带v前缀
cat config/VERSION
# 输出: VERSION=1.2.4
# 2. 构建
cd frontend
npm run build
# 3. 部署(两个目录都要部署)
rm -rf /var/www/mobile/*
rm -rf /var/www/frontend/*
cp -r dist/* /var/www/mobile/
cp -r dist/* /var/www/frontend/
cp -r ../static /var/www/mobile/
cp -r ../static /var/www/frontend/
# 4. 重载Nginx
nginx -s reload
```
### 后端部署
```bash
# 1. 拉取最新代码
cd /root/jiachenlong
git pull
# 2. 重启后端服务
pkill -f uvicorn
cd backend
python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 &
```
---
## 标签信息
- **当前稳定版本**: v1.2.4-stable
- **Gitea标签地址**: http://47.253.189.47:3000/coolbot/jiachenlong/tags
- **发布说明**: v1.2.4稳定版本 - 用户协议修复版
---
**文档结束**

View File

@ -1,297 +0,0 @@
# 甲辰藏品管理系统 - 标准部署流程
**版本**: v1.0
**创建时间**: 2026-03-21
**维护人**: 甲辰生产
---
## 📋 部署前检查清单
### 1. 获取信息
| 项目 | 内容 | 获取方式 |
|------|------|---------|
| 目标服务器IP | 如 8.149.137.26 | MEMORY.md |
| SSH密码 | 如 Jiachen123 | 询问酷博特 |
| 目标版本 | 如 v1.2.4 | Gitea tags |
| 数据库配置 | IP/密码/端口 | MEMORY.md |
### 2. 环境确认
```bash
# 登录目标服务器
ssh root@<目标IP>
# 检查已有配置(不要覆盖!)
cat /root/jiachenlong/config/VERSION
cat /etc/nginx/conf.d/*.conf
```
---
## 🚀 标准部署流程
### 前端部署(所有环境)
```bash
# 1. 登录服务器
ssh root@<前端IP>
# 2. 拉取代码(重要:不要覆盖已有目录)
cd /root
rm -rf jiachenlong_bak
mv jiachenlong jiachenlong_bak # 备份旧代码
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git jiachenlong
# 3. 检查并修改VERSION文件重要用sed保留原内容
# 先查看原内容
cat jiachenlong/config/VERSION
# 修改VERSION行保留其他行
sed -i 's/^VERSION=.*/VERSION=1.2.4/' jiachenlong/config/VERSION
# 4. 构建前端
cd jiachenlong/frontend
npm install
npm run build
# 5. 部署(两个目录都要部署!)
rm -rf /var/www/mobile/*
rm -rf /var/www/frontend/*
cp -r dist/* /var/www/mobile/
cp -r dist/* /var/www/frontend/
cp -r ../static /var/www/mobile/
cp -r ../static /var/www/frontend/
# 6. 部署用户协议(如有)
cp user_agreement.html /var/www/mobile/
cp user_agreement.html /var/www/frontend/
# 7. 重载Nginx
nginx -s reload
```
### 后端部署(所有环境)
```bash
# 1. 登录服务器
ssh root@<后端IP>
# 2. 拉取代码
cd /root
rm -rf jiachenlong_bak
mv jiachenlong jiachenlong_bak
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git jiachenlong
# 3. 检查OCR扩展名修复如无则手动修复
grep -n 'temp_extensions' jiachenlong/backend/app/routers/ocr.py
# 如只有小写,修复:
sed -i "s/temp_extensions = \['jpg', 'jpeg', 'png', 'gif'\]/temp_extensions = ['jpg', 'jpeg', 'png', 'gif', 'JPG', 'JPEG', 'PNG', 'GIF']/" jiachenlong/backend/app/routers/ocr.py
# 4. 检查数据库配置
cat jiachenlong/backend/.env | grep DATABASE_URL
# 5. 停止旧服务
pkill -f uvicorn
# 6. 启动新服务
cd jiachenlong/backend
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 > /tmp/uvicorn.log 2>&1 &
# 7. 等待启动
sleep 5
# 8. 验证
curl -s http://localhost:3000/ | head -c 100
```
---
## ✅ 部署后验证清单
### 必须验证的项目
| # | 验证项 | 命令 | 期望结果 |
|---|--------|------|---------|
| 1 | 前端页面 | curl http://<前端IP>/ | 200 + HTML |
| 2 | 前端版本 | curl http://<前端IP>/ \| grep title | v1.2.4 |
| 3 | 前端端口 | curl http://<前端IP>:3001/ | 200 |
| 4 | 后端健康 | curl http://<后端IP>:3000/ | 200 |
| 5 | 登录功能 | curl -X POST http://<后端IP>:3000/api/auth/login -d "username=admin&password=admin123" | 返回token |
| 6 | Logo图片 | curl -I http://<前端IP>/static/images/jiachenlong-logo.png | 200 |
| 7 | 用户协议 | curl http://<前端IP>/user_agreement.html | 200 + 内容 |
| 8 | 80端口 | curl -o /dev/null -w "%{http_code}" http://<前端IP>/ | 200 |
| 9 | API代理 | curl http://<前端IP>/api/collections | JSON响应 |
### 与基准环境对比
```bash
# 以C环境为基准对比关键文件
# C环境
curl -s http://47.103.29.111/ | grep title
# B环境
curl -s http://8.149.137.26/ | grep title
# 期望:版本号一致
```
---
## ⚠️ 常见错误及解决方案
### 1. git clone失败目录已存在
**错误**
```
fatal: destination path . already exists and is not an empty directory.
```
**解决**
```bash
# 方法1先备份再删除
mv jiachenlong jiachenlong_backup
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git
# 方法2删除后克隆
rm -rf jiachenlong
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git
```
### 2. VERSION文件被覆盖
**错误**
```
VERSION=1.2.4
# 原有内容丢失
```
**解决**使用sed修改而非echo覆盖
```bash
# 错误方法
echo "VERSION=1.2.4" > VERSION # ❌ 会覆盖整个文件
# 正确方法
sed -i s/^VERSION=.*/VERSION=1.2.4/ VERSION # ✅ 只修改VERSION行
```
### 3. 版本号显示vv1.2.4
**原因**VERSION文件带v前缀 + vite.config.js又加v
**解决**
```bash
# VERSION文件不要带v
VERSION=1.2.4 # ✅
# 不是 VERSION=v1.2.4
# 源index.html如有vv先修复
sed -i s/vv/v/g index.html
```
### 4. 首页Logo显示404
**原因**static目录未部署
**解决**
```bash
# 部署时必须复制static目录
cp -r ../static /var/www/mobile/
cp -r ../static /var/www/frontend/
```
### 5. 浏览器缓存旧JS
**原因**JS文件名hash未变
**解决**首页Logo添加版本号
```jsx
// Home.jsx
<img src="/static/images/jiachenlong-logo.png?v=1.2.4" ... />
```
### 6. Nginx 80端口返回403
**原因**root目录为空或不存在
**解决**
```bash
# 检查目录
ls -la /var/www/frontend/
# 部署到正确目录
cp -r dist/* /var/www/frontend/
# 重载Nginx
nginx -s reload
```
---
## 📊 环境配置参考
### A环境生产
| 服务 | IP | 端口 |
|------|-----|------|
| 前端 | 8.154.46.3 | 80, 3001 |
| 后端 | 42.121.116.25 | 3000 |
| 数据库 | 47.98.171.101 | 5432 |
### B环境灰度
| 服务 | IP | 端口 |
|------|-----|------|
| 前端 | 8.149.137.26 | 80, 3001 |
| 后端 | 47.110.37.129 | 3000 |
| 数据库 | 47.96.181.36 | 5432 |
### C环境测试
| 服务 | IP | 端口 |
|------|-----|------|
| 前端 | 47.103.29.111 | 80 |
| 后端 | 47.103.9.192 | 3000 |
| 数据库 | 47.103.9.192 | 5432 |
---
## 📝 部署记录模板
每次部署后填写:
```markdown
## 部署记录
### 2026-03-21 v1.2.4
| 环境 | 部署时间 | 操作人 | 结果 |
|------|---------|--------|------|
| B环境 | 00:27 | 甲辰生产 | ✅ 成功 |
### 部署命令
```bash
# 前端
ssh root@8.149.137.26
cd /root/jiachenlong/frontend
npm run build
cp -r dist/* /var/www/mobile/
cp -r dist/* /var/www/frontend/
# 后端
ssh root@47.110.37.129
pkill -f uvicorn
cd /root/jiachenlong/backend
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 &
```
### 验证结果
- 前端版本v1.2.4 ✅
- 后端健康200 ✅
- Logo显示200 ✅
### 问题记录
```
---
**文档结束**

View File

@ -1,186 +0,0 @@
# 测试环境部署指南
## 测试环境架构
| 服务器 | IP | 服务 |
|--------|-----|------|
| 测试机1 | 47.103.29.111 | 前端 (Nginx) |
| 测试机2 | 47.103.9.192 | 后端 (FastAPI) + PostgreSQL |
## 部署步骤
### 1. 测试机1 - 前端部署
```bash
# 拉取代码
cd /root/jiachenlong
git fetch --all
git checkout v1.1.21
# 安装依赖并构建
cd frontend
npm install
npm run build
# 复制静态文件到可访问目录
mkdir -p /var/www/html
cp -r dist/* /var/www/html/
cp -r ../static /var/www/html/
chmod -R 755 /var/www/html
# 配置Nginx
cat > /etc/nginx/conf.d/jiachenlong-test.conf << 'EOF'
server {
listen 80;
server_name _;
root /var/www/html;
index index.html;
client_max_body_size 20M;
# 静态资源logo、图片等
location /static {
alias /var/www/html/static;
expires 30d;
add_header Cache-Control "public, immutable";
}
# SPA路由
location / {
try_files $uri $uri/ /index.html;
}
# API代理到后端
location /api {
proxy_pass http://47.103.9.192:3000/api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
EOF
nginx -t && systemctl enable nginx && systemctl restart nginx
```
### 2. 测试机2 - 后端部署
```bash
# 拉取代码
cd /root/jiachenlong
git fetch --all
git checkout v1.1.21
# 安装Python依赖
pip3 install fastapi uvicorn sqlalchemy psycopg2-binary pydantic python-jose bcrypt python-multipart pillow dashscope alibabacloud-dysmsapi20170525 oss2 email-validator httpx python-dotenv
# 创建环境变量文件
cat > backend/.env << 'EOF'
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/zodiac
SECRET_KEY=test-secret-key-for-sms
ACCESS_TOKEN_EXPIRE_MINUTES=60
OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
OSS_BUCKET=jiachenlong-oss
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
SMS_SIGN_NAME=苏州算力
SMS_TEMPLATE_CODE=SMS_501590956
EOF
# 启动后端
cd backend
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 > /tmp/backend.log 2>&1 &
# 复制静态文件
mkdir -p /var/www/html/static
cp -r ../static/* /var/www/html/static/
chmod -R 755 /var/www/html/static
# 配置Nginx
cat > /etc/nginx/conf.d/jiachenlong-test.conf << 'EOF'
server {
listen 80;
server_name _;
root /var/www/html;
index index.html;
client_max_body_size 20M;
location /static {
alias /var/www/html/static;
}
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://127.0.0.1:3000/api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
EOF
nginx -t && systemctl enable nginx && systemctl restart nginx
```
## 常见问题
### 问题1: Logo不显示
**原因:**
1. Nginx未配置`/static`路径SPA路由捕获了请求
2. 静态文件在`/root`目录下nginx无权限读取
**解决:**
1. 添加`location /static`配置
2. 将静态文件复制到`/var/www/html/static`
### 问题2: 短信发送失败 "找不到模板"
**原因:** sms.py中的默认配置未更新
**解决:** 修改`backend/app/services/sms.py`:
```python
SMS_CONFIG = {
"access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5tQAx5niD7JQVqGE5acE"),
"access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "QsQFAEKBkaNynIoKyvdIi3BUyWVZu1"),
"sign_name": os.getenv("SMS_SIGN_NAME", "苏州算力"),
"template_code": os.getenv("SMS_TEMPLATE_CODE", "SMS_501590956"),
}
```
### 问题3: 数据库连接失败 "Ident authentication failed"
**原因:** PostgreSQL默认使用ident认证
**解决:**
```bash
sed -i 's/ident/trust/g' /var/lib/pgsql/data/pg_hba.conf
systemctl restart postgresql
```
## 验证命令
```bash
# 测试机1验证
curl -s -o /dev/null -w "%{http_code}" http://47.103.29.111/
curl -s -o /dev/null -w "%{http_code}" http://47.103.29.111/static/images/jiachenlong-logo.png
# 测试机2验证
curl -s http://47.103.9.192:3000/
curl -s -o /dev/null -w "%{http_code}" http://47.103.9.192/static/images/jiachenlong-logo.png
```
## 短信配置
| 项目 | 值 |
|------|-----|
| 模板Code | SMS_501590956 |
| 签名 | 苏州算力 |
| AccessKey ID | LTAI5tQAx5niD7JQVqGE5acE |
| AccessKey Secret | QsQFAEKBkaNynIoKyvdIi3BUyWVZu1 |

View File

@ -66,7 +66,6 @@ tailwindcss@3
| 版本 | 日期 | 更新人 | 更新内容 |
|------|------|--------|---------|
| v1.2 | 2026-03-15 | 菜鸟小 D | 添加详细版本要求和关键注意事项 |
| v1.2.4 | 2026-03-20 | 甲辰生产 | 修复OCR扩展名大小写问题、修复用户协议乱码、添加返回按钮 |
| v1.1 | 2026-03-15 | 菜鸟小 D | 根据实地检查更新实际配置 |
| v1.0 | 2026-03-15 | 菜鸟小 D | 初始版本 |

View File

@ -1,72 +0,0 @@
# 部署检查清单
## 部署后必须检查
### 1. 环境变量检查
```bash
cat backend/.env
```
必须包含:
- ✅ DASHSCOPE_API_KEY不是sk-xxx
- ✅ SMS_ACCESS_KEY_ID
- ✅ SMS_SIGN_NAME
- ✅ SMS_TEMPLATE_CODE
### 2. 代码版本检查
```bash
git log --oneline -1
```
确认是目标版本
### 3. OCR扩展名检查
```bash
grep temp_extensions backend/app/routers/ocr.py
```
确认包含大小写扩展名 JPG JPEG PNG GIF
### 4. Python版本检查
```bash
python3 --version
```
必须是 3.11+
### 5. 服务启动检查
```bash
curl http://localhost:3000/
```
返回 200
### 6. API测试
```bash
# 登录
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password=admin123" | python3 -c "import sys,json; print(json.load(sys.stdin).get(access_token,))")
# 测试验证码
curl -X POST http://localhost:3000/api/auth/send-verification-code \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"phone":"13800138000"}'
```
## 常见问题快速修复
| 问题 | 修复命令 |
|------|---------|
| OCR慢 | 检查DASHSCOPE_API_KEY是否正确 |
| 验证码失败 | 检查SMS_*配置是否完整 |
| 导入错误 | 使用python3.11启动 |
| 404错误 | 重启后端服务 |
### 7. 用户协议页面检查
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
确认返回HTML内容
### 8. 前端静态文件检查
确认用户协议文件存在

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 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">
<title>甲辰收藏 v=1.2.97</title>
<title>甲辰收藏 v1.1.19</title>
<!-- 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" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "jiachenlong-frontend",
"version": "1.2.82",
"version": "1.0.1",
"private": true,
"description": "甲辰藏品管理系统 - 移动端前端",
"scripts": {
@ -9,13 +9,13 @@
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"react": "^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": {
"@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

@ -1,115 +0,0 @@
<!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>

View File

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

View File

@ -3,7 +3,7 @@
// 修改版本号只需修改 ../../VERSION 文件中的 VERSION=xxx
// 从环境变量读取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 = {

View File

@ -1,13 +1,6 @@
/**
* Add - 添加藏品页面
* Version: 1.2.90x
* 更新
*/
// - AI //
import React, { useState, useRef } from 'react'
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:'带7号',label:'带7号'},{value:'带4号',label:'带4号'},{value:'其他',label:'其他'}];
//
const convertField = (obj) => {
@ -19,7 +12,7 @@ const convertField = (obj) => {
packaging: 'f02_12_packaging', rarity: 'f02_13_rarity',
isGraded: 'f03_20_is_graded', gradingCompany: 'f03_21_grading_company',
gradingScore: 'f03_22_grading_score', threeStar: 'f03_23_three_star',
specialMark: 'f04_30_special_mark', serialFeature: 'f04_31_serial_feature', numberCategory: 'f02_14_number_category',
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',
@ -68,66 +61,21 @@ const Input = ({ form, handleChange, label, field, type = 'text', options = null
}
const getDefaultForm = () => ({
name: '藏品', code: '', category: '自持', rarity: '通货', prefixSerial: '',
name: '龙钞', code: '', category: '自持', rarity: '通货', prefixSerial: '',
version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张',
material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false,
gradingCompany: '爱藏', gradingScore: '67+', threeStar: false, specialMark: '',
serialFeature: '', numberCategory: '', issuer: '中国人民银行', issueYear: '2024',
gradingCompany: '', gradingScore: '', threeStar: false, specialMark: '',
serialFeature: '', issuer: '中国人民银行', issueYear: '2024',
costPrice: '', targetPrice: '', goalPrice: '', repairFee: '', gradingFee: '', remark: ''
})
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
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const modeParam = params.get('mode')
const defaultTab = modeParam === 'manual' ? 'manual' : 'ai'
const [activeTab, setActiveTab] = useState(defaultTab) // ai, manual, deal
const [activeTab, setActiveTab] = useState(defaultTab) // ai, manual, batch
const [form, setForm] = useState(getDefaultForm())
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
@ -198,23 +146,6 @@ export default function Add() {
if (key === 'goalPrice' && value) {
setForm(prev => ({ ...prev, status: 'sold' }))
}
//
if (key === 'prefixSerial') {
const serial = value
let cat = ''
const match = serial.match(/J(\d{9})/)
const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9)
if (digits) {
if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347'
else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347'
else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247'
else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47'
else if (digits.includes('7') && !digits.includes('4')) cat = '带7号'
else if (digits.includes('4')) cat = '带4号'
else cat = '其他'
}
setForm(prev => ({ ...prev, numberCategory: cat }))
}
//
if (key === 'version') {
const yearMatch = value.match(/(20\d{2})/)
@ -313,26 +244,7 @@ export default function Add() {
setSaving(true)
setError('')
const token = localStorage.getItem('token')
//
const formWithCategory = { ...form }
if (formWithCategory.prefixSerial && !formWithCategory.numberCategory) {
const serial = formWithCategory.prefixSerial
const match = serial.match(/J(\d{9})/)
const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9)
if (digits) {
let cat = ''
if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347'
else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347'
else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247'
else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47'
else if (digits.includes('7') && !digits.includes('4')) cat = '带7号'
else if (digits.includes('4')) cat = '带4号'
else cat = '其他'
formWithCategory.numberCategory = cat
}
}
const formData = convertField(formWithCategory)
const formData = convertField(form)
try {
// 1.
const res = await fetch('/api/collections', {
@ -347,11 +259,6 @@ export default function Add() {
const result = await res.json()
let collectionId = result.f99_90_id || result.id
//
if (result.error) {
throw new Error(result.error.message || '保存失败')
}
//
if (result.warning && result.warning.code === 'DUPLICATE_SERIAL') {
const { warning } = result
@ -456,15 +363,15 @@ export default function Add() {
<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 识别
🤖 AI 识别
</button>
<button onClick={() => setActiveTab('manual')}
style={{ flex: 1, padding: '10px', background: activeTab === 'manual' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'manual' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
手工录入
手工录入
</button>
<button onClick={() => setActiveTab('deal')}
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' }}>
行情录入
<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>
@ -486,7 +393,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>
<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 ? '🔍 识别中...' : '开始识别'}
{recognizing ? '🔍 识别中...' : '🤖 开始识别'}
</button>
</div>
</div>
@ -629,7 +536,6 @@ export default function Add() {
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="名称 *" field="name" />
<Input form={form} handleChange={handleChange} label="编号(可选)" field="code" />
<Input form={form} handleChange={handleChange} label="持仓类型" field="category" options={categoryOptions} />
<Input form={form} handleChange={handleChange} label="珍惜度" field="rarity" options={rarityOptions} />
<Input form={form} handleChange={handleChange} label="冠字序号" field="prefixSerial" />
@ -667,7 +573,6 @@ export default function Add() {
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="特殊标识" field="specialMark" />
<Input form={form} handleChange={handleChange} label="号码特征" field="serialFeature" />
<Input form={form} handleChange={handleChange} label="号码分类" field="numberCategory" options={numberCategoryOptions} />
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
<Input form={form} handleChange={handleChange} label="材质" field="material" />
@ -708,380 +613,16 @@ export default function Add() {
</div>
)}
{/* 版本号 */}
{/* 行情录入模式 */}
{activeTab === 'deal' && (
<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>
{/* 批量录入模式 */}
{activeTab === 'batch' && (
<div style={{ padding: '48px 16px', textAlign: 'center', color: '#94a3b8' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🚧</div>
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '8px' }}>批量录入开发中</div>
<div style={{ fontSize: '13px' }}>敬请期待后续版本</div>
</div>
)}
{/* 版本号 */}
<div style={{ position: 'fixed', bottom: '16px', right: '16px', color: 'rgba(255,255,255,0.2)', fontSize: '11px', zIndex: 100 }}>v{APP_VERSION}</div>
</div>
)

View File

@ -1,9 +1,3 @@
/**
* Admin - 管理员用户管理页面
* Version: 1.2.98 (2026-04-19)
* 更新新增 dealCount 字段显示用户发布的行情数量
*/
import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version'
@ -132,18 +126,7 @@ export default function Admin() {
const updateData = {
username: editingUser.username,
email: editingUser.email,
phone: editingUser.phone,
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
role: editingUser.role
}
//
@ -203,56 +186,26 @@ export default function Admin() {
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{users.map((user) => (
<div key={user.id} style={{ background: 'rgba(30, 41, 59, 0.8)', borderRadius: '12px', padding: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<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={{
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)'),
color: user.role === 'admin' ? '#10b981' : (user.role === 'editor' ? '#f59e0b' : '#94a3b8'),
background: user.role === 'admin' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(148, 163, 184, 0.2)',
color: user.role === 'admin' ? '#10b981' : '#94a3b8',
fontSize: '12px'
}}>
{user.role === 'admin' ? '👑 管理员' : (user.role === 'editor' ? '📝 信息员' : '👤 用户')}
</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 || '青铜'}
{user.role === 'admin' ? '👑 管理员' : '👤 用户'}
</div>
</div>
<div style={{ color: '#64748b', fontSize: '13px', marginTop: '8px' }}>
📧 {user.email || '未设置'} | 📱 {user.phone || '未设置'} | 📱已认证: {user.phoneVerified ? '✓' : '✗'}
<div style={{ color: '#64748b', fontSize: '13px', marginTop: '4px' }}>
📧 {user.email || '未设置'} | 📱 {user.phone || '未设置'}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ color: '#94a3b8', fontSize: '12px' }}>藏品数</div>
<div style={{ color: '#60a5fa', fontSize: '20px', fontWeight: 'bold', cursor: 'pointer' }} onClick={() => window.location.hash = '#/list?userId=' + user.id} title='点击查看'>{user.collectionCount || 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 style={{ color: '#60a5fa', fontSize: '20px', fontWeight: 'bold' }}>{user.collection_count || 0}</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
@ -261,7 +214,7 @@ export default function Admin() {
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setEditingUser({ ...user, email: user.email || '', phone: user.phone || '', phoneVerified: user.phoneVerified !== false, newPassword: '', confirmPassword: '' })}
onClick={() => setEditingUser({ ...user })}
style={{
padding: '6px 12px',
borderRadius: '6px',
@ -347,7 +300,6 @@ export default function Admin() {
>
<option value="user">普通用户</option>
<option value="admin">管理员</option>
<option value="editor">信息员</option>
</select>
</div>
@ -399,28 +351,6 @@ export default function Admin() {
/>
</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' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>角色</label>
<select
@ -430,7 +360,6 @@ export default function Admin() {
>
<option value="user">普通用户</option>
<option value="admin">管理员</option>
<option value="editor">信息员</option>
</select>
</div>
</div>
@ -462,113 +391,6 @@ export default function Admin() {
</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' }}>
<button
onClick={() => setEditingUser(null)}

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'
export default function Detail() {
@ -236,7 +230,6 @@ export default function Detail() {
<Section title="特殊信息">
<Field label="特殊标识" value={collection.specialMark} />
<Field label="号码特征" value={collection.serialFeature} />
<Field label="号码分类" value={collection.numberCategory} />
<Field label="发行方" value={collection.issuer} />
<Field label="发行年份" value={collection.issueYear} />
<Field label="材质" value={collection.material} />

View File

@ -1,9 +1,3 @@
/**
* Edit - 编辑藏品页面
* Version: 1.2.90x
* 更新
*/
import React, { useState, useRef, useEffect } from 'react'
// Input Add.jsx
@ -52,7 +46,6 @@ const categoryOptions = [
{ 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 rarityOptions = [
{ value: '通货', label: '通货' },
{ value: '特色', label: '特色' },
@ -75,7 +68,7 @@ export default function Edit() {
const editId = params.get('id')
const [form, setForm] = useState({
name: '', code: '', category: '自持', rarity: '通货', numberCategory: '', prefixSerial: '',
name: '', code: '', category: '自持', rarity: '通货', prefixSerial: '',
version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张',
material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false,
gradingCompany: '', gradingScore: '', threeStar: false, specialMark: '',
@ -103,7 +96,6 @@ export default function Edit() {
code: data.code || '',
category: data.category || '自持',
rarity: data.rarity || '通货',
numberCategory: data.numberCategory || '',
prefixSerial: data.prefixSerial || '',
version: data.version || '2024 龙',
denomination: data.denomination || '',
@ -154,23 +146,6 @@ export default function Edit() {
const handleChange = (key, value) => {
setForm({ ...form, [key]: value })
if (key === 'targetPrice' && value) setForm(prev => ({ ...prev, status: 'sold' }))
//
if (key === 'prefixSerial') {
const serial = value
let cat = ''
const match = serial.match(/J(\d{9})/)
const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9)
if (digits) {
if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347'
else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347'
else if (digits.includes('4')) cat = '带4号'
else if (digits.includes('7')) cat = '带7号'
else if (!digits.includes('4') && !digits.includes('7')) cat = '无47'
else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247'
else cat = '其他'
}
setForm(prev => ({ ...prev, numberCategory: cat }))
}
//
if (key === 'version') {
const yearMatch = value.match(/(20\d{2})/)
@ -263,7 +238,6 @@ export default function Edit() {
status: 'f01_04_status', remark: 'f01_05_remark',
prefixSerial: 'f02_10_prefix_serial', version: 'f02_11_version',
packaging: 'f02_12_packaging', rarity: 'f02_13_rarity',
numberCategory: 'f02_14_number_category',
isGraded: 'f03_20_is_graded', gradingCompany: 'f03_21_grading_company',
gradingScore: 'f03_22_grading_score', threeStar: 'f03_23_three_star',
specialMark: 'f04_30_special_mark', serialFeature: 'f04_31_serial_feature',
@ -424,7 +398,6 @@ export default function Edit() {
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="特殊标识" field="specialMark" />
<Input form={form} handleChange={handleChange} label="号码特征" field="serialFeature" />
<Input form={form} handleChange={handleChange} label="号码分类" field="numberCategory" options={numberCategoryOptions} />
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
<Input form={form} handleChange={handleChange} label="材质" field="material" />

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 { APP_VERSION } from '../config/version'
@ -11,10 +5,6 @@ export default function Home() {
const [user, setUser] = useState(null)
const [stats, setStats] = useState({ totalCount: 0, totalCost: 0, totalRevenue: 0, expectedProfit: 0, totalProfit: 0, gradedCount: 0 })
const [recentCollections, setRecentCollections] = useState([])
const [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) || '/'
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'
@ -102,16 +67,16 @@ export default function Home() {
const formatMoney = (val) => {
if (!val || val === 0) return '0'
const v = val / 10000
return v.toFixed(1)
return v.toFixed(4)
}
const statCards = [
{ label: '藏品数', value: stats.totalCount, color: '#3b82f6' },
{ label: '总成本', value: formatMoney(stats.totalCost) + '万', color: '#22c55e' },
{ label: '总收入', value: formatMoney(stats.totalRevenue) + '万', color: '#06b6d4' },
{ label: '评级数', value: stats.gradedCount, color: '#8b5cf6' },
{ label: '预期利润', value: formatMoney(stats.expectedProfit) + '万', color: stats.expectedProfit >= 0 ? '#f59e0b' : '#ef4444' },
{ label: '总利润', value: formatMoney(stats.totalProfit) + '万', color: stats.totalProfit >= 0 ? '#10b981' : '#f43f5e' }
{ label: '藏品数', value: stats.totalCount, color: '#3b82f6', icon: '📦' },
{ label: '总成本', value: formatMoney(stats.totalCost) + '万', color: '#22c55e', icon: '💰' },
{ label: '总收入', value: formatMoney(stats.totalRevenue) + '万', color: '#06b6d4', icon: '📈' },
{ label: '评级数', value: stats.gradedCount, color: '#8b5cf6', icon: '⭐' },
{ label: '预期利润', value: formatMoney(stats.expectedProfit) + '万', color: stats.expectedProfit >= 0 ? '#f59e0b' : '#ef4444', icon: '🎯' },
{ label: '总利润', value: formatMoney(stats.totalProfit) + '万', color: stats.totalProfit >= 0 ? '#10b981' : '#f43f5e', icon: '💵' }
]
return (
@ -133,7 +98,7 @@ export default function Home() {
<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' }} />
<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>
<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)' }}
onClick={() => window.location.hash = '#/stats'}
>
<div style={{ fontSize: '24px', marginBottom: '4px' }}>{card.icon}</div>
<div style={{ color: card.color, fontSize: '18px', fontWeight: '700', marginBottom: '4px' }}>{card.value}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>{card.label}</div>
</div>
@ -180,171 +145,41 @@ export default function Home() {
<div onClick={() => window.location.hash = '#/add?mode=ocr'} style={{
background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
borderRadius: '12px',
padding: '12px 16px',
padding: '16px',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center'
gap: '12px'
}}>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>藏品录入</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>AI识别添加</div>
</div>
<div onClick={() => window.location.hash = '#/add?mode=deal'} style={{
background: 'linear-gradient(135deg, #ec4899 0%, #db2777 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 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 style={{ fontSize: '24px' }}>📷</div>
<div>
<div style={{ color: '#fff', fontSize: '15px', fontWeight: '600' }}>AI识别</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '11px' }}>拍照识别藏品</div>
</div>
</div>
<div onClick={() => window.location.hash = '#/add?mode=manual'} style={{
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
borderRadius: '12px',
padding: '12px 16px',
padding: '16px',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center'
gap: '12px'
}}>
<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>
{/* 寻配号数据 */}
<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 style={{ fontSize: '24px' }}></div>
<div>
<div style={{ color: '#fbbf24', fontSize: '18px', fontWeight: '700' }}>{seekStats.seekCount || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', 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 style={{ color: '#fff', fontSize: '15px', fontWeight: '600' }}>手动录入</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '11px' }}>添加新藏品</div>
</div>
</div>
</div>
</div>
{/* 一尘今日数据 */}
<div 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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', paddingLeft: '4px' }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px' }}>📝 最新一尘发帖</div>
<div onClick={() => window.location.hash = '#/news'} style={{ color: '#3b82f6', fontSize: '12px', cursor: 'pointer' }}>查看全部 </div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px' }}>最近藏品</div>
<div onClick={() => window.location.hash = '#/list'} style={{ color: '#3b82f6', fontSize: '12px', cursor: 'pointer' }}>查看全部 </div>
</div>
<div style={{
background: 'rgba(255,255,255,0.03)',
@ -352,30 +187,24 @@ export default function Home() {
border: '1px solid rgba(255,255,255,0.05)',
overflow: 'hidden'
}}>
{recentPosts.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>暂无帖子</div>
{recentCollections.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>暂无藏品</div>
) : (
recentPosts.map((item, idx) => (
<div key={item.post_id || idx} onClick={() => window.open(item.url, '_blank')} style={{
recentCollections.map((item, idx) => (
<div key={item.id || idx} onClick={() => window.location.hash = '#/detail?id=' + item.id} style={{
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',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ flex: 1 }}>
<div style={{ color: '#fff', fontSize: '14px' }}>{item.title || '无标题'}</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>
<div style={{ color: '#fff', fontSize: '14px' }}>{item.code || '-'} <span style={{ color: 'rgba(255,255,255,0.4)', fontSize: '12px' }}>{item.prefixSerial || ''}</span></div>
<div style={{ color: 'rgba(255,255,255,0.4)', fontSize: '12px', marginTop: '2px' }}>{item.version || '-'} · {item.status === 'sold' ? '已售' : item.status === 'in_collection' ? '收藏中' : item.status}</div>
</div>
<div style={{
color: item.post_type === 'deal' ? '#10b981' : item.post_type === 'want' ? '#f59e0b' : '#8b5cf6',
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 style={{ color: item.costPrice ? '#22c55e' : 'rgba(255,255,255,0.3)', fontSize: '13px' }}>
{item.costPrice ? '¥' + item.costPrice : '-'}
</div>
</div>
))
@ -399,13 +228,13 @@ export default function Home() {
{(isAdmin ? [
{ icon: '🏠', label: '首页', hash: '#/', idx: 0 },
{ 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: '#/admin', idx: 4 }
] : [
{ icon: '🏠', label: '首页', hash: '#/', idx: 0 },
{ icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
{ icon: '', label: '录入', hash: '#/ocr', idx: 2 },
{ icon: '', label: '添加', hash: '#/ocr', idx: 2 },
{ icon: '📊', label: '统计', hash: '#/stats', idx: 3 }
]).map((item) => (
<div key={item.idx} onClick={() => window.location.hash = item.hash} style={{

View File

@ -1,35 +1,17 @@
/**
* List - 藏品列表页面
* Version: 1.2.93x
* 更新
*/
import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version'
const API_BASE = localStorage.getItem('API_BASE') || ''
export default function List() {
const [collections, setCollections] = useState([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('')
const [filterType, setFilterType] = useState('')
const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || '')
const urlUserId = urlParams.get('userId') || ''
const [userIdFilter, setUserIdFilter] = useState(urlUserId)
const [sortField, setSortField] = useState('createdAt')
const [sortOrder, setSortOrder] = useState('desc')
const [viewMode, setViewMode] = useState('list')
const [key, setKey] = useState(0)
const [search, setSearch] = useState('')
const [isAdmin, setIsAdmin] = useState(false)
const [page, setPage] = useState(1)
const [pagination, setPagination] = useState({ total: 0, pages: 1 })
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(() => {
//
@ -74,70 +56,11 @@ 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 () => {
setLoading(true)
const token = localStorage.getItem('token')
try {
// URL
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const urlFilterType = params.get('filter') || ''
const urlFilterValue = params.get('value') ? decodeURIComponent(params.get('value')) : ''
let api = '/api/collections?page=' + page + '&limit=100&sortBy=' + sortField + '&sortOrder=' + sortOrder
if (urlFilterType && urlFilterValue) {
api += '&' + urlFilterType + '=' + encodeURIComponent(urlFilterValue)
}
const res = await fetch(api, {
const res = await fetch('/api/collections?limit=100', {
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
})
@ -157,18 +80,12 @@ export default function List() {
}
//
// IDURL
if (userIdFilter) {
list = list.filter(item => item.userId === userIdFilter || item.userId === userIdFilter.replace(/-/g, ''))
}
if (filterType && filter) {
const fieldMap = {
status: 'status',
category: 'category',
packaging: 'packaging',
rarity: 'rarity',
numberCategory: 'numberCategory',
version: 'version',
gradingCompany: 'gradingCompany',
gradingScore: 'gradingScore',
@ -195,14 +112,6 @@ export default function List() {
}
setCollections(list || [])
//
if (data.pagination) {
setPagination({
total: data.pagination.total || 0,
pages: data.pagination.pages || 1
})
}
} catch (e) {
console.error('Fetch collections error:', e)
//
@ -213,14 +122,6 @@ export default function List() {
setLoading(false)
}
//
useEffect(() => {
if (page > 1) {
fetchCollections()
}
}, [page])
const refresh = () => {
setKey(k => k + 1)
}
@ -369,11 +270,6 @@ export default function List() {
const rarityOrder = { '通货': 1, '特色': 2, '少见': 3, '稀有': 4, '珍品': 5, '孤品': 6 }
aVal = rarityOrder[aVal] || 0
bVal = rarityOrder[bVal] || 0
} else if (sortField === 'numberCategory') {
//
const numberCategoryOrder = { '无2347': 1, '无347': 2, '无247': 3, '无47': 4, '带7号': 5, '带4号': 6, '其他': 7 }
aVal = numberCategoryOrder[aVal] || 99
bVal = numberCategoryOrder[bVal] || 99
}
if (aVal == null) return 1
if (bVal == null) return -1
@ -413,30 +309,6 @@ export default function List() {
return colors[category] || '#64748b'
}
const getNumberCategoryColor = (cat) => {
const colors = { '无2347': '#8b5cf6', '无347': '#ef4444', '无247': '#fbbf24', '无47': '#3b82f6', '带7号': '#06b6d4', '带4号': '#22c55e', '其他': '#64748b' };
return colors[cat] || '#64748b';
};
const getRarityColor = (rarity) => {
const colors = { '通货': '#22c55e', '特色': '#06b6d4', '少见': '#3b82f6', '稀有': '#ec4899', '珍品': '#ef4444', '孤品': '#8b5cf6' }
return colors[rarity] || '#64748b'
}
const formatPrefixSerial = (serial) => {
if (!serial) return '-'
// J101J + 9
const match = serial.match(/J(\d{9})/)
if (match) return 'J' + match[1]
// J10
return serial.substring(0, 10)
}
const getPackagingColor = (packaging) => {
const colors = { '裸钞': '#22c55e', '单张': '#3b82f6', '标十': '#fbbf24', '标百': '#8b5cf6' }
return colors[packaging] || '#64748b'
}
const getStatusColor = (status) => {
const colors = {
'in_collection': '#22c55e',
@ -470,46 +342,37 @@ export default function List() {
}
const ListItem = ({ item }) => (
<div onClick={() => goDetail(item.id)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '12px', padding: '10px', marginBottom: '8px', cursor: 'pointer' }}>
{/* 第1行编号 + 冠字号 + 包装(蓝色) | 状态 + 持仓类型 + 珍惜度(紫色) */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
<span style={{ color: '#fff', fontSize: '11px', fontWeight: 'normal' }}>{item.code || '-'}</span>
<span style={{ color: '#fbbf24', fontSize: '14px', fontFamily: 'monospace', }}>{formatPrefixSerial(item.prefixSerial)}</span>
{item.packaging && <span style={{ background: getPackagingColor(item.packaging) + '20', color: getPackagingColor(item.packaging), fontSize: '10px', padding: '1px 5px', borderRadius: '3px' }}>{item.packaging}</span>}
{item.numberCategory && <span style={{ color: getNumberCategoryColor(item.numberCategory), fontSize: '9px', padding: '1px 4px', background: getNumberCategoryColor(item.numberCategory) + '20', borderRadius: '2px', marginLeft: '4px' }}>{item.numberCategory}</span>}
<div onClick={() => goDetail(item.id)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '12px', padding: '14px', marginBottom: '10px', cursor: 'pointer' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ color: '#fff', fontSize: '15px', fontWeight: '500' }}>{item.code || '-'}</div>
{isAdmin && item.owner_name && <div style={{ color: '#60a5fa', fontSize: '11px', padding: '2px 6px', background: 'rgba(96, 165, 250, 0.2)', borderRadius: '4px' }}>👤 {item.owner_name}</div>}
</div>
<div style={{ color: '#fbbf24', fontSize: '13px', fontFamily: 'monospace', marginTop: '4px' }}>{item.prefixSerial || '-'}</div>
</div>
<div style={{ display: 'flex', gap: '3px' }}>
{item.status && <span style={{ color: getStatusColor(item.status), fontSize: '9px', padding: '1px 4px', background: getStatusColor(item.status) + '25', borderRadius: '2px' }}>{getStatusText(item.status)}</span>}
{item.category && <span style={{ color: getCategoryColor(item.category), fontSize: '9px', padding: '1px 4px', background: getCategoryColor(item.category) + '25', borderRadius: '2px' }}>{getCategoryText(item.category)}</span>}
<div style={{ textAlign: 'right' }}>
{item.category && <div style={{ color: getCategoryColor(item.category), fontSize: '12px', padding: '3px 10px', background: getCategoryColor(item.category) + '20', borderRadius: '4px', display: 'inline-block', marginBottom: '4px' }}>{getCategoryText(item.category)}</div>}
{item.rarity && <div style={{ color: '#8b5cf6', fontSize: '12px', padding: '3px 10px', background: 'rgba(139, 92, 246, 0.2)', borderRadius: '4px', display: 'inline-block', marginBottom: '4px' }}>{item.rarity}</div>}
<div style={{ color: getStatusColor(item.status), fontSize: '12px', padding: '3px 10px', background: getStatusColor(item.status) + '20', borderRadius: '4px', display: 'inline-block' }}>{getStatusText(item.status)}</div>
{item.gradingScore && <div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginTop: '6px' }}>{item.gradingScore}</div>}
</div>
</div>
{/* 第2行版本(彩色) + 已评级 + 评级公司 + 评级分数 + 三星 + 特殊标识 | 备注 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '2px', flexWrap: 'wrap', marginBottom: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '2px', flexWrap: 'wrap' }}>
{item.version && <span style={{ background: getVersionColor(item.version).bg, color: getVersionColor(item.version).color, fontSize: '10px', padding: '1px 5px', borderRadius: '3px' }}>{item.version}</span>}
{item.isGraded && <span style={{ background: 'rgba(6, 182, 212, 0.15)', color: '#06b6d4', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>已评级</span>}
{item.gradingCompany && <span style={{ background: 'rgba(6, 182, 212, 0.15)', color: '#06b6d4', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>{item.gradingCompany.substring(0,4)}</span>}
{item.gradingScore && <span style={{ background: 'rgba(249, 115, 22, 0.2)', color: '#f97316', fontSize: '9px', padding: '1px 4px', borderRadius: '2px', }}>{item.gradingScore}</span>}
{item.threeStar && <span style={{ background: 'rgba(6, 182, 212, 0.15)', color: '#06b6d4', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>三星</span>}
{item.specialMark && <span style={{ background: 'rgba(139, 92, 246, 0.15)', color: '#a78bfa', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>{item.specialMark}</span>}
</div>
<div style={{ display: 'flex', gap: '2px', marginLeft: 'auto' }}>
{item.rarity && <span style={{ color: getRarityColor(item.rarity), fontSize: '9px', padding: '1px 4px', background: getRarityColor(item.rarity) + '20', borderRadius: '2px' }}>{item.rarity}</span>}
{item.remark && <span style={{ background: 'rgba(100,116,139,0.2)', color: '#94a3b8', fontSize: '9px', padding: '1px 4px', borderRadius: '2px', maxWidth: '80px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.remark}</span>}
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '10px', flexWrap: 'wrap' }}>
{item.version && <span style={{ background: getVersionColor(item.version).bg, color: getVersionColor(item.version).color, fontSize: '12px', fontWeight: 'bold', padding: '4px 10px', borderRadius: '4px' }}>{item.version}</span>}
{item.packaging && <span style={{ background: 'rgba(255,255,255,0.08)', color: '#94a3b8', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>{item.packaging}</span>}
{item.isGraded ? <span style={{ background: 'rgba(251, 191, 36, 0.15)', color: '#fbbf24', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>已评级</span> : <span style={{ background: 'rgba(255,255,255,0.08)', color: '#64748b', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>未评级</span>}
{item.gradingCompany && <span style={{ background: 'rgba(251, 191, 36, 0.15)', color: '#fbbf24', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>{item.gradingCompany}</span>}
{item.threeStar && <span style={{ background: 'rgba(251, 191, 36, 0.15)', color: '#fbbf24', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>三星</span>}
{item.specialMark && <span style={{ background: 'rgba(139, 92, 246, 0.15)', color: '#8b5cf6', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>{item.specialMark}</span>}
{item.remark && <span style={{ background: 'rgba(100,116,139,0.2)', color: '#94a3b8', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>{item.remark}</span>}
</div>
{/* 第3行成本 + 修复 + 评级 | 目标 + 出售 */}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', color: '#94a3b8', paddingTop: '4px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', gap: '8px' }}>
{item.costPrice && <span>成本: <span style={{ color: '#e2e8f0' }}>¥{item.costPrice}</span></span>}
{item.repairFee && <span>修复: <span style={{ color: '#e2e8f0' }}>¥{item.repairFee}</span></span>}
{item.gradingFee && <span>评级: <span style={{ color: '#e2e8f0' }}>¥{item.gradingFee}</span></span>}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
{item.targetPrice && <span>目标: <span style={{ color: '#fbbf24' }}>¥{item.targetPrice}</span></span>}
{item.goalPrice && <span>出售: <span style={{ color: '#4ade80' }}>¥{item.goalPrice}</span></span>}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '10px', paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
{item.costPrice && <span style={{ color: '#94a3b8', fontSize: '12px' }}>成本: <span style={{ color: '#fff' }}>¥{item.costPrice}</span></span>}
{item.targetPrice && <span style={{ color: '#94a3b8', fontSize: '12px' }}>目标: <span style={{ color: '#f59e0b' }}>¥{item.targetPrice}</span></span>}
{item.goalPrice && <span style={{ color: '#94a3b8', fontSize: '12px' }}>出售: <span style={{ color: '#22c55e' }}>¥{item.goalPrice}</span></span>}
{item.repairFee && <span style={{ color: '#94a3b8', fontSize: '12px' }}>修复: <span style={{ color: '#fff' }}>¥{item.repairFee}</span></span>}
{item.gradingFee && <span style={{ color: '#94a3b8', fontSize: '12px' }}>评级: <span style={{ color: '#fff' }}>¥{item.gradingFee}</span></span>}
</div>
</div>
)
@ -518,18 +381,7 @@ export default function List() {
<div style={{ minHeight: '100vh', overflowY: 'auto', background: '#0f172a', paddingBottom: '70px' }}>
<div style={{ padding: '20px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<div style={{ color: '#fff', fontSize: '20px' }}>
<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={{ color: '#fff', fontSize: '20px', fontWeight: 'bold' }}>我的藏品 <span style={{ fontSize: '14px', color: '#fbbf24' }}>({filteredCollections.length})</span></div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{filter && filterType && (
<button
@ -538,7 +390,7 @@ export default function List() {
setFilterType('')
window.location.hash = '#/list'
}}
style={{ background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', border: 'none', borderRadius: '6px', padding: '4px 10px', fontSize: '12px', cursor: 'pointer' }}
style={{ background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', border: 'none', borderRadius: '6px', padding: '6px 12px', fontSize: '13px', cursor: 'pointer' }}
>
清除筛选
</button>
@ -549,15 +401,13 @@ export default function List() {
{filter && filterType && (
<div style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', borderRadius: '8px', padding: '12px', marginBottom: '16px' }}>
<div style={{ color: '#60a5fa', fontSize: '12px', }}>当前筛选</div>
<div style={{ color: '#60a5fa', fontSize: '13px', fontWeight: 'bold' }}>当前筛选</div>
<div style={{ color: '#fff', fontSize: '14px', marginTop: '4px' }}>
{getFilterLabel(filterType)} = <span style={{ color: '#fbbf24', }}>{getFilterValueLabel(filterType, filter)}</span>
{getFilterLabel(filterType)} = <span style={{ color: '#fbbf24', fontWeight: 'bold' }}>{getFilterValueLabel(filterType, filter)}</span>
</div>
</div>
)}
{activeTab === 'collections' && (
<>
{/* 搜索框 - 全字段搜索 */}
<div style={{ position: 'relative', marginBottom: '16px' }}>
<input type="text"
@ -570,7 +420,7 @@ export default function List() {
color: '#fff',
width: '100%',
boxSizing: 'border-box',
padding: '4px 40px 4px 8px',
padding: '12px 40px 12px 12px',
borderRadius: '12px',
fontSize: '14px',
outline: 'none',
@ -595,7 +445,7 @@ export default function List() {
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start'
justifyContent: 'center'
}}
>
@ -605,12 +455,15 @@ export default function List() {
{/* 排序表头按钮 */}
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap', marginBottom: '12px' }}>
<span style={{ color: '#94a3b8', fontSize: '12px', marginRight: '2px' }}>排序:</span>
<span style={{ color: '#94a3b8', fontSize: '13px', marginRight: '4px' }}>排序:</span>
{[
{ key: 'code', label: '编号' },
{ key: 'prefixSerial', label: '冠字号' },
{ key: 'costPrice', label: '成本' },
{ key: 'goalPrice', label: '售价' },
{ key: 'category', label: '持仓类型' },
{ key: 'rarity', label: '珍惜度' },
{ key: 'numberCategory', label: '号码分类' },
{ key: 'packaging', label: '包装类型' },
{ key: 'gradingScore', label: '评级分数' }
].map(item => (
<div key={item.key} onClick={() => {
if (sortField === item.key) {
@ -620,9 +473,9 @@ export default function List() {
setSortOrder('desc')
}
}} style={{
padding: '4px 8px',
padding: '8px 14px',
borderRadius: '8px',
fontSize: '12px',
fontSize: '13px',
cursor: 'pointer',
background: sortField === item.key ? (sortOrder === 'asc' ? '#22c55e' : '#fbbf24') : 'rgba(255,255,255,0.08)',
color: sortField === item.key ? '#fff' : '#94a3b8',
@ -633,70 +486,9 @@ export default function List() {
</div>
))}
</div>
</>
)}
{/* 分页组件 - 仅在藏品tab下显示 */}
{activeTab === 'collections' && pagination.pages > 1 && (
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', justifyContent: 'flex-start', marginBottom: '12px', padding: '8px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px' }}>
<button onClick={() => { setPage(Math.max(1, page - 1)) }} disabled={page === 1} style={{ padding: '4px 10px', borderRadius: '6px', border: 'none', background: page === 1 ? 'rgba(255,255,255,0.1)' : '#3b82f6', color: '#fff', cursor: page === 1 ? 'not-allowed' : 'pointer', opacity: page === 1 ? 0.5 : 1 }}>上一页</button>
{Array.from({ length: Math.min(5, pagination.pages) }, (_, i) => {
let startPage = Math.max(1, page - 2)
return <button key={i} onClick={() => { setPage(startPage + i) }} style={{ padding: '4px 8px', borderRadius: '6px', border: 'none', background: page === startPage + i ? '#3b82f6' : 'rgba(255,255,255,0.08)', color: '#fff', cursor: 'pointer' }}>{startPage + i}</button>
})}
<button onClick={() => { setPage(Math.min(pagination.pages, page + 1)) }} disabled={page === pagination.pages} style={{ padding: '4px 10px', borderRadius: '6px', border: 'none', background: page === pagination.pages ? 'rgba(255,255,255,0.1)' : '#3b82f6', color: '#fff', cursor: page === pagination.pages ? 'not-allowed' : 'pointer', opacity: page === pagination.pages ? 0.5 : 1 }}>下一页</button>
<span style={{ color: '#94a3b8', fontSize: '11px', marginLeft: '8px' }}>{pagination.total}</span>
</div>
)}
</div>
<div style={{ padding: '16px' }}>
{/* 行情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 ? (
<div style={{ textAlign: 'center', padding: '60px', color: '#64748b' }}>加载中...</div>
) : filteredCollections.length === 0 ? (
@ -705,11 +497,11 @@ export default function List() {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
{filteredCollections.map(item => (
<div key={item.id} onClick={() => goDetail(item.id)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '12px', padding: '12px', cursor: 'pointer' }}>
<div style={{ height: '80px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', fontSize: '32px', marginBottom: '10px' }}>🐉</div>
<div style={{ color: '#fff', fontSize: '11px', fontWeight: 'normal' }}>{item.code || '-'}</div>
<div style={{ color: '#fbbf24', fontSize: '11px', fontFamily: 'monospace', marginTop: '2px' }}>{formatPrefixSerial(item.prefixSerial)}</div>
<div style={{ display: 'flex', gap: '2px', marginTop: '6px', flexWrap: 'wrap' }}>
{item.gradingScore && <span style={{ color: '#fbbf24', fontSize: '11px', }}>{item.gradingScore}</span>}
<div style={{ height: '80px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '32px', marginBottom: '10px' }}>🐉</div>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '500' }}>{item.code || '-'}</div>
<div style={{ color: '#fbbf24', fontSize: '12px', fontFamily: 'monospace', marginTop: '2px' }}>{item.prefixSerial || '-'}</div>
<div style={{ display: 'flex', gap: '4px', marginTop: '6px', flexWrap: 'wrap' }}>
{item.gradingScore && <span style={{ color: '#fbbf24', fontSize: '12px', fontWeight: 'bold' }}>{item.gradingScore}</span>}
{item.threeStar && <span style={{ color: '#fbbf24', fontSize: '10px' }}></span>}
</div>
</div>
@ -718,237 +510,7 @@ export default function List() {
) : (
filteredCollections.map(item => <ListItem key={item.id} item={item} />)
)}
</>
)}
</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 { APP_VERSION } from '../config/version'
import { api } from '../utils/api'
@ -18,84 +12,21 @@ export default function Login() {
//
const [showRegister, setShowRegister] = useState(false)
const [registerData, setRegisterData] = useState({ agreeTerms: false,
username: '',
password: '',
confirmPassword: '',
email: '',
phone: '',
verifyCode: '',
inviteCode: ''
})
const [registerData, setRegisterData] = useState({ username: '', password: '', confirmPassword: '', email: '' })
const [registerLoading, setRegisterLoading] = useState(false)
const [registerError, setRegisterError] = useState('')
//
const [sendingCode, setSendingCode] = useState(false)
const [codeCountdown, setCodeCountdown] = useState(0)
const [codeSent, setCodeSent] = useState(false)
//
//
useEffect(() => {
generateCaptcha()
}, [])
//
useEffect(() => {
if (codeCountdown > 0) {
const timer = setTimeout(() => setCodeCountdown(codeCountdown - 1), 1000)
return () => clearTimeout(timer)
}
}, [codeCountdown])
const generateCaptcha = () => {
const num1 = Math.floor(Math.random() * 10)
const num2 = Math.floor(Math.random() * 10)
setCaptcha({ num1, num2, answer: '' })
}
//
const handleSendCode = async () => {
if (!registerData.phone) {
setRegisterError('请先输入手机号')
return
}
//
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(registerData.phone)) {
setRegisterError('请输入正确的手机号')
return
}
setSendingCode(true)
setRegisterError('')
try {
const res = await fetch('/api/auth/send-verification-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ phone: registerData.phone })
})
const data = await res.json()
if (data.success) {
setCodeSent(true)
setCodeCountdown(60)
setRegisterError('')
} else {
setRegisterError(data.message || '发送失败')
}
} catch (err) {
setRegisterError('发送失败,请稍后重试')
} finally {
setSendingCode(false)
}
}
//
const handleRegister = async () => {
//
@ -104,18 +35,13 @@ export default function Login() {
return
}
if (!registerData.agreeTerms) {
setRegisterError('请先同意用户协议')
if (registerData.username.length < 3) {
setRegisterError('用户名至少 3 个字符')
return
}
if (registerData.username.length < 2) {
setRegisterError('用户名至少 2 个字符')
return
}
if (registerData.password.length < 8 || !/[A-Z]/.test(registerData.password) || !/[a-z]/.test(registerData.password) || !/[0-9]/.test(registerData.password)) {
setRegisterError('密码至少8位需包含大写、小写、数字')
if (registerData.password.length < 6) {
setRegisterError('密码至少 6 个字符')
return
}
@ -124,40 +50,17 @@ export default function Login() {
return
}
//
if (!registerData.phone) {
setRegisterError('手机号为必填项')
return
}
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(registerData.phone)) {
setRegisterError('请输入正确的手机号')
return
}
//
if (!registerData.verifyCode) {
setRegisterError('请输入短信获取')
return
}
setRegisterLoading(true)
setRegisterError('')
try {
const payload = {
username: registerData.username,
password: registerData.password,
phone: registerData.phone,
verifyCode: registerData.verifyCode
password: registerData.password
}
if (registerData.email) {
payload.email = registerData.email
}
if (registerData.inviteCode) {
payload.inviteCode = registerData.inviteCode
}
const res = await fetch('/api/auth/register', {
method: 'POST',
@ -170,14 +73,12 @@ export default function Login() {
const data = await res.json()
if (!res.ok) {
throw new Error(data.detail || data.error?.message || '注册失败')
throw new Error(data.error?.message || '注册失败')
}
alert('注册成功!请登录')
setShowRegister(false)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '', inviteCode: '' })
setCodeSent(false)
setCodeCountdown(0)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '' })
generateCaptcha()
} catch (err) {
console.error('注册错误:', err)
@ -194,13 +95,17 @@ export default function Login() {
return
}
if (username.length < 2) {
if (username.length < 3) {
setError(ErrorCodes.E00021.message)
return
}
if (password.length < 6) {
setError(ErrorCodes.E00022.message)
return
}
//
//
const expectedAnswer = captcha.num1 + captcha.num2
if (!captcha.answer || Number(captcha.answer) !== expectedAnswer) {
setError(ErrorCodes.E00012.message)
@ -265,6 +170,7 @@ export default function Login() {
background: '#fff'
}}
/>
<h1 style={{ color: '#fbbf24', fontSize: '28px', fontWeight: '700', margin: '16px 0 8px' }}>甲辰收藏</h1>
<p style={{ color: 'rgba(255,255,255,0.6)', fontSize: '14px', margin: 0 }}>生肖纪念钞管理系统</p>
</div>
@ -335,11 +241,11 @@ export default function Login() {
/>
</div>
{/* 获取 */}
{/* 验证码 */}
<div style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', gap: '12px' }}>
<div onClick={generateCaptcha} style={{
flex: 1, minWidth: "90px", width: "auto",
flex: 1,
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
@ -449,9 +355,7 @@ export default function Login() {
background: 'rgba(30, 41, 59, 0.95)',
borderRadius: '16px',
padding: '24px',
border: '1px solid rgba(255,255,255,0.1)',
maxHeight: '90vh',
overflowY: 'auto'
border: '1px solid rgba(255,255,255,0.1)'
}}>
<h2 style={{ color: '#fbbf24', fontSize: '20px', fontWeight: '600', marginBottom: '20px', textAlign: 'center' }}>
注册新账号 🎉
@ -477,7 +381,7 @@ export default function Login() {
type="text"
value={registerData.username}
onChange={(e) => setRegisterData(prev => ({ ...prev, username: e.target.value }))}
placeholder="用户名(至少 2 个字符)"
placeholder="用户名(至少 3 个字符)"
style={{
width: '100%',
padding: '14px',
@ -491,68 +395,6 @@ export default function Login() {
/>
</div>
{/* 手机号 */}
<div style={{ marginBottom: '16px' }}>
<input
type="tel"
value={registerData.phone}
onChange={(e) => setRegisterData(prev => ({ ...prev, phone: e.target.value }))}
placeholder="手机号11位"
maxLength={11}
style={{
width: '100%',
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
</div>
{/* 短信获取 */}
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="text"
value={registerData.verifyCode}
onChange={(e) => setRegisterData(prev => ({ ...prev, verifyCode: e.target.value }))}
placeholder="短信验证码"
maxLength={6}
style={{
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
<button
onClick={handleSendCode}
disabled={sendingCode || codeCountdown > 0}
style={{
minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: 'none',
background: codeCountdown > 0 ? 'rgba(148, 163, 184, 0.3)' : 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
color: '#fff',
fontSize: '14px',
fontWeight: '600',
cursor: codeCountdown > 0 ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap'
}}
>
{codeCountdown > 0 ? `${codeCountdown}` : sendingCode ? '发送中...' : '获取验证码'}
</button>
</div>
</div>
{/* 邮箱(可选) */}
<div style={{ marginBottom: '16px' }}>
<input
@ -579,7 +421,7 @@ export default function Login() {
type="password"
value={registerData.password}
onChange={(e) => setRegisterData(prev => ({ ...prev, password: e.target.value }))}
placeholder="密码(至少8位需大写+小写+数字"
placeholder="密码(至少 6 个字符"
style={{
width: '100%',
padding: '14px',
@ -594,7 +436,7 @@ export default function Login() {
</div>
{/* 确认密码 */}
<div style={{ marginBottom: '16px' }}>
<div style={{ marginBottom: '24px' }}>
<input
type="password"
value={registerData.confirmPassword}
@ -613,56 +455,16 @@ export default function Login() {
/>
</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' }}>
<input
type="checkbox"
checked={registerData.agreeTerms}
onChange={(e) => setRegisterData(prev => ({ ...prev, agreeTerms: e.target.checked }))}
style={{ width: '18px', height: '18px', marginTop: '2px', cursor: 'pointer' }}
/>
<div style={{ fontSize: '12px', color: 'rgba(255,255,255,0.7)', lineHeight: '1.5' }}>
我已阅读并同意
<span
onClick={() => window.open('/user_agreement.html', '_blank')}
style={{ color: '#fbbf24', cursor: 'pointer', textDecoration: 'underline' }}
>用户协议</span>
</div>
</div>
{/* 按钮 */}
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={() => {
setShowRegister(false)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '', inviteCode: '' })
setRegisterData({ username: '', password: '', confirmPassword: '', email: '' })
setRegisterError('')
setCodeSent(false)
setCodeCountdown(0)
}}
style={{
flex: 1, minWidth: "90px", width: "auto",
flex: 1,
padding: '14px',
borderRadius: '8px',
border: 'none',
@ -679,7 +481,7 @@ export default function Login() {
onClick={handleRegister}
disabled={registerLoading}
style={{
flex: 1, minWidth: "90px", width: "auto",
flex: 1,
padding: '14px',
borderRadius: '8px',
border: 'none',

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 { useNavigate } from 'react-router-dom'
@ -22,13 +16,6 @@ export default function Settings() {
bio: ''
})
//
const [phoneChanged, setPhoneChanged] = useState(false)
const [newPhone, setNewPhone] = useState('')
const [verifyCode, setVerifyCode] = useState('')
const [sendingCode, setSendingCode] = useState(false)
const [codeCountdown, setCodeCountdown] = useState(0)
const [passwordForm, setPasswordForm] = useState({
oldPassword: '',
newPassword: '',
@ -42,14 +29,6 @@ export default function Settings() {
fetchUserInfo()
}, [])
//
useEffect(() => {
if (codeCountdown > 0) {
const timer = setTimeout(() => setCodeCountdown(codeCountdown - 1), 1000)
return () => clearTimeout(timer)
}
}, [codeCountdown])
const fetchUserInfo = async () => {
try {
const res = await fetch('/api/users/me', {
@ -66,10 +45,12 @@ export default function Settings() {
address: data.address || '',
bio: data.bio || ''
})
setNewPhone(data.phone || '')
} else {
console.error('获取用户信息失败:', res.status)
}
} catch (e) {
console.error('获取用户信息异常:', e)
//
const userStr = localStorage.getItem('user')
if (userStr) {
try {
@ -82,7 +63,6 @@ export default function Settings() {
address: user.address || '',
bio: user.bio || ''
})
setNewPhone(user.phone || '')
} catch (e2) {
setError('获取用户信息失败')
}
@ -92,92 +72,26 @@ export default function Settings() {
}
}
//
const handleSendCode = async () => {
if (!newPhone) {
setError('请输入手机号')
return
}
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(newPhone)) {
setError('请输入正确的手机号')
return
}
setSendingCode(true)
setError('')
try {
const res = await fetch('/api/auth/send-verification-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ phone: newPhone })
})
const data = await res.json()
if (data.success) {
setPhoneChanged(true)
setCodeCountdown(60)
setSuccess('验证码已发送到 ' + newPhone.substring(0,3) + '****' + newPhone.substring(7))
} else {
setError(data.message || '发送失败')
}
} catch (err) {
setError('发送失败,请稍后重试')
} finally {
setSendingCode(false)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSuccess('')
//
if (newPhone !== form.phone) {
if (!verifyCode) {
setError('请输入手机验证码')
return
}
}
setSaving(true)
try {
const updateData = {
username: form.username,
email: form.email || null,
phone: newPhone,
address: form.address,
bio: form.bio
}
//
if (newPhone !== form.phone) {
updateData.verifyCode = verifyCode
}
const res = await fetch('/api/users/me', {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify(updateData)
body: JSON.stringify(form)
})
if (res.ok) {
const data = await res.json()
setSuccess('保存成功!')
setForm({...form, phone: newPhone})
setVerifyCode('')
setPhoneChanged(false)
//
//
const userStr = localStorage.getItem('user')
if (userStr) {
const user = JSON.parse(userStr)
@ -188,7 +102,7 @@ export default function Settings() {
}
} else {
const data = await res.json()
setError(data.error?.message || data.detail || '保存失败')
setError(data.error?.message || '保存失败')
}
} catch (e) {
setError('保存失败,请重试')
@ -212,14 +126,16 @@ export default function Settings() {
return
}
if (passwordForm.newPassword.length < 8) {
setError('密码至少8位需包含大写+小写+数字')
if (passwordForm.newPassword.length < 6) {
setError('密码长度至少6位')
return
}
setSaving(true)
console.log('开始修改密码...')
try {
console.log('发送请求到 /api/auth/change-password')
const res = await fetch('/api/auth/change-password', {
method: 'POST',
headers: {
@ -232,7 +148,9 @@ export default function Settings() {
})
})
console.log('响应状态:', res.status)
const data = await res.json()
console.log('响应数据:', data)
if (res.ok) {
setSuccess('密码修改成功!')
@ -282,12 +200,11 @@ export default function Settings() {
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>邮箱选填</label>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>邮箱</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="选填"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
@ -296,69 +213,27 @@ export default function Settings() {
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>手机号</label>
<input
type="tel"
value={newPhone}
onChange={(e) => {
setNewPhone(e.target.value)
setPhoneChanged(true)
setVerifyCode('')
}}
placeholder="11位手机号"
maxLength={11}
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
{/* 手机验证码 - 仅在手机号变更时显示 */}
{phoneChanged && (
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>手机验证码</label>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
placeholder="6位验证码"
maxLength={6}
style={{ flex: 1, padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
<button
type="button"
onClick={handleSendCode}
disabled={sendingCode || codeCountdown > 0}
style={{
width: '120px',
padding: '12px',
borderRadius: '8px',
border: 'none',
background: codeCountdown > 0 ? '#4b5563' : '#22c55e',
color: '#fff',
fontSize: '14px',
cursor: codeCountdown > 0 ? 'not-allowed' : 'pointer'
}}
>
{codeCountdown > 0 ? codeCountdown + '秒' : sendingCode ? '发送中...' : '获取验证码'}
</button>
</div>
</div>
)}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>地址选填</label>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>地址</label>
<input
type="text"
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
placeholder="选填"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>简介选填</label>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>简介</label>
<textarea
value={form.bio}
onChange={(e) => setForm({ ...form, bio: e.target.value })}
placeholder="选填"
rows={3}
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff', resize: 'none' }}
/>
@ -385,8 +260,8 @@ export default function Settings() {
type="password"
value={passwordForm.oldPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, oldPassword: e.target.value })}
placeholder="请输入当前密码"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
placeholder="请输入当前密码"
/>
</div>
@ -396,8 +271,8 @@ export default function Settings() {
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
placeholder="至少8位需包含大写+小写+数字"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
placeholder="至少6位"
/>
</div>
@ -407,8 +282,8 @@ export default function Settings() {
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
placeholder="再次输入新密码"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
placeholder="再次输入新密码"
/>
</div>

View File

@ -1,9 +1,4 @@
/**
* Stats - 统计页面
* Version: 1.2.70x
* 更新
*/
// -
import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version'
@ -15,7 +10,6 @@ export default function Stats() {
byGrading: [],
byPackaging: [],
byRarity: [],
byNumberCategory: [],
byVersion: [],
byGradingCompany: [],
byGradingScore: [],
@ -55,7 +49,6 @@ export default function Stats() {
byGrading: data.byGrading || [],
byPackaging: data.byPackaging || [],
byRarity: data.byRarity || [],
byNumberCategory: data.byNumberCategory || [],
byVersion: data.byVersion || [],
byGradingCompany: data.byGradingCompany || [],
byGradingScore: data.byGradingScore || [],
@ -93,9 +86,6 @@ export default function Stats() {
gradingCompany: 'gradingCompany',
gradingScore: 'gradingScore',
specialMark: 'specialMark',
numberCategory: 'numberCategory',
gradingCompany: 'gradingCompany',
gradingScore: 'gradingScore',
profitLoss: 'profitLoss'
}
return map[type] || type
@ -104,15 +94,10 @@ export default function Stats() {
//
const colors = {
packaging: { '标十': '#22c55e', '标百': '#3b82f6', '单张': '#f59e0b', '裸钞': '#64748b' },
rarity: { '通货': '#64748b', '特色': '#22c55e', '少见': '#3b82f6', '稀有': '#8b5cf6', '珍品': '#ef4444', '孤品': '#fbbf24' },
rarity: { '通货': '#64748b', '特色': '#22c55e', '少见': '#3b82f6', '稀有': '#8b5cf6', '珍品': '#f59e0b', '孤品': '#ef4444' },
status: { 'in_collection': '#22c55e', 'selling': '#f59e0b', 'sold': '#ef4444', 'grading': '#8b5cf6', 'repairing': '#f97316', 'transit': '#06b6d4', 'seeking': '#ec4899' },
category: { '自持': '#22c55e', '寄存': '#3b82f6', '寄售': '#f59e0b', '共有': '#8b5cf6', '寻号': '#06b6d4', '生肖钞': '#22c55e', '其他': '#64748b' },
profitLoss: { 'profit': '#22c55e', 'loss': '#ef4444' },
numberCategory: { '无2347': '#8b5cf6', '无347': '#ef4444', '无247': '#fbbf24', '无47': '#3b82f6', '带7号': '#06b6d4', '带4号': '#22c55e', '其他': '#64748b' },
version: {},
gradingCompany: {},
gradingScore: {},
specialMark: {}
profitLoss: { 'profit': '#22c55e', 'loss': '#ef4444' }
}
//
@ -132,14 +117,8 @@ export default function Stats() {
}
}
const colorPalette = ['#22c55e', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#f97316', '#14b8a6', '#a855f7']
const getColor = (type, value) => {
if (colors[type]?.[value]) return colors[type][value]
//
const key = String(value)
let hash = 0
for (let i = 0; i < key.length; i++) hash = key.charCodeAt(i) + ((hash << 5) - hash)
return colorPalette[Math.abs(hash) % colorPalette.length]
return colors[type]?.[value] || '#64748b'
}
const getLabel = (type, value) => {
@ -151,23 +130,11 @@ export default function Stats() {
return Number(val).toLocaleString('zh-CN')
}
const numberCategoryOrder = ['无2347', '无347', '无247', '无47', '带7号', '带4号', '其他']
const getSortedData = (data, type) => {
if (type === 'numberCategory') {
return [...data].sort((a, b) => {
const order = numberCategoryOrder.indexOf(a.numberCategory)
const order2 = numberCategoryOrder.indexOf(b.numberCategory)
return order - order2
})
}
return data
}
const DistributionCard = ({ title, data, type, valueKey, labelKey }) => (
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>{title}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}>
{getSortedData(data, type).map((item, index) => {
{data.slice(0, 6).map((item, index) => {
const value = item[valueKey]
const label = getLabel(type, item[labelKey] || value)
const color = getColor(type, value)
@ -192,17 +159,13 @@ export default function Stats() {
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.05)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flex: 1, overflow: 'hidden' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: color, flexShrink: 0 }} />
<div style={{ color: color, fontSize: '12px', fontWeight: '500', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
</div>
</div>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: 'bold', marginLeft: '8px', flexShrink: 0 }}>
{item.count}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: color }} />
<div style={{ color: '#fff', fontSize: '13px', fontWeight: '500', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
</div>
</div>
<div style={{ color: '#fff', fontSize: '16px', fontWeight: 'bold' }}>{item.count}</div>
</div>
)
})}
@ -238,25 +201,6 @@ export default function Stats() {
</div>
<div style={{ padding: '16px' }}>
{/* 总统计 */}
<div style={{ background: 'linear-gradient(135deg, rgba(251, 191, 36, 0.2) 0%, rgba(251, 191, 36, 0.05) 100%)', borderRadius: '12px', padding: '20px', marginBottom: '16px', border: '1px solid rgba(251, 191, 36, 0.3)' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '16px' }}>📊 总统计</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>总数量</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{stats.totalCount}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>已评级</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{gradedCount}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>未评级</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{ungradedCount}</div>
</div>
</div>
</div>
{/* 财务统计 */}
<div style={{ background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.05) 100%)', borderRadius: '12px', padding: '20px', marginBottom: '16px', border: '1px solid rgba(34, 197, 94, 0.3)' }}>
<div style={{ color: '#22c55e', fontSize: '14px', fontWeight: 'bold', marginBottom: '16px' }}>💰 财务统计</div>
@ -283,9 +227,28 @@ export default function Stats() {
{/* 盈亏统计移到顶部 */}
<DistributionCard title="💰 盈亏统计" data={stats.byProfitLoss} type="profitLoss" valueKey="type" labelKey="label" />
<DistributionCard title="📦 包装分布" data={stats.byPackaging} type="packaging" valueKey="packaging" />
<DistributionCard title="🔢 号码分类分布" data={stats.byNumberCategory} type="numberCategory" valueKey="numberCategory" />
{/* 总统计 */}
<div style={{ background: 'linear-gradient(135deg, rgba(251, 191, 36, 0.2) 0%, rgba(251, 191, 36, 0.05) 100%)', borderRadius: '12px', padding: '20px', marginBottom: '16px', border: '1px solid rgba(251, 191, 36, 0.3)' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '16px' }}>📊 总统计</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>总数量</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{stats.totalCount}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>已评级</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{gradedCount}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>未评级</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{ungradedCount}</div>
</div>
</div>
</div>
<DistributionCard title="📋 状态分布" data={stats.byStatus} type="status" valueKey="status" />
<DistributionCard title="💼 持仓类型分布" data={stats.byCategory} type="category" valueKey="category" />
<DistributionCard title="📦 包装分布" data={stats.byPackaging} type="packaging" valueKey="packaging" />
<DistributionCard title="⭐ 珍惜度分布" data={stats.byRarity} type="rarity" valueKey="rarity" />
<DistributionCard title="🏷️ 版别分布" data={stats.byVersion} type="version" valueKey="version" />
<DistributionCard title="🏅 评级机构分布" data={stats.byGradingCompany} type="gradingCompany" valueKey="company" />

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 文件读取版本号
function getVersion() {
try {
const versionFile = join(__dirname, 'config', 'VERSION')
const content = readFileSync(versionFile, 'utf-8').trim()
// 移除 VERSION= 前缀
if (content.startsWith('VERSION=')) {
return content.substring(7).trim()
}
return content || '0.0.0'
const versionFile = join(__dirname, '..', 'config', 'VERSION')
const content = readFileSync(versionFile, 'utf-8')
const match = content.match(/^VERSION=(.*)$/m)
return match ? match[1].trim() : '0.0.0'
} catch (e) {
console.error('读取 VERSION 文件失败:', e.message)
return '0.0.0'
@ -20,42 +17,42 @@ function getVersion() {
}
const APP_VERSION = getVersion()
console.log('📦 构建版本v' + APP_VERSION)
console.log(`📦 构建版本v${APP_VERSION}`)
// 构建后执行 - 更新 dist/index.html 的 title
// 构建时自动更新 index.html 的 title
function updateHtmlTitle() {
return {
name: 'update-html-title',
closeBundle() {
try {
const htmlPath = join(__dirname, 'dist', 'index.html')
let htmlContent = readFileSync(htmlPath, 'utf-8')
// 替换 <title>甲辰收藏 vXXX</title>
htmlContent = htmlContent.replace(
/<title>甲辰收藏 v[\d.]+<\/title>/,
'<title>甲辰收藏 v' + APP_VERSION + '</title>'
)
writeFileSync(htmlPath, htmlContent, 'utf-8')
console.log('✅ 已更新 dist/index.html title: 甲辰收藏 v' + APP_VERSION)
} catch (e) {
console.error('更新 dist/index.html 失败:', e.message)
}
}
try {
const htmlPath = join(__dirname, 'index.html')
let htmlContent = readFileSync(htmlPath, 'utf-8')
// 替换 <title>甲辰收藏 vXXX</title>
htmlContent = htmlContent.replace(
/<title>甲辰收藏 v[\d.]+<\/title>/,
`<title>甲辰收藏 v${APP_VERSION}</title>`
)
writeFileSync(htmlPath, htmlContent, 'utf-8')
console.log(`✅ 已更新 index.html title: 甲辰收藏 v${APP_VERSION}`)
} catch (e) {
console.error('更新 index.html 失败:', e.message)
}
}
// 构建前执行
updateHtmlTitle()
export default defineConfig({
plugins: [react(), updateHtmlTitle()],
plugins: [react()],
define: {
'import.meta.env.APP_VERSION': JSON.stringify(APP_VERSION)
},
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name]-[hash]-[name].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]'
entryFileNames: `assets/[name]-[hash]-[name].js`,
chunkFileNames: `assets/[name]-[hash].js`,
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 +0,0 @@
#!/bin/bash
cd /root/jiachenlong/backend
export DATABASE_URL='postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong'
export SECRET_KEY=production-secret-key-b-env-20260401
export OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
export OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
export SMS_ACCESS_KEY_ID=LTAI5t86bc1nNKVNyYv4Af6x
export SMS_ACCESS_KEY_SECRET=92EVAIE3GECr214c9UaSF6TSYJvDLY
export SMS_SIGN_NAME=苏州双人旁
export SMS_TEMPLATE_CODE=SMS_505015231
export DASHSCOPE_API_KEY=sk-9389024a37da4f7bb455ac9a6b28776f
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 --workers 1 > /tmp/uvicorn.log 2>&1 &
echo 'B后端服务已启动'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB