Compare commits

...

110 Commits

Author SHA1 Message Date
甲辰生产 a9a68de436 v1.2.98 - 同步所有文件版本号与VERSION.json一致 2026-04-19 21:58:28 +08:00
甲辰生产 36b467895c v1.2.98 - 完善版本管理体系:代码文件添加版本注释 2026-04-19 21:34:14 +08:00
甲辰生产 591db03ee1 v1.2.98 - 分离前后端版本文件,独立管理 2026-04-19 21:17:06 +08:00
甲辰生产 fb987dc5b9 v1.2.98 - 修复前端构建版本号不更新问题 2026-04-19 21:11:49 +08:00
甲辰生产 d578289c7c v1.2.98 - 更新版本号 2026-04-19 21:10:11 +08:00
甲辰生产 b0f28466c8 v1.2.98 - 添加前端版本文件 2026-04-19 21:09:50 +08:00
甲辰生产 57e51f6c28 v1.2.98 - 修复管理员用户列表行情数显示问题,改为显示实际发布的行情数量 2026-04-19 20:07:24 +08:00
甲辰生产 524601afc3 fix: 一尘看板按最新帖子发表时间排序 - 使用COALESCE(post_time, crawled_at) 2026-04-17 15:13:50 +08:00
甲辰生产 58c2291e83 fix: 修复统计页面无数据问题 - base_filter判断逻辑修正 2026-04-17 14:19:07 +08:00
甲辰生产 4ae29eb4bf v1.2.97 - 完整同步 2026-04-15 17:48:30 +08:00
甲辰生产 257ca40079 v1.2.97 - 修复行情录入接口、优化权限控制 2026-04-15 17:48:08 +08:00
甲辰生产 aec6ef7c79 feat: 表拆分seek_info和deal_info,优化显示和分页 2026-04-14 23:47:16 +08:00
甲辰生产 ea6528b7f3 v1.2.93 - 成交行情列表优化、行情编号格式调整 2026-04-14 16:56:29 +08:00
甲辰生产 e381427138 fix: 行情编号改为纯数字格式(如2404140001) 2026-04-14 16:34:39 +08:00
甲辰生产 0ffc10ad9a fix: 行情编号改为年份后两位+月日+4位序号(如DJ2404140001) 2026-04-14 16:19:56 +08:00
甲辰生产 c1f3b9bd3c fix: remove invalid category field check 2026-04-14 15:35:51 +08:00
甲辰生产 d2c4d5bf5f fix: handle Optional[User] in update_information 2026-04-14 15:31:02 +08:00
甲辰生产 38f1da12b5 fix: handle None f99_90_id in update_information 2026-04-14 15:28:32 +08:00
甲辰生产 2204e3a2af fix: InformationUpdate content field make optional 2026-04-14 15:24:10 +08:00
甲辰生产 e45899db0c v1.2.92 - 成交行情页面显示500条数据 2026-04-14 08:39:46 +08:00
甲辰生产 9fe3f77d45 v1.2.91 - 修复单条录入冠字号生成规则(7位→J0+0+数字,8位→J0+数字,9位→J0+后8位) 2026-04-14 08:02:45 +08:00
甲辰生产 2306e35dec v1.2.90 - 修复行情编辑保存、编号格式、默认值等问题 2026-04-13 09:08:19 +08:00
甲辰生产 5eb234443b 版本更新至v1.2.82 2026-04-11 22:39:38 +08:00
甲辰生产 6d7ba6e097 成交详情列表按价格排序 2026-04-11 22:32:38 +08:00
甲辰生产 161ff51b72 修改成交详情列表展示格式 2026-04-11 22:28:14 +08:00
甲辰生产 64eb4b921c 修复评级分数显示 2026-04-11 22:24:44 +08:00
甲辰生产 238383d3d8 统一分类排序和名称标准化 2026-04-11 22:21:22 +08:00
甲辰生产 2b1044ddb2 成交行情列表按分类排序 2026-04-11 22:18:07 +08:00
甲辰生产 e1bed251fa 删除统计卡片中的日期选择框 2026-04-11 22:12:50 +08:00
甲辰生产 9faa2d8069 修复日期过滤使用正确的date类型 2026-04-11 22:06:11 +08:00
甲辰生产 810a36d757 修复日期过滤 2026-04-11 22:00:02 +08:00
甲辰生产 4f6de4d3ac 成交行情按日期获取全部数据 2026-04-11 21:53:23 +08:00
甲辰生产 afa217e1b5 API支持deal_date参数过滤 2026-04-11 21:43:45 +08:00
甲辰生产 73de6a1f85 修改成交行情列表展示格式 2026-04-11 21:34:43 +08:00
甲辰生产 e8ac05decd 修复冠字号显示 2026-04-11 21:26:34 +08:00
甲辰生产 5484d12557 点击价格显示详情列表 2026-04-11 21:21:22 +08:00
甲辰生产 dced540eb8 修复分类名称映射 2026-04-11 21:11:19 +08:00
甲辰生产 926c12c787 修复包装分类匹配 2026-04-11 21:02:02 +08:00
甲辰生产 36527d033f 全局统计、按价格排序 2026-04-11 20:47:15 +08:00
甲辰生产 615be9805b 修复useState位置 2026-04-11 20:35:44 +08:00
甲辰生产 0093367452 修复日期选择器 2026-04-11 20:29:20 +08:00
甲辰生产 d5180d01b6 成交行情价格统计表格 2026-04-11 20:13:26 +08:00
甲辰生产 285929024b 成交行情添加统计区域 2026-04-11 19:50:41 +08:00
甲辰生产 55f0c9aa23 成交行情页面按维度展示 2026-04-11 19:47:22 +08:00
甲辰生产 39e5d44604 添加user_id参数支持我的行情权限过滤 2026-04-11 19:00:38 +08:00
甲辰生产 12c35e39db 恢复我的行情页面搜索框 2026-04-11 18:49:37 +08:00
甲辰生产 f038a397cc v1.2.84 - 行情录入完整版:号码分类名称修正、我的行情页面优化 2026-04-11 18:40:07 +08:00
甲辰生产 e2c0765623 fix: 修复后端号码分类名称(通货→带4号,钻石→钻石号,永恒→永恒号,无4→带7号) 2026-04-11 18:38:03 +08:00
甲辰生产 2b2b916f91 Merge commit '99d34f8' 2026-04-11 17:54:16 +08:00
甲辰生产 99d34f86c4 v1.2.83 - 我的行情页面搜索和排序功能修复 2026-04-11 17:54:09 +08:00
甲辰生产 74589883b6 v1.2.82 - 行情功能优化:批量录入增强、我的行情页面完善
- 批量录入增加默认日期、成交平台、默认包装类型选项
- 我的行情页面优化显示:冠字号金色、编号白色、大小号显示
- 编辑界面增加平台、出售者、购买者字段
- 导航栏优化:我的、行情
- 修复批量解析大小号计算问题
- 自动生成行情编号(日期+5位自然数)
2026-04-11 16:30:18 +08:00
甲辰生产 bd22c20108 v1.2.82 - 行情录入功能优化:添加号码分类、尾号、大小号、包装类型、出售者、购买者字段;提交时自动计算尾号和大小号 2026-04-11 01:47:55 +08:00
甲辰生产 acac7922af fix: 修复information.py缺少Response导入 2026-04-09 15:42:13 +08:00
甲辰生产 abc8c7bd08 v1.2.79 - 优化一尘看板 2026-04-09 15:36:14 +08:00
甲辰生产 550fc8f7da v1.2.79 - 邀请码注册功能 2026-04-09 08:29:40 +08:00
甲辰生产 b40f5c44a6 fix: 修复邀请码字段无法保存的bug 2026-04-09 08:22:27 +08:00
甲辰生产 448b3a1ba7 v1.2.77 - 添加邀请码注册功能(选填),用户模型增加邀请数量字段 2026-04-09 08:14:01 +08:00
甲辰生产 dacd023523 v1.2.78 - 支持用户编码登录 2026-04-09 00:45:19 +08:00
甲辰生产 0dee8a9d53 v1.2.77 - 用户编码从201开始 2026-04-09 00:41:21 +08:00
甲辰生产 7e19e2a55d v1.2.76 - 首页最新帖子+搜索分页修复 2026-04-08 23:29:15 +08:00
甲辰生产 6401fc57ea v1.2.75 - 搜索功能完善+分页修复 2026-04-08 23:12:30 +08:00
甲辰生产 49b68e802e v1.2.74 - 搜索返回总数+分页修复 2026-04-08 22:54:51 +08:00
甲辰生产 e5a6a5928e v1.2.73 - 全局搜索后端支持 2026-04-08 22:40:46 +08:00
甲辰生产 e5a5a2c48c v1.2.72 - 优化加载性能 2026-04-08 22:24:22 +08:00
甲辰生产 a4be63512c v1.2.72 - 修复一尘看板渲染逻辑问题 2026-04-08 22:17:55 +08:00
甲辰生产 36f617266d v1.2.70 2026-04-08 20:47:43 +08:00
甲辰生产 d6fd86bbfa 修复VERSION格式 2026-04-08 20:46:00 +08:00
甲辰生产 a46d63ab01 v1.2.70 - 修复编号并发锁 2026-04-08 20:45:24 +08:00
甲辰生产 022e81c800 v1.2.70 - 修复版本号统一+编号并发锁+Stats性能优化 2026-04-08 20:44:15 +08:00
甲辰生产 49795690af v1.2.69 - 修复版本号统一 2026-04-08 20:42:48 +08:00
甲辰生产 dd3e0d9fed 修复VERSION格式 2026-04-08 20:38:59 +08:00
甲辰生产 c96033167f v1.2.69 - 更新龙钞分类规则关键词 2026-04-08 20:14:35 +08:00
甲辰生产 e1ac548115 v1.2.63 - 统一版本号 2026-04-08 16:22:36 +08:00
甲辰生产 0a4d56f260 v1.2.63 - 首页龙钞数据模块+帖子跳转优化 2026-04-08 16:21:15 +08:00
甲辰生产 e158276101 v1.2.73 - 查看全部跳转一尘看板 2026-04-08 16:19:09 +08:00
甲辰生产 263890f43b v1.2.72 - 帖子点击跳转原帖 2026-04-08 16:18:24 +08:00
甲辰生产 8cfec31adf v1.2.71 - 添加合计列 2026-04-08 16:12:52 +08:00
甲辰生产 5ea8e9f4ab v1.2.70 - 龙钞数据改为单表格卡片布局 2026-04-08 16:09:32 +08:00
甲辰生产 041a5bc8bb v1.2.69 - 添加出售求购字样 2026-04-08 15:59:07 +08:00
甲辰生产 e635c36160 v1.2.68 - 美化龙钞数据UI 2026-04-08 15:56:39 +08:00
甲辰生产 bf2cc0848f v1.2.67 - 隐藏龙钞数据总数 2026-04-08 15:48:49 +08:00
甲辰生产 c9481ec385 v1.2.66 - 龙钞数据出售求购换行显示 2026-04-08 15:45:30 +08:00
甲辰生产 f7b5d6909b v1.2.65 - 优化龙钞数据展示:显示出售/求购数量 2026-04-08 15:37:55 +08:00
甲辰生产 90ce5a031f v1.2.64 - 修复龙钞统计数据API 2026-04-08 15:27:52 +08:00
甲辰生产 d207d6d329 v1.2.63 - 首页增加今日龙钞数据模块 2026-04-08 15:20:15 +08:00
甲辰生产 47e18948f1 fix: 添加images字段映射 2026-04-08 14:41:02 +08:00
甲辰生产 79ce79b92c fix: 恢复collections.py并添加用户认证检查 2026-04-08 14:39:10 +08:00
甲辰生产 9956ed3f61 fix: Stats返回值语法修复 2026-04-08 14:35:39 +08:00
甲辰生产 190df56118 fix: Stats返回值修复 2026-04-08 14:33:45 +08:00
甲辰生产 2a8dea6acf fix: Stats接口用户认证检查 2026-04-08 14:29:08 +08:00
甲辰生产 bb30f0e711 v1.2.62 - 更新版本号 2026-04-08 14:20:55 +08:00
甲辰生产 3cf8079b33 v1.2.62 - 性能优化:Stats数据库聚合+编号并发锁+images映射 2026-04-08 14:15:53 +08:00
甲辰生产 eb64ce3d8c v1.2.61 - 安全修复:CORS限制+SECRET_KEY强制环境变量 2026-04-08 13:35:18 +08:00
甲辰生产 309dd056c6 v1.2.60 - 用户功能开发基础稳定版 2026-04-08 13:28:18 +08:00
甲辰生产 893446ccd4 fix: 去掉寻配号数据箭头 2026-04-08 13:25:18 +08:00
甲辰生产 b8961a32e0 fix: 使用network_matched_count统计网络匹配数据 2026-04-08 13:22:29 +08:00
甲辰生产 e17024128f fix: 使用Information.id统计 2026-04-08 13:16:17 +08:00
甲辰生产 92bc6ab8d6 fix: 修正寻配号统计数据逻辑 2026-04-08 13:13:42 +08:00
甲辰生产 81ac7fd826 v1.2.54 - 寻配号数据增加我的匹配数 2026-04-08 13:07:55 +08:00
甲辰生产 b4b4a71640 fix: 寻配号数据调整到快捷操作下面 2026-04-08 13:05:07 +08:00
甲辰生产 a7dd51400c fix: 金额显示保留1位小数 2026-04-08 13:02:50 +08:00
甲辰生产 7a92c0d430 v1.2.53 - 首页优化:去掉图标+数字保留1位小数 2026-04-08 12:41:09 +08:00
甲辰生产 0bbaf87093 v1.2.52 - 首页优化:修改一尘标题+添加寻配号数据 2026-04-08 12:37:39 +08:00
甲辰生产 31cbe53c67 v1.2.51 - 修复用户信息返回缺少的字段 2026-04-08 12:30:38 +08:00
甲辰生产 723e5a2500 v1.2.51 - 修复用户信息返回缺少的字段 2026-04-08 11:22:32 +08:00
龙大 e8c3069bfd v1.2.50 阶段性完善版本:寻配号优化、网络数据匹配、分页功能、logo修复 2026-04-07 15:49:35 +08:00
龙大 693e13bbab v1.2.49 发布寻号优化版:支持输入D/E字符 2026-04-07 14:18:17 +08:00
龙大 195d779e79 v1.2.48 一尘看板阶段性稳定版:删除Info面板一尘看板tab 2026-04-07 14:11:35 +08:00
龙大 97cc727547 v1.2.47 修复logo 404问题:移除版本参数,修复static路径 2026-04-07 14:01:55 +08:00
龙大 3c3072159a v1.2.46 一尘数据首页优化版:首页布局调整、最新一尘发帖展示 2026-04-07 13:14:51 +08:00
50 changed files with 8831 additions and 2005 deletions

3
.gitignore vendored
View File

@ -32,6 +32,3 @@ Thumbs.db
.vscode/ .vscode/
*.swp *.swp
*.swo *.swo
# 版本文件
VERSION

View File

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

View File

@ -1 +1 @@
1.2.38 1.2.98

View File

@ -11,7 +11,9 @@ from app.core.database import SessionLocal
from app.models.models import User from app.models.models import User
# 配置 # 配置
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production") SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
raise ValueError("SECRET_KEY environment variable is not set. Please configure it in production!")
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7天 ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7天

View File

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

View File

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

@ -44,7 +44,8 @@ class User(Base):
f99_100_points = Column(Integer, default=0) # 积分 f99_100_points = Column(Integer, default=0) # 积分
f01_11_balance = Column(Float, default=0) # 余额 f01_11_balance = Column(Float, default=0) # 余额
f01_12_total_amount = Column(Float, default=0) # 累计金额 f01_12_total_amount = Column(Float, default=0) # 累计金额
f01_13_invite_code = Column(String(20), nullable=True) # 邀请码 f01_13_invite_code = Column(String(20), nullable=True) # 邀请码(自己的邀请码)
f99_101_invited_count = Column(Integer, default=0) # 通过自己邀请码注册的用户数量
collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan") collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan")
operations = relationship("Operation", back_populates="user", cascade="all, delete-orphan") operations = relationship("Operation", back_populates="user", cascade="all, delete-orphan")
@ -179,6 +180,16 @@ class Information(Base):
deal_price = Column(Float, nullable=True) deal_price = Column(Float, nullable=True)
deal_date = Column(Date, 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-已过期 # 状态: active-有效, closed-已关闭, expired-已过期
status = Column(String(20), default="active", index=True) status = Column(String(20), default="active", index=True)

View File

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

File diff suppressed because it is too large Load Diff

502
backend/app/routers/deal.py Normal file
View File

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

View File

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

View File

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

384
backend/app/routers/seek.py Normal file
View File

@ -0,0 +1,384 @@
# 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,4 +1,7 @@
# 用户管理路由 # users.py - 用户管理路由
# Version: 1.2.98 (2026-04-19)
# 更新:新增 dealCount 字段,从 Information 表统计用户发布的行情数量
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -11,7 +14,7 @@ router = APIRouter(prefix="/api", tags=["用户"])
# ============ 当前用户接口 ============ # ============ 当前用户接口 ============
@router.get("/users/me", response_model=UserResponse) @router.get("/users/me") # 无 response_model避免 Pydantic 序列化问题
def get_current_user_info( def get_current_user_info(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -27,11 +30,27 @@ def get_current_user_info(
"address": current_user.address, "address": current_user.address,
"bio": current_user.bio, "bio": current_user.bio,
"role": current_user.role, "role": current_user.role,
"level": current_user.f99_94_level,
"aiCount": current_user.f99_95_ai_count or 0,
"searchCount": current_user.f99_96_search_count or 0,
"collectionCount": current_user.f99_97_collection_count or 0,
"phoneVerified": current_user.f01_06_phone_verified or False,
"loginCount": current_user.f99_98_login_count or 0,
"lastLogin": current_user.f99_99_last_login.isoformat() if current_user.f99_99_last_login else None,
"gender": current_user.f01_07_gender,
"birthday": current_user.f01_08_birthday.isoformat() if current_user.f01_08_birthday else None,
"region": current_user.f01_09_region,
"realnameVerified": current_user.f01_10_realname_verified or False,
"points": current_user.f99_100_points or 0,
"balance": float(current_user.f01_11_balance) if current_user.f01_11_balance else 0,
"totalAmount": float(current_user.f01_12_total_amount) if current_user.f01_12_total_amount else 0,
"inviteCode": current_user.f01_13_invite_code,
"user_code": current_user.user_code,
"f99_92_created_at": current_user.f99_92_created_at.isoformat() if current_user.f99_92_created_at else None, "f99_92_created_at": current_user.f99_92_created_at.isoformat() if current_user.f99_92_created_at else None,
"f99_93_updated_at": current_user.f99_93_updated_at.isoformat() if current_user.f99_93_updated_at else None "f99_93_updated_at": current_user.f99_93_updated_at.isoformat() if current_user.f99_93_updated_at else None
} }
@router.put("/users/me", response_model=UserResponse) @router.put("/users/me") # 无 response_model避免 Pydantic 序列化问题
def update_current_user( def update_current_user(
user_update: UserUpdate, user_update: UserUpdate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@ -98,6 +117,11 @@ def get_users(
for u in users: for u in users:
# 统计每个用户的藏品数量 # 统计每个用户的藏品数量
count = db.query(Collection).filter(Collection.f99_91_user_id == u.f99_90_id).count() count = db.query(Collection).filter(Collection.f99_91_user_id == u.f99_90_id).count()
# 统计每个用户发布的行情数量info_type='deal'
deal_count = db.query(Information).filter(
Information.user_id == u.f99_90_id,
Information.info_type == "deal"
).count()
user_list.append({ user_list.append({
"id": u.f99_90_id, "id": u.f99_90_id,
"username": u.f01_01_name, "username": u.f01_01_name,
@ -107,6 +131,7 @@ def get_users(
"user_code": u.user_code, "user_code": u.user_code,
"created_at": u.f99_92_created_at.isoformat() if u.f99_92_created_at else None, "created_at": u.f99_92_created_at.isoformat() if u.f99_92_created_at else None,
"collectionCount": count, "collectionCount": count,
"dealCount": deal_count,
"level": u.f99_94_level, "level": u.f99_94_level,
"aiCount": u.f99_95_ai_count, "aiCount": u.f99_95_ai_count,
"searchCount": u.f99_96_search_count, "searchCount": u.f99_96_search_count,

View File

@ -1,252 +1,760 @@
# yichens - 一尘数据路由
# Version: 1.2.70
# 更新:
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
# 更新:
# Version: 1.2.x
# 更新:
from sqlalchemy import func, text from sqlalchemy import func, text
# 更新:
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
# 更新:
from pydantic import BaseModel from pydantic import BaseModel
# 更新:
from typing import Optional, List from typing import Optional, List
# 更新:
from datetime import datetime, date from datetime import datetime, date
# 更新:
from app.core.coolbot_db import get_coolbot_db from app.core.coolbot_db import get_coolbot_db
# 更新:
# 更新:
router = APIRouter(prefix="/api/yichens", tags=["一尘看板"]) router = APIRouter(prefix="/api/yichens", tags=["一尘看板"])
# 更新:
# 更新:
# ============ 数据模型 ============ # ============ 数据模型 ============
# 更新:
class YichensPostStats(BaseModel): class YichensPostStats(BaseModel):
# 更新:
total_posts: int total_posts: int
# 更新:
total_deals: int # 出售 total_deals: int # 出售
# 更新:
total_wants: int # 求购 total_wants: int # 求购
# 更新:
total_replies: int total_replies: int
# 更新:
total_views: int total_views: int
# 更新:
avg_price: Optional[float] avg_price: Optional[float]
# 更新:
# 更新:
class CategoryStat(BaseModel): class CategoryStat(BaseModel):
# 更新:
category: str category: str
# 更新:
count: int count: int
# 更新:
# 更新:
class PostItem(BaseModel): class PostItem(BaseModel):
# 更新:
post_id: str post_id: str
# 更新:
title: str title: str
# 更新:
category: Optional[str] category: Optional[str]
# 更新:
post_type: str post_type: str
# 更新:
price: Optional[float] price: Optional[float]
# 更新:
author_username: str author_username: str
# 更新:
post_time: str post_time: str
# 更新:
reply_count: int reply_count: int
# 更新:
view_count: int view_count: int
# 更新:
url: Optional[str] url: Optional[str]
# 更新:
content: Optional[str] content: Optional[str]
# 更新:
# 更新:
class UserStat(BaseModel): class UserStat(BaseModel):
# 更新:
total_users: int total_users: int
# 更新:
new_users_today: int new_users_today: int
# 更新:
sellers: int sellers: int
# 更新:
# 更新:
class UserItem(BaseModel): class UserItem(BaseModel):
# 更新:
user_id: str user_id: str
# 更新:
username: str username: str
# 更新:
avatar_url: Optional[str] avatar_url: Optional[str]
# 更新:
content: Optional[str] content: Optional[str]
# 更新:
credit_level: Optional[str] credit_level: Optional[str]
# 更新:
credit_score: Optional[int] credit_score: Optional[int]
# 更新:
post_count: int post_count: int
# 更新:
is_seller: bool is_seller: bool
# 更新:
registration_date: Optional[str] registration_date: Optional[str]
# 更新:
# 更新:
# ============ 统计接口 ============ # ============ 统计接口 ============
# 更新:
@router.get("/stats/posts", response_model=YichensPostStats) @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)): def get_post_stats(days: int = Query(30, ge=1, le=365), db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取帖子统计""" """获取帖子统计"""
# 更新:
result = db.execute(text(""" result = db.execute(text("""
# 更新:
SELECT SELECT
# 更新:
COUNT(*) as total_posts, COUNT(*) as total_posts,
# 更新:
COUNT(*) FILTER (WHERE post_type = 'deal') as total_deals, COUNT(*) FILTER (WHERE post_type = 'deal') as total_deals,
# 更新:
COUNT(*) FILTER (WHERE post_type = 'want') as total_wants, COUNT(*) FILTER (WHERE post_type = 'want') as total_wants,
# 更新:
COALESCE(SUM(reply_count), 0) as total_replies, COALESCE(SUM(reply_count), 0) as total_replies,
# 更新:
COALESCE(SUM(view_count), 0) as total_views, COALESCE(SUM(view_count), 0) as total_views,
# 更新:
AVG(price) as avg_price AVG(price) as avg_price
# 更新:
FROM yichens_posts FROM yichens_posts
# 更新:
WHERE post_time >= NOW() - INTERVAL '1 day' * :days WHERE post_time >= NOW() - INTERVAL '1 day' * :days
# 更新:
"""), {"days": days}).fetchone() """), {"days": days}).fetchone()
# 更新:
# 更新:
return YichensPostStats( return YichensPostStats(
# 更新:
total_posts=result[0] or 0, total_posts=result[0] or 0,
# 更新:
total_deals=result[1] or 0, total_deals=result[1] or 0,
# 更新:
total_wants=result[2] or 0, total_wants=result[2] or 0,
# 更新:
total_replies=result[3] or 0, total_replies=result[3] or 0,
# 更新:
total_views=result[4] or 0, total_views=result[4] or 0,
# 更新:
avg_price=float(result[5]) if result[5] else None avg_price=float(result[5]) if result[5] else None
# 更新:
) )
# 更新:
# 更新:
@router.get("/stats/categories", response_model=List[CategoryStat]) @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)): def get_category_stats(days: int = Query(30, ge=1, le=365), db: Session = Depends(get_coolbot_db)):
# 更新:
"""按分类统计帖子数量""" """按分类统计帖子数量"""
# 更新:
results = db.execute(text(""" results = db.execute(text("""
# 更新:
SELECT category, COUNT(*) as count SELECT category, COUNT(*) as count
# 更新:
FROM yichens_posts FROM yichens_posts
# 更新:
WHERE post_time >= NOW() - INTERVAL '1 day' * :days WHERE post_time >= NOW() - INTERVAL '1 day' * :days
# 更新:
GROUP BY category GROUP BY category
# 更新:
ORDER BY count DESC ORDER BY count DESC
# 更新:
"""), {"days": days}).fetchall() """), {"days": days}).fetchall()
# 更新:
# 更新:
return [CategoryStat(category=r[0] or "未知", count=r[1]) for r in results] return [CategoryStat(category=r[0] or "未知", count=r[1]) for r in results]
# 更新:
# 更新:
@router.get("/stats/users", response_model=UserStat) @router.get("/stats/users", response_model=UserStat)
# 更新:
def get_user_stats(db: Session = Depends(get_coolbot_db)): def get_user_stats(db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取用户统计""" """获取用户统计"""
# 更新:
result = db.execute(text(""" result = db.execute(text("""
# 更新:
SELECT SELECT
# 更新:
COUNT(*) as total_users, COUNT(*) as total_users,
# 更新:
COUNT(*) FILTER (WHERE created_at::date = CURRENT_DATE) as new_users_today, COUNT(*) FILTER (WHERE created_at::date = CURRENT_DATE) as new_users_today,
# 更新:
COUNT(*) FILTER (WHERE is_seller = true) as sellers COUNT(*) FILTER (WHERE is_seller = true) as sellers
# 更新:
FROM yichens_users FROM yichens_users
# 更新:
""")).fetchone() """)).fetchone()
# 更新:
# 更新:
return UserStat( return UserStat(
# 更新:
total_users=result[0] or 0, total_users=result[0] or 0,
# 更新:
new_users_today=result[1] or 0, new_users_today=result[1] or 0,
# 更新:
sellers=result[2] or 0 sellers=result[2] or 0
# 更新:
) )
# 更新:
@router.get("/posts", response_model=List[PostItem]) # 更新:
@router.get("/posts")
# 更新:
def get_posts( def get_posts(
# 更新:
limit: int = Query(20, ge=1, le=500), limit: int = Query(20, ge=1, le=500),
# 更新:
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
# 更新:
category: Optional[str] = None, category: Optional[str] = None,
# 更新:
post_type: Optional[str] = None, post_type: Optional[str] = None,
# 更新:
keyword: Optional[str] = None,
# 更新:
db: Session = Depends(get_coolbot_db) db: Session = Depends(get_coolbot_db)
# 更新:
): ):
"""获取帖子列表""" # 更新:
query = """ """获取帖子列表 - 支持全局搜索,返回总数和分页信息"""
SELECT post_id, title, content, category, post_type, price, # 更新:
author_username, post_time, reply_count, view_count, url # 构建WHERE条件
FROM yichens_posts # 更新:
WHERE 1=1 where_clauses = ["1=1"]
""" # 更新:
params = {"limit": limit, "offset": offset} params = {"limit": limit, "offset": offset}
# 更新:
# 更新:
if category: if category:
query += " AND category = :category" # 更新:
where_clauses.append("category = :category")
# 更新:
params["category"] = category params["category"] = category
# 更新:
# 更新:
if post_type: if post_type:
query += " AND post_type = :post_type" # 更新:
where_clauses.append("post_type = :post_type")
# 更新:
params["post_type"] = post_type params["post_type"] = post_type
# 更新:
query += " ORDER BY post_time DESC LIMIT :limit OFFSET :offset" # 更新:
# 全局搜索
# 更新:
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}%"
# 更新:
results = db.execute(text(query), params).fetchall() # 更新:
where_sql = " AND ".join(where_clauses)
# 更新:
return [PostItem( # 更新:
# 查询总数
# 更新:
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], post_id=r[0],
# 更新:
title=r[1] or "", title=r[1] or "",
# 更新:
content=r[2] or "", content=r[2] or "",
# 更新:
category=r[3], category=r[3],
# 更新:
post_type=r[4] or "", post_type=r[4] or "",
# 更新:
price=float(r[5]) if r[5] else None, price=float(r[5]) if r[5] else None,
# 更新:
author_username=r[6] or "", author_username=r[6] or "",
# 更新:
post_time=str(r[7]) if r[7] else "", post_time=str(r[7]) if r[7] else "",
# 更新:
reply_count=r[8] or 0, reply_count=r[8] or 0,
# 更新:
view_count=r[9] or 0, view_count=r[9] or 0,
# 更新:
url=r[10] url=r[10]
# 更新:
) for r in results] ) for r in results]
# 更新:
# 更新:
return {
# 更新:
"posts": posts,
# 更新:
"total": total_count,
# 更新:
"page": offset // limit + 1,
# 更新:
"page_size": limit
# 更新:
}
# 更新:
# 更新:
@router.get("/users", response_model=List[UserItem]) @router.get("/users", response_model=List[UserItem])
# 更新:
def get_users( def get_users(
# 更新:
limit: int = Query(20, ge=1, le=500), limit: int = Query(20, ge=1, le=500),
# 更新:
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
# 更新:
is_seller: Optional[bool] = None, is_seller: Optional[bool] = None,
# 更新:
db: Session = Depends(get_coolbot_db) db: Session = Depends(get_coolbot_db)
# 更新:
): ):
# 更新:
"""获取用户列表""" """获取用户列表"""
# 更新:
query = """ query = """
# 更新:
SELECT user_id, username, avatar_url, credit_level, credit_score, SELECT user_id, username, avatar_url, credit_level, credit_score,
# 更新:
post_count, is_seller, registration_date post_count, is_seller, registration_date
# 更新:
FROM yichens_users FROM yichens_users
# 更新:
WHERE 1=1 WHERE 1=1
# 更新:
""" """
# 更新:
params = {"limit": limit, "offset": offset} params = {"limit": limit, "offset": offset}
# 更新:
# 更新:
if is_seller is not None: if is_seller is not None:
# 更新:
query += " AND is_seller = :is_seller" query += " AND is_seller = :is_seller"
# 更新:
params["is_seller"] = is_seller params["is_seller"] = is_seller
# 更新:
# 更新:
query += " ORDER BY post_count DESC LIMIT :limit OFFSET :offset" query += " ORDER BY post_count DESC LIMIT :limit OFFSET :offset"
# 更新:
# 更新:
results = db.execute(text(query), params).fetchall() results = db.execute(text(query), params).fetchall()
# 更新:
# 更新:
return [UserItem( return [UserItem(
# 更新:
user_id=r[0], user_id=r[0],
# 更新:
username=r[1] or "", username=r[1] or "",
# 更新:
avatar_url=r[2], avatar_url=r[2],
# 更新:
credit_level=r[3], credit_level=r[3],
# 更新:
credit_score=r[4], credit_score=r[4],
# 更新:
post_count=r[5] or 0, post_count=r[5] or 0,
# 更新:
is_seller=r[6] or False, is_seller=r[6] or False,
# 更新:
registration_date=str(r[7]) if r[7] else None registration_date=str(r[7]) if r[7] else None
# 更新:
) for r in results] ) for r in results]
# 更新:
# 更新:
# 更新:
@router.get("/stats/today") @router.get("/stats/today")
# 更新:
async def get_today_stats(db: Session = Depends(get_coolbot_db)): async def get_today_stats(db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取今日新增帖子统计""" """获取今日新增帖子统计"""
# 更新:
query = """ query = """
# 更新:
SELECT SELECT
# 更新:
COUNT(*) as total, COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals, 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 = 'want' THEN 1 ELSE 0 END) as wants,
# 更新:
SUM(CASE WHEN post_type = 'normal' THEN 1 ELSE 0 END) as others, 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 dragons,
# 更新:
SUM(CASE WHEN category LIKE '%%' THEN 1 ELSE 0 END) as horses, 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 category LIKE '%%' THEN 1 ELSE 0 END) as snakes,
# 更新:
SUM(CASE WHEN title LIKE '%天马%' OR content LIKE '%天马%' THEN 1 ELSE 0 END) as tianma SUM(CASE WHEN title LIKE '%天马%' OR content LIKE '%天马%' THEN 1 ELSE 0 END) as tianma
# 更新:
FROM yichens_posts FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE WHERE post_time >= CURRENT_DATE
# 更新:
""" """
# 更新:
result = db.execute(text(query)).fetchone() result = db.execute(text(query)).fetchone()
# 更新:
return { return {
# 更新:
"total": result[0] or 0, "total": result[0] or 0,
# 更新:
"deals": result[1] or 0, "deals": result[1] or 0,
# 更新:
"wants": result[2] or 0, "wants": result[2] or 0,
# 更新:
"others": result[3] or 0, "others": result[3] or 0,
# 更新:
"dragons": result[4] or 0, "dragons": result[4] or 0,
# 更新:
"horses": result[5] or 0, "horses": result[5] or 0,
# 更新:
"snakes": result[6] or 0, "snakes": result[6] or 0,
# 更新:
"tianma": result[7] or 0 "tianma": result[7] or 0
# 更新:
} }
# 更新:
# 更新:
@router.get("/stats/hour") @router.get("/stats/hour")
# 更新:
async def get_hour_stats(db: Session = Depends(get_coolbot_db)): async def get_hour_stats(db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取近一个小时新增帖子统计""" """获取近一个小时新增帖子统计"""
# 更新:
query = """ query = """
# 更新:
SELECT SELECT
# 更新:
COUNT(*) as total, COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals, 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 = 'want' THEN 1 ELSE 0 END) as wants,
# 更新:
SUM(CASE WHEN post_type = 'normal' THEN 1 ELSE 0 END) as others, 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 dragons,
# 更新:
SUM(CASE WHEN category LIKE '%%' THEN 1 ELSE 0 END) as horses, 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 category LIKE '%%' THEN 1 ELSE 0 END) as snakes
# 更新:
FROM yichens_posts FROM yichens_posts
# 更新:
WHERE post_time >= NOW() - INTERVAL '1 hour' WHERE post_time >= NOW() - INTERVAL '1 hour'
# 更新:
""" """
# 更新:
result = db.execute(text(query)).fetchone() result = db.execute(text(query)).fetchone()
# 更新:
return { return {
# 更新:
"total": result[0] or 0, "total": result[0] or 0,
# 更新:
"deals": result[1] or 0, "deals": result[1] or 0,
# 更新:
"wants": result[2] or 0, "wants": result[2] or 0,
# 更新:
"others": result[3] or 0, "others": result[3] or 0,
# 更新:
"dragons": result[3] or 0, "dragons": result[3] or 0,
# 更新:
"horses": result[4] or 0, "horses": result[4] or 0,
# 更新:
"snakes": result[5] or 0 "snakes": result[5] or 0
# 更新:
} }
# 更新:
# 更新:
@router.get("/stats/today-category") @router.get("/stats/today-category")
# 更新:
async def get_today_category_stats(db: Session = Depends(get_coolbot_db)): async def get_today_category_stats(db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取今日帖子分类统计""" """获取今日帖子分类统计"""
# 更新:
query = """ query = """
# 更新:
SELECT category, COUNT(*) as count SELECT category, COUNT(*) as count
# 更新:
FROM yichens_posts FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE WHERE post_time >= CURRENT_DATE
# 更新:
GROUP BY category GROUP BY category
# 更新:
ORDER BY count DESC ORDER BY count DESC
# 更新:
""" """
# 更新:
results = db.execute(text(query)).fetchall() results = db.execute(text(query)).fetchall()
# 更新:
return [{"category": r[0] or "未分类", "count": r[1]} for r in results] 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,6 +19,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase): class UserCreate(UserBase):
password: str = Field(..., min_length=6) password: str = Field(..., min_length=6)
invite_code: Optional[str] = Field(None, alias="inviteCode") # 填写的邀请码(选填)
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
@ -50,6 +51,7 @@ class UserResponse(UserBase):
f01_11_balance: Optional[float] = Field(0, alias="balance") f01_11_balance: Optional[float] = Field(0, alias="balance")
f01_12_total_amount: Optional[float] = Field(0, alias="totalAmount") f01_12_total_amount: Optional[float] = Field(0, alias="totalAmount")
f01_13_invite_code: Optional[str] = Field(None, alias="inviteCode") f01_13_invite_code: Optional[str] = Field(None, alias="inviteCode")
f99_101_invited_count: Optional[int] = Field(0, alias="invitedCount")
f99_90_id: str = Field(..., alias="id") f99_90_id: str = Field(..., alias="id")
f01_01_name: str = Field(..., alias="username") f01_01_name: str = Field(..., alias="username")
role: str role: str

View File

@ -0,0 +1,132 @@
# 号码分类工具
# 按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

35
config/CHANGELOG.md Normal file
View File

@ -0,0 +1,35 @@
# 版本更新记录
## 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 @@
VERSION=1.2.45 1.2.98

48
config/VERSION.json Normal file
View File

@ -0,0 +1,48 @@
{
"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"
}
}
}
}

8
env.conf Normal file
View File

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

1
frontend/VERSION Normal file
View File

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

1
frontend/config/VERSION Normal file
View File

@ -0,0 +1 @@
1.2.98

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<title>甲辰收藏 v1.2.45</title> <title>甲辰收藏 v=1.2.97</title>
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="/static/icons/favicon.png" /> <link rel="icon" type="image/png" href="/static/icons/favicon.png" />

View File

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

View File

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

1
frontend/package.txt Normal file
View File

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

View File

@ -5,7 +5,6 @@ import List from './pages/List'
import Add from './pages/Add' import Add from './pages/Add'
import Stats from './pages/Stats' import Stats from './pages/Stats'
import News from './pages/News' import News from './pages/News'
import Info from './pages/Info'
import Login from './pages/Login' import Login from './pages/Login'
import Detail from './pages/Detail' import Detail from './pages/Detail'
import Edit from './pages/Edit' import Edit from './pages/Edit'
@ -31,7 +30,6 @@ export default function App() {
const basePath = path.split('?')[0] const basePath = path.split('?')[0]
if (basePath === '/') return <Home /> if (basePath === '/') return <Home />
if (basePath === '/news') return <News /> if (basePath === '/news') return <News />
if (basePath === '/info') return <Info />
if (basePath === '/stats') return <Stats /> if (basePath === '/stats') return <Stats />
if (basePath === '/list') return <List /> if (basePath === '/list') return <List />
if (basePath === '/add') return <Add /> if (basePath === '/add') return <Add />
@ -53,7 +51,6 @@ export default function App() {
} }
const isAdmin = user && user.role === 'admin' const isAdmin = user && user.role === 'admin'
const isEditor = user && user.role === 'editor' const isEditor = user && user.role === 'editor'
const canAccessInfo = isAdmin || isEditor
// //
if (!token) { if (!token) {
@ -88,11 +85,10 @@ export default function App() {
}}> }}>
{[ {[
{ path: '/', icon: '🏠', label: '首页' }, { path: '/', icon: '🏠', label: '首页' },
{ path: '/news', icon: '📰', label: '资讯' }, { path: '/news', icon: '📰', label: '行情' },
{ path: '/stats', icon: '📊', label: '统计' }, { path: '/stats', icon: '📊', label: '统计' },
{ path: '/list', icon: '📚', label: '藏品' }, { path: '/list', icon: '📚', label: '我的' },
{ path: '/add', icon: '🎯', label: '添加' }, { path: '/add', icon: '🎯', label: '录入' },
...(canAccessInfo ? [{ path: '/info', icon: '📝', label: '信息' }] : []),
...(isAdmin ? [{ path: '/admin', icon: '⚙️', label: '管理' }] : []) ...(isAdmin ? [{ path: '/admin', icon: '⚙️', label: '管理' }] : [])
].map(tab => ( ].map(tab => (
<div <div
@ -116,3 +112,4 @@ export default function App() {
</div> </div>
) )
} }
// Fri Apr 10 03:44:24 PM CST 2026

View File

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

View File

@ -1,7 +1,13 @@
// - AI // /**
* Add - 添加藏品页面
* Version: 1.2.90x
* 更新
*/
import React, { useState, useRef } from 'react' import React, { useState, useRef } from 'react'
import { APP_VERSION } from '../config/version' import { APP_VERSION } from '../config/version'
const numberCategoryOptions = [{value:'无2347',label:'无2347'},{value:'无347',label:'无347'},{value:'无247',label:'无247'},{value:'无47',label:'无47'},{value:'无4',label:'无4'},{value:'带4',label:'带4'},{value:'其他',label:'其他'}]; const 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) => { const convertField = (obj) => {
@ -65,18 +71,63 @@ const getDefaultForm = () => ({
name: '藏品', code: '', category: '自持', rarity: '通货', prefixSerial: '', name: '藏品', code: '', category: '自持', rarity: '通货', prefixSerial: '',
version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张', version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张',
material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false, material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false,
gradingCompany: '', gradingScore: '', threeStar: false, specialMark: '', gradingCompany: '爱藏', gradingScore: '67+', threeStar: false, specialMark: '',
serialFeature: '', numberCategory: '', issuer: '中国人民银行', issueYear: '2024', serialFeature: '', numberCategory: '', issuer: '中国人民银行', issueYear: '2024',
costPrice: '', targetPrice: '', goalPrice: '', repairFee: '', gradingFee: '', remark: '' costPrice: '', targetPrice: '', goalPrice: '', repairFee: '', gradingFee: '', remark: ''
}) })
export default function Add() { export default function Add() {
//
const [dealForm, setDealForm] = useState({
serial: '',
category: '',
packaging: '标十',
price: '',
platform: '抖音',
seller: '',
buyer: '',
date: new Date().toISOString().split('T')[0],
isGraded: false,
gradingCompany: '爱藏',
gradingScore: '67+'
})
const [batchDefaultPackaging, setBatchDefaultPackaging] = useState('')
const [batchDefaultDate, setBatchDefaultDate] = useState('')
const [batchDefaultPlatform, setBatchDefaultPlatform] = useState('抖音')
const [dealMode, setDealMode] = useState('single') // single-, batch-
const [batchText, setBatchText] = useState('')
const [batchResult, setBatchResult] = useState([])
const [parsing, setParsing] = useState(false)
const [savingDeal, setSavingDeal] = useState(false)
//
const autoCategory = (serial) => {
const digits = serial.replace('J', '').replace(/[^0-9]/g, '').slice(0, 9)
if (!digits) return ''
const d = digits
if (!d.includes('1') && !d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '圆圆号'
if (!d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '倒置号'
if (!d.includes('1') && !d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '金马王'
if (!d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '金马号'
if (!d.includes('1') && !d.includes('2') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '金山王'
if (!d.includes('1') && !d.includes('2') && !d.includes('4') && !d.includes('7')) return '天马王'
if (!d.includes('2') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '金山号'
if (!d.includes('2') && !d.includes('4') && !d.includes('7')) return '天马号'
if (!d.includes('1') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '朦胧王'
if (!d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '朦胧号'
if (!d.includes('1') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '如意号'
if (!d.includes('3') && !d.includes('4') && !d.includes('7')) return '钻石号'
if (!d.includes('4') && !d.includes('7')) return '永恒号'
if (!d.includes('4')) return '带7号'
return '带4号'
}
// URL // URL
const params = new URLSearchParams(window.location.hash.split('?')[1] || '') const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const modeParam = params.get('mode') const modeParam = params.get('mode')
const defaultTab = modeParam === 'manual' ? 'manual' : 'ai' const defaultTab = modeParam === 'manual' ? 'manual' : 'ai'
const [activeTab, setActiveTab] = useState(defaultTab) // ai, manual, batch const [activeTab, setActiveTab] = useState(defaultTab) // ai, manual, deal
const [form, setForm] = useState(getDefaultForm()) const [form, setForm] = useState(getDefaultForm())
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@ -158,8 +209,8 @@ export default function Add() {
else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347' else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347'
else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247' else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247'
else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47' else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47'
else if (digits.includes('7') && !digits.includes('4')) cat = '无4' else if (digits.includes('7') && !digits.includes('4')) cat = '带7号'
else if (digits.includes('4')) cat = '带4' else if (digits.includes('4')) cat = '带4'
else cat = '其他' else cat = '其他'
} }
setForm(prev => ({ ...prev, numberCategory: cat })) setForm(prev => ({ ...prev, numberCategory: cat }))
@ -274,8 +325,8 @@ export default function Add() {
else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347' else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347'
else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247' else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247'
else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47' else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47'
else if (digits.includes('7') && !digits.includes('4')) cat = '无4' else if (digits.includes('7') && !digits.includes('4')) cat = '带7号'
else if (digits.includes('4')) cat = '带4' else if (digits.includes('4')) cat = '带4'
else cat = '其他' else cat = '其他'
formWithCategory.numberCategory = cat formWithCategory.numberCategory = cat
} }
@ -405,15 +456,15 @@ export default function Add() {
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => setActiveTab('ai')} <button onClick={() => setActiveTab('ai')}
style={{ flex: 1, padding: '10px', background: activeTab === 'ai' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'ai' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}> style={{ flex: 1, padding: '10px', background: activeTab === 'ai' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'ai' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
🤖 AI 识别 AI 识别
</button> </button>
<button onClick={() => setActiveTab('manual')} <button onClick={() => setActiveTab('manual')}
style={{ flex: 1, padding: '10px', background: activeTab === 'manual' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'manual' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}> style={{ flex: 1, padding: '10px', background: activeTab === 'manual' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'manual' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
手工录入 手工录入
</button> </button>
<button onClick={() => setActiveTab('batch')} disabled <button onClick={() => setActiveTab('deal')}
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' }}> 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> </button>
</div> </div>
</div> </div>
@ -435,7 +486,7 @@ export default function Add() {
style={{ padding: '12px 24px', background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', border: '1px solid #ef4444', borderRadius: '8px', fontSize: '14px', cursor: 'pointer' }}>🗑 重新选择</button> style={{ padding: '12px 24px', background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', border: '1px solid #ef4444', borderRadius: '8px', fontSize: '14px', cursor: 'pointer' }}>🗑 重新选择</button>
<button onClick={handleRecognize} disabled={recognizing} <button onClick={handleRecognize} disabled={recognizing}
style={{ padding: '12px 24px', background: recognizing ? '#64748b' : '#fbbf24', color: recognizing ? '#94a3b8' : '#1e293b', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: recognizing ? 'not-allowed' : 'pointer' }}> style={{ padding: '12px 24px', background: recognizing ? '#64748b' : '#fbbf24', color: recognizing ? '#94a3b8' : '#1e293b', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: recognizing ? 'not-allowed' : 'pointer' }}>
{recognizing ? '🔍 识别中...' : '🤖 开始识别'} {recognizing ? '🔍 识别中...' : '开始识别'}
</button> </button>
</div> </div>
</div> </div>
@ -657,16 +708,380 @@ export default function Add() {
</div> </div>
)} )}
{/* 批量录入模式 */}
{activeTab === 'batch' && (
<div style={{ padding: '48px 16px', textAlign: 'center', color: '#94a3b8' }}> {/* 版本号 */}
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🚧</div>
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '8px' }}>批量录入开发中</div> {/* 行情录入模式 */}
<div style={{ fontSize: '13px' }}>敬请期待后续版本</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>
</div> </div>
)} )}
{/* 版本号 */}
<div style={{ position: 'fixed', bottom: '16px', right: '16px', color: 'rgba(255,255,255,0.2)', fontSize: '11px', zIndex: 100 }}>v{APP_VERSION}</div> <div style={{ position: 'fixed', bottom: '16px', right: '16px', color: 'rgba(255,255,255,0.2)', fontSize: '11px', zIndex: 100 }}>v{APP_VERSION}</div>
</div> </div>
) )

View File

@ -1,3 +1,9 @@
/**
* Admin - 管理员用户管理页面
* Version: 1.2.98 (2026-04-19)
* 更新新增 dealCount 字段显示用户发布的行情数量
*/
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version' import { APP_VERSION } from '../config/version'
@ -126,6 +132,8 @@ export default function Admin() {
const updateData = { const updateData = {
username: editingUser.username, username: editingUser.username,
email: editingUser.email, email: editingUser.email,
phone: editingUser.phone,
phoneVerified: editingUser.phoneVerified,
role: editingUser.role, role: editingUser.role,
user_code: editingUser.user_code, user_code: editingUser.user_code,
level: editingUser.level, level: editingUser.level,
@ -232,7 +240,7 @@ export default function Admin() {
🤖 AI识别: {user.aiCount || 0} 🤖 AI识别: {user.aiCount || 0}
</div> </div>
<div style={{ background: 'rgba(20, 184, 166, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#14b8a6' }}> <div style={{ background: 'rgba(20, 184, 166, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#14b8a6' }}>
🎯 配号: {user.searchCount || 0} 📈 行情: {user.dealCount || 0}
</div> </div>
<div style={{ background: 'rgba(245, 158, 11, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#f59e0b' }}> <div style={{ background: 'rgba(245, 158, 11, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#f59e0b' }}>
🔍 寻号: {user.searchCount || 0} 🔍 寻号: {user.searchCount || 0}
@ -253,7 +261,7 @@ export default function Admin() {
</div> </div>
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<button <button
onClick={() => setEditingUser({ ...user })} onClick={() => setEditingUser({ ...user, email: user.email || '', phone: user.phone || '', phoneVerified: user.phoneVerified !== false, newPassword: '', confirmPassword: '' })}
style={{ style={{
padding: '6px 12px', padding: '6px 12px',
borderRadius: '6px', borderRadius: '6px',
@ -391,6 +399,28 @@ export default function Admin() {
/> />
</div> </div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>手机号</label>
<input
type="text"
value={editingUser.phone || ''}
onChange={(e) => setEditingUser({ ...editingUser, phone: e.target.value })}
style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', color: '#94a3b8', fontSize: '13px', marginBottom: '6px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editingUser.phoneVerified === true}
onChange={(e) => setEditingUser({ ...editingUser, phoneVerified: e.target.checked })}
style={{ marginRight: '8px' }}
/>
手机号已验证
</label>
</div>
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>角色</label> <label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>角色</label>
<select <select
@ -458,7 +488,10 @@ export default function Admin() {
<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="带7号">带7号</option>
<option value="带4号">带4号</option>
</select> </select>
</div> </div>
@ -534,18 +567,6 @@ export default function Admin() {
/> />
</div> </div>
</div> </div>
<div style={{ marginTop: '12px' }}>
<label style={{ display: 'flex', alignItems: 'center', color: '#94a3b8', fontSize: '13px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editingUser.phoneVerified || false}
onChange={(e) => setEditingUser({ ...editingUser, phoneVerified: e.target.checked })}
style={{ marginRight: '8px' }}
/>
手机号已认证
</label>
</div>
</div> </div>
<div style={{ display: 'flex', gap: '12px' }}> <div style={{ display: 'flex', gap: '12px' }}>

View File

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

View File

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

View File

@ -1,3 +1,9 @@
/**
* Home - 首页
* Version: 1.2.95x
* 更新
*/
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version' import { APP_VERSION } from '../config/version'
@ -6,6 +12,9 @@ export default function Home() {
const [stats, setStats] = useState({ totalCount: 0, totalCost: 0, totalRevenue: 0, expectedProfit: 0, totalProfit: 0, gradedCount: 0 }) const [stats, setStats] = useState({ totalCount: 0, totalCost: 0, totalRevenue: 0, expectedProfit: 0, totalProfit: 0, gradedCount: 0 })
const [recentCollections, setRecentCollections] = useState([]) const [recentCollections, setRecentCollections] = useState([])
const [yichensStats, setYichensStats] = useState({ total: 0, deals: 0, wants: 0, dragons: 0, horses: 0, tianma: 0 }) const [yichensStats, setYichensStats] = useState({ total: 0, deals: 0, wants: 0, dragons: 0, horses: 0, tianma: 0 })
const [seekStats, setSeekStats] = useState({ seekCount: 0, matchedCount: 0 })
const [recentPosts, setRecentPosts] = useState([])
const [dragonStats, setDragonStats] = useState({})
const currentPath = window.location.hash.slice(1) || '/' const currentPath = window.location.hash.slice(1) || '/'
useEffect(() => { useEffect(() => {
@ -57,9 +66,27 @@ export default function Home() {
// //
useEffect(() => { 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 => { fetch('/api/yichens/stats/today').then(res => res.json()).then(data => {
setYichensStats(data || {}) setYichensStats(data || {})
}).catch(() => {}) }).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(() => {})
}, []) }, [])
// //
@ -75,16 +102,16 @@ export default function Home() {
const formatMoney = (val) => { const formatMoney = (val) => {
if (!val || val === 0) return '0' if (!val || val === 0) return '0'
const v = val / 10000 const v = val / 10000
return v.toFixed(4) return v.toFixed(1)
} }
const statCards = [ const statCards = [
{ label: '藏品数', value: stats.totalCount, color: '#3b82f6', icon: '📦' }, { label: '藏品数', value: stats.totalCount, color: '#3b82f6' },
{ label: '总成本', value: formatMoney(stats.totalCost) + '万', color: '#22c55e', icon: '💰' }, { label: '总成本', value: formatMoney(stats.totalCost) + '万', color: '#22c55e' },
{ label: '总收入', value: formatMoney(stats.totalRevenue) + '万', color: '#06b6d4', icon: '📈' }, { label: '总收入', value: formatMoney(stats.totalRevenue) + '万', color: '#06b6d4' },
{ label: '评级数', value: stats.gradedCount, color: '#8b5cf6', icon: '⭐' }, { label: '评级数', value: stats.gradedCount, color: '#8b5cf6' },
{ label: '预期利润', value: formatMoney(stats.expectedProfit) + '万', color: stats.expectedProfit >= 0 ? '#f59e0b' : '#ef4444', icon: '🎯' }, { label: '预期利润', value: formatMoney(stats.expectedProfit) + '万', color: stats.expectedProfit >= 0 ? '#f59e0b' : '#ef4444' },
{ label: '总利润', value: formatMoney(stats.totalProfit) + '万', color: stats.totalProfit >= 0 ? '#10b981' : '#f43f5e', icon: '💵' } { label: '总利润', value: formatMoney(stats.totalProfit) + '万', color: stats.totalProfit >= 0 ? '#10b981' : '#f43f5e' }
] ]
return ( return (
@ -104,9 +131,9 @@ export default function Home() {
backdropFilter: 'blur(10px)' backdropFilter: 'blur(10px)'
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<img src="/static/images/jiachenlong-logo.png?v=1.2.4" alt="甲辰收藏" style={{ width: '48px', height: '48px', borderRadius: '50%', objectFit: 'cover' }} /> <img src="/static/images/jiachenlong-logo.png" alt="甲辰收藏" style={{ width: '48px', height: '48px', borderRadius: '50%', objectFit: 'cover' }} />
<div> <div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '600' }}>{user?.username || '用户'}</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: 'rgba(255,255,255,0.6)', fontSize: '12px' }}>欢迎回来 👋</div> <div style={{ color: 'rgba(255,255,255,0.6)', fontSize: '12px' }}>欢迎回来 👋</div>
</div> </div>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '12px' }}> <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '12px' }}>
@ -139,16 +166,107 @@ export default function Home() {
onMouseOut={e => { e.currentTarget.style.transform = 'translateY(0)' }} onMouseOut={e => { e.currentTarget.style.transform = 'translateY(0)' }}
onClick={() => window.location.hash = '#/stats'} onClick={() => window.location.hash = '#/stats'}
> >
<div style={{ fontSize: '24px', marginBottom: '4px' }}>{card.icon}</div>
<div style={{ color: card.color, fontSize: '18px', fontWeight: '700', marginBottom: '4px' }}>{card.value}</div> <div style={{ color: card.color, fontSize: '18px', fontWeight: '700', marginBottom: '4px' }}>{card.value}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>{card.label}</div> <div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>{card.label}</div>
</div> </div>
))} ))}
</div> </div>
{/* 一尘看板数据 */} {/* 快捷操作 */}
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', marginBottom: '12px', paddingLeft: '4px' }}>📊 一尘今日数据</div> <div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', marginBottom: '12px', paddingLeft: '4px' }}>快捷操作</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
<div onClick={() => window.location.hash = '#/add?mode=ocr'} style={{
background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
borderRadius: '12px',
padding: '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' }}>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>
<div onClick={() => window.location.hash = '#/add?mode=manual'} style={{
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
borderRadius: '12px',
padding: '12px 16px',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center'
}}>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>发布藏品</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>手动发布</div>
</div>
</div>
</div>
{/* 寻配号数据 */}
<div style={{ marginBottom: '20px' }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', marginBottom: '12px', paddingLeft: '4px' }}>🔍 寻配号数据</div>
<div onClick={() => window.location.hash = '#/news?type=seek'} style={{
background: 'linear-gradient(135deg, rgba(245,158,11,0.15) 0%, rgba(245,158,11,0.05) 100%)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(245,158,11,0.2)',
cursor: 'pointer'
}}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px', textAlign: 'center' }}>
<div>
<div 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>
</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={{ 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={{ 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: '#60a5fa', fontSize: '20px', fontWeight: '700' }}>{yichensStats.total || 0}</div>
@ -177,48 +295,56 @@ export default function Home() {
</div> </div>
</div> </div>
{/* 快捷操作 */} {/* 今日龙钞帖子数据统计 */}
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', marginBottom: '12px', paddingLeft: '4px' }}>快捷操作</div> <div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', marginBottom: '12px', paddingLeft: '4px' }}>🐉 今日龙钞帖子数据统计</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}> <div 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 onClick={() => window.location.hash = '#/add?mode=ocr'} style={{ {/* 表头 */}
background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)', <div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '8px' }}>
borderRadius: '12px', <div></div>
padding: '16px', <div style={{ color: '#22c55e', fontSize: '14px', fontWeight: '600', textAlign: 'center' }}>出售</div>
cursor: 'pointer', <div style={{ color: '#f97316', fontSize: '14px', fontWeight: '600', textAlign: 'center' }}>求购</div>
display: 'flex', <div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', fontWeight: '600', textAlign: 'center' }}>合计</div>
alignItems: 'center',
gap: '12px'
}}>
<div style={{ fontSize: '24px' }}>📷</div>
<div>
<div style={{ color: '#fff', fontSize: '15px', fontWeight: '600' }}>AI识别</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '11px' }}>拍照识别藏品</div>
</div>
</div> </div>
<div onClick={() => window.location.hash = '#/add?mode=manual'} style={{ {/* 数据行 */}
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', <div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
borderRadius: '12px', <div style={{ color: '#ef4444', fontSize: '14px', fontWeight: '500' }}>带4</div>
padding: '16px', <div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.dai4?.deals || 0}</div>
cursor: 'pointer', <div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.dai4?.wants || 0}</div>
display: 'flex', <div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.dai4?.deals || 0) + (dragonStats.dai4?.wants || 0)}</div>
alignItems: 'center', </div>
gap: '12px' <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={{ fontSize: '24px' }}></div> <div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu4?.deals || 0}</div>
<div> <div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu4?.wants || 0}</div>
<div style={{ color: '#fff', fontSize: '15px', fontWeight: '600' }}>手动录入</div> <div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu4?.deals || 0) + (dragonStats.wu4?.wants || 0)}</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '11px' }}>添加新藏品</div> </div>
</div> <div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
<div style={{ color: '#3b82f6', fontSize: '14px', fontWeight: '500' }}>无47</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu47?.deals || 0}</div>
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu47?.wants || 0}</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu47?.deals || 0) + (dragonStats.wu47?.wants || 0)}</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
<div style={{ color: '#a855f7', fontSize: '14px', fontWeight: '500' }}>无247</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu247?.deals || 0}</div>
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu247?.wants || 0}</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu247?.deals || 0) + (dragonStats.wu247?.wants || 0)}</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px' }}>
<div style={{ color: '#ec4899', fontSize: '14px', fontWeight: '500' }}>无347</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu347?.deals || 0}</div>
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu347?.wants || 0}</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu347?.deals || 0) + (dragonStats.wu347?.wants || 0)}</div>
</div> </div>
</div> </div>
</div> </div>
{/* 最近藏品 */} {/* 最新一尘发帖 */}
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', paddingLeft: '4px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', paddingLeft: '4px' }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px' }}>最近藏品</div> <div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px' }}>📝 最新一尘发帖</div>
<div onClick={() => window.location.hash = '#/list'} style={{ color: '#3b82f6', fontSize: '12px', cursor: 'pointer' }}>查看全部 </div> <div onClick={() => window.location.hash = '#/news'} style={{ color: '#3b82f6', fontSize: '12px', cursor: 'pointer' }}>查看全部 </div>
</div> </div>
<div style={{ <div style={{
background: 'rgba(255,255,255,0.03)', background: 'rgba(255,255,255,0.03)',
@ -226,24 +352,30 @@ export default function Home() {
border: '1px solid rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.05)',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
{recentCollections.length === 0 ? ( {recentPosts.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>暂无藏品</div> <div style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>暂无帖子</div>
) : ( ) : (
recentCollections.map((item, idx) => ( recentPosts.map((item, idx) => (
<div key={item.id || idx} onClick={() => window.location.hash = '#/detail?id=' + item.id} style={{ <div key={item.post_id || idx} onClick={() => window.open(item.url, '_blank')} style={{
padding: '12px 16px', padding: '12px 16px',
borderBottom: idx < recentCollections.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none', borderBottom: idx < recentPosts.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none',
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center' alignItems: 'center'
}}> }}>
<div> <div style={{ flex: 1 }}>
<div style={{ color: '#fff', fontSize: '14px' }}>{item.code || '-'} <span style={{ color: 'rgba(255,255,255,0.4)', fontSize: '14px', fontWeight: 'bold' }}>{item.prefixSerial || ''}</span></div> <div style={{ color: '#fff', fontSize: '14px' }}>{item.title || '无标题'}</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 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>
<div style={{ color: item.costPrice ? '#22c55e' : 'rgba(255,255,255,0.3)', fontSize: '13px' }}> <div style={{
{item.costPrice ? '¥' + item.costPrice : '-'} 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> </div>
</div> </div>
)) ))
@ -267,13 +399,13 @@ export default function Home() {
{(isAdmin ? [ {(isAdmin ? [
{ icon: '🏠', label: '首页', hash: '#/', idx: 0 }, { icon: '🏠', label: '首页', hash: '#/', idx: 0 },
{ icon: '📚', label: '藏品', hash: '#/list', idx: 1 }, { icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
{ icon: '', label: '添加', hash: '#/ocr', idx: 2 }, { icon: '', label: '录入', hash: '#/ocr', idx: 2 },
{ icon: '📊', label: '统计', hash: '#/stats', idx: 3 }, { icon: '📊', label: '统计', hash: '#/stats', idx: 3 },
{ icon: '⚙️', label: '管理', hash: '#/admin', idx: 4 } { icon: '⚙️', label: '管理', hash: '#/admin', idx: 4 }
] : [ ] : [
{ icon: '🏠', label: '首页', hash: '#/', idx: 0 }, { icon: '🏠', label: '首页', hash: '#/', idx: 0 },
{ icon: '📚', label: '藏品', hash: '#/list', idx: 1 }, { icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
{ icon: '', label: '添加', hash: '#/ocr', idx: 2 }, { icon: '', label: '录入', hash: '#/ocr', idx: 2 },
{ icon: '📊', label: '统计', hash: '#/stats', idx: 3 } { icon: '📊', label: '统计', hash: '#/stats', idx: 3 }
]).map((item) => ( ]).map((item) => (
<div key={item.idx} onClick={() => window.location.hash = item.hash} style={{ <div key={item.idx} onClick={() => window.location.hash = item.hash} style={{

View File

@ -1,837 +0,0 @@
import React, { useState, useEffect } from 'react'
// - /
export default function Info() {
//
if (!localStorage.getItem('token')) {
window.location.hash = '#/login'
return null
}
// tab /
const [activeTab, setActiveTab] = useState('yichens')
const [showPublish, setShowPublish] = useState(false)
const [publishType, setPublishType] = useState('deal') // 'seek' 'deal'
const [myList, setMyList] = useState([])
const [loading, setLoading] = useState(false)
const [editingItem, setEditingItem] = useState(null)
const [filterType, setFilterType] = useState('all') // all/seek/deal
const [expandedItems, setExpandedItems] = useState({}) //
const [formData, setFormData] = useState({
edition: '龙钞', type: '标百', isGraded: true, category: '成交', source: '直播', title: ''
})
//
const [seekForm, setSeekForm] = useState({
edition: '龙钞', price: '', features: '', contact: '', content: '', title: ''
})
const API_BASE = localStorage.getItem('API_BASE') || ''
// /
const toggleExpand = (itemId) => {
setExpandedItems(prev => ({
...prev,
[itemId]: !prev[itemId]
}))
}
useEffect(() => {
if (activeTab === 'manage') fetchMyList()
}, [activeTab])
useEffect(() => {
//
const now = new Date()
const date = `${now.getFullYear()}/${now.getMonth()+1}/${now.getDate()}`
const grade = formData.isGraded ? '(评级币)' : '(裸钞)'
const title = `${date} ${formData.edition} ${formData.type} ${formData.category} 行情信息${grade}`
setFormData(prev => ({ ...prev, title }))
}, [formData.edition, formData.type, formData.isGraded, formData.category])
useEffect(() => {
//
if (seekForm.edition || seekForm.features) {
const title = `「寻号 ${seekForm.edition} J0${seekForm.features || 'XXXXXXXX'}`
setSeekForm(prev => ({ ...prev, title }))
}
}, [seekForm.edition, seekForm.features])
const fetchMyList = async () => {
setLoading(true)
try {
const token = localStorage.getItem('token')
const res = await fetch(`${API_BASE}/api/information/list`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
setMyList(data || [])
}
} catch (e) {
console.error(e)
}
setLoading(false)
}
//
const handlePublishSeek = async () => {
if (!seekForm.contact) {
alert('请填写联系方式')
return
}
try {
const token = localStorage.getItem('token')
const res = await fetch(`${API_BASE}/api/information/`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: seekForm.title,
content: document.getElementById('seekContent')?.value || seekForm.content,
info_type: 'seek',
expect_category: seekForm.edition,
expect_number: seekForm.features ? `J0${seekForm.features}` : null
})
})
const data = await res.json()
if (data.id || data.code === 0) {
alert('发布成功!')
setShowPublish(false)
setSeekForm({ edition: '龙钞', price: '', features: '', contact: '', content: '', title: '' })
const contentEl = document.getElementById('seekContent')
if (contentEl) contentEl.value = ''
fetchMyList()
} else {
alert(data.message || '发布失败')
}
} catch (e) {
alert('发布失败: ' + e.message)
}
}
//
const handlePublishDeal = async () => {
const content = document.getElementById('publishContent')?.value || ''
if (!formData.title) {
alert('请填写标题')
return
}
try {
const token = localStorage.getItem('token')
const res = await fetch(`${API_BASE}/api/information/`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: formData.title,
content: content,
info_type: formData.category === '成交' ? 'deal' : 'seek'
})
})
const data = await res.json()
if (data.id || data.code === 0) {
alert('发布成功!')
setShowPublish(false)
setFormData({ edition: '龙钞', type: '标百', isGraded: true, category: '成交', source: '直播', title: '' })
const contentEl = document.getElementById('publishContent')
if (contentEl) contentEl.value = ''
fetchMyList()
} else {
alert(data.message || '发布失败')
}
} catch (e) {
alert('发布失败: ' + e.message)
}
}
//
const handleDelete = async (id) => {
if (!confirm('确定删除这条信息吗?')) return
try {
const token = localStorage.getItem('token')
const res = await fetch(`${API_BASE}/api/information/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
alert('删除成功')
fetchMyList()
} else {
alert('删除失败')
}
} catch (e) {
alert('删除失败: ' + e.message)
}
}
//
const handleEdit = (item) => {
setEditingItem({ id: item.id, title: item.title, content: item.content })
}
const saveEdit = async () => {
if (!editingItem) return
try {
const token = localStorage.getItem('token')
const res = await fetch(`${API_BASE}/api/information/${editingItem.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ title: editingItem.title, content: editingItem.content })
})
if (res.ok) {
alert('保存成功')
setEditingItem(null)
fetchMyList()
} else {
alert('保存失败')
}
} catch (e) {
alert('保存失败: ' + e.message)
}
}
const getUserPhone = () => {
try {
const user = JSON.parse(localStorage.getItem('user') || '{}')
return user.phone || user.phoneNumber || user.mobile || user.tel || ''
} catch {
return ''
}
}
useEffect(() => {
setSeekForm(prev => ({ ...prev, contact: getUserPhone() }))
}, [])
//
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN').slice(0, 16)
}
//
const filteredList = myList.filter(item => filterType === 'all' || item.info_type === filterType)
return (
<div style={{ minHeight: '100vh', background: 'linear-gradient(180deg, #0f172a 0%, #1e293b 100%)', paddingBottom: '80px' }}>
{/* 顶部 Tab 切换 */}
<div style={{ position: 'sticky', top: 0, background: 'rgba(15,23,42,0.95)', backdropFilter: 'blur(10px)', padding: '16px 20px', borderBottom: '1px solid #1e293b', zIndex: 100 }}>
<div style={{ display: 'flex', gap: '8px', background: '#1e293b', borderRadius: '12px', padding: '4px' }}>
{[
{ key: 'manage', label: '📋 发布管理' },
{ key: 'yichens', label: '📊 一尘看板' }
].map(tab => (
<div
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
flex: 1,
padding: '12px 16px',
borderRadius: '10px',
background: activeTab === tab.key ? 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' : 'transparent',
color: activeTab === tab.key ? '#fff' : '#9ca3af',
cursor: 'pointer',
textAlign: 'center',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.3s'
}}
>
{tab.label}
</div>
))}
</div>
</div>
<div style={{ padding: '20px' }}>
{/* 寻配号发布页 - 已删除 */}
{false && (
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: '16px', padding: '20px', border: '1px solid #374151', boxShadow: '0 4px 20px rgba(0,0,0,0.3)' }}>
<h3 style={{ color: '#f9fafb', margin: '0 0 20px 0', fontSize: '18px', fontWeight: '600' }}>🔍 寻配号发布</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div>
<label style={{ color: '#9ca3af', fontSize: '13px', display: 'block', marginBottom: '8px' }}>版别</label>
<div style={{ display: 'flex', gap: '8px' }}>
{['龙钞', '马钞', '蛇钞'].map(ed => {
const selected = seekForm.edition === ed
return (
<button
key={ed}
onClick={() => setSeekForm({ ...seekForm, edition: ed })}
style={{
flex: 1,
padding: '10px',
borderRadius: '8px',
border: selected ? '2px solid #10b981' : '1px solid #374151',
background: selected ? 'rgba(16,185,129,0.15)' : '#1f2937',
color: selected ? '#10b981' : '#d1d5db',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{ed}
</button>
)
})}
</div>
</div>
<div>
<label style={{ color: '#9ca3af', fontSize: '13px', display: 'block', marginBottom: '8px' }}>号码特征8</label>
<input
type="text"
value={seekForm.features}
onChange={e => setSeekForm({ ...seekForm, features: e.target.value.toUpperCase().slice(0, 8) })}
placeholder="输入号码特征12345678"
maxLength={8}
style={{ width: '100%', padding: '12px', background: '#1f2937', border: '1px solid #374151', borderRadius: '10px', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }}
/>
<div style={{ color: '#6b7280', fontSize: '11px', marginTop: '4px' }}>X=任意数字 A=非4 B=非47 C=非347 D=非247</div>
</div>
<div>
<label style={{ color: '#9ca3af', fontSize: '13px', display: 'block', marginBottom: '8px' }}>标题自动生成</label>
<input
type="text"
value={seekForm.title}
readOnly
style={{ width: '100%', padding: '12px', background: '#1f2937', border: '1px solid #374151', borderRadius: '10px', color: '#10b981', fontSize: '14px', boxSizing: 'border-box' }}
/>
</div>
<div>
<label style={{ color: '#9ca3af', fontSize: '13px', display: 'block', marginBottom: '8px' }}>正文</label>
<textarea
id="seekContent"
placeholder="请输入详细信息..."
style={{ width: '100%', padding: '12px', background: '#1f2937', border: '1px solid #374151', borderRadius: '10px', color: '#fff', fontSize: '14px', boxSizing: 'border-box', resize: 'vertical', minHeight: '80px' }}
/>
</div>
<div>
<label style={{ color: '#ef4444', fontSize: '13px', display: 'block', marginBottom: '8px' }}>联系方式 *</label>
<input
type="text"
value={seekForm.contact}
onChange={e => setSeekForm({ ...seekForm, contact: e.target.value })}
placeholder="请输入手机号或微信"
style={{ width: '100%', padding: '12px', background: '#1f2937', border: '1px solid #374151', borderRadius: '10px', color: '#fff', fontSize: '14px', boxSizing: 'border-box' }}
/>
</div>
<button
onClick={handlePublishSeek}
style={{
width: '100%',
padding: '14px',
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
border: 'none',
borderRadius: '10px',
color: '#fff',
fontSize: '16px',
fontWeight: 'bold',
cursor: 'pointer'
}}
>
🚀 发布寻配号
</button>
</div>
</div>
)}
{/* 发布管理页 */}
{activeTab === 'manage' && (
<div>
{/* 新增发布区域 - 发布行情信息 */}
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: '16px', padding: '20px', marginBottom: '16px', border: '1px solid #374151', boxShadow: '0 4px 20px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3 style={{ color: '#f9fafb', margin: 0, fontSize: '16px', fontWeight: '600' }}>📋 发布管理</h3>
<button
onClick={() => setShowPublish(!showPublish)}
style={{
padding: '10px 20px',
background: showPublish ? 'rgba(108,114,132,0.5)' : 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
border: 'none',
borderRadius: '10px',
color: '#fff',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
{showPublish ? '✕ 收起' : '+ 💰 新增行情发布'}
</button>
</div>
{/* 行情发布表单 */}
{showPublish && (
<div style={{ background: '#0f172a', borderRadius: '12px', padding: '16px', marginBottom: '16px' }}>
<div style={{ color: '#f59e0b', fontSize: '14px', fontWeight: '600', marginBottom: '16px' }}>💰 发布行情信息</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', gap: '12px' }}>
<div style={{ flex: 2 }}>
<label style={{ color: '#9ca3af', fontSize: '12px', display: 'block', marginBottom: '6px' }}>版别</label>
<div style={{ display: 'flex', gap: '6px' }}>
{['龙钞', '马钞', '蛇钞', '其他'].map(ed => (
<button
key={ed}
onClick={() => setFormData({ ...formData, edition: ed })}
style={{
flex: 1,
padding: '8px',
borderRadius: '6px',
border: formData.edition === ed ? '2px solid #f59e0b' : '1px solid #374151',
background: formData.edition === ed ? 'rgba(245,158,11,0.15)' : '#1f2937',
color: formData.edition === ed ? '#f59e0b' : '#d1d5db',
cursor: 'pointer',
fontSize: '12px'
}}
>
{ed}
</button>
))}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<div style={{ flex: 2 }}>
<label style={{ color: '#9ca3af', fontSize: '12px', display: 'block', marginBottom: '6px' }}>类型</label>
<div style={{ display: 'flex', gap: '6px' }}>
{['标百', '标十', '单张'].map(t => (
<button
key={t}
onClick={() => setFormData({ ...formData, type: t })}
style={{
flex: 1,
padding: '8px',
borderRadius: '6px',
border: formData.type === t ? '2px solid #f59e0b' : '1px solid #374151',
background: formData.type === t ? 'rgba(245,158,11,0.15)' : '#1f2937',
color: formData.type === t ? '#f59e0b' : '#d1d5db',
cursor: 'pointer',
fontSize: '12px'
}}
>
{t}
</button>
))}
</div>
</div>
<div style={{ flex: 1 }}>
<label style={{ color: '#9ca3af', fontSize: '12px', display: 'block', marginBottom: '6px' }}>是否评级</label>
<button
onClick={() => setFormData({ ...formData, isGraded: !formData.isGraded })}
style={{
width: '100%',
padding: '8px',
borderRadius: '6px',
border: formData.isGraded ? '2px solid #f59e0b' : '1px solid #374151',
background: formData.isGraded ? 'rgba(245,158,11,0.15)' : '#1f2937',
color: formData.isGraded ? '#f59e0b' : '#d1d5db',
cursor: 'pointer',
fontSize: '12px'
}}
>
{formData.isGraded ? '✓ 评级币' : '○ 裸钞'}
</button>
</div>
</div>
<div>
<label style={{ color: '#9ca3af', fontSize: '12px', display: 'block', marginBottom: '6px' }}>分类</label>
<div style={{ display: 'flex', gap: '8px' }}>
{[{value:'成交',label:'💰 成交'},{value:'求购',label:'🔍 求购'},{value:'出售',label:'💵 出售'}].map(opt => (
<button
key={opt.value}
onClick={() => setFormData({ ...formData, category: opt.value })}
style={{
flex: 1,
padding: '10px',
borderRadius: '8px',
border: formData.category === opt.value ? '2px solid #f59e0b' : '1px solid #374151',
background: formData.category === opt.value ? 'rgba(245,158,11,0.15)' : '#1f2937',
color: formData.category === opt.value ? '#f59e0b' : '#d1d5db',
cursor: 'pointer',
fontSize: '13px',
fontWeight: '500'
}}
>
{opt.label}
</button>
))}
</div>
</div>
<div>
<label style={{ color: '#9ca3af', fontSize: '12px', display: 'block', marginBottom: '6px' }}>标题自动生成</label>
<input
type="text"
value={formData.title}
readOnly
style={{ width: '100%', padding: '12px', background: '#1f2937', border: '1px solid #374151', borderRadius: '10px', color: '#10b981', fontSize: '14px', boxSizing: 'border-box' }}
/>
</div>
<div>
<label style={{ color: '#9ca3af', fontSize: '12px', display: 'block', marginBottom: '6px' }}>正文</label>
<textarea
id="publishContent"
placeholder="请输入行情详细信息..."
style={{ width: '100%', padding: '12px', background: '#1f2937', border: '1px solid #374151', borderRadius: '10px', color: '#fff', fontSize: '14px', boxSizing: 'border-box', resize: 'vertical', minHeight: '80px' }}
/>
</div>
<button
onClick={handlePublishDeal}
style={{
width: '100%',
padding: '14px',
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
border: 'none',
borderRadius: '10px',
color: '#fff',
fontSize: '15px',
fontWeight: 'bold',
cursor: 'pointer'
}}
>
🚀 提交发布
</button>
</div>
</div>
)}
</div>
{/* 列表过滤:全部 / 寻号 / 行情 */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<button
onClick={() => setFilterType('all')}
style={{
padding: '8px 16px',
background: filterType === 'all' ? '#3b82f6' : '#374151',
border: 'none',
borderRadius: '6px',
color: '#fff',
fontSize: '13px',
cursor: 'pointer'
}}
>
全部
</button>
<button
onClick={() => setFilterType('seek')}
style={{
padding: '8px 16px',
background: filterType === 'seek' ? '#10b981' : '#374151',
border: 'none',
borderRadius: '6px',
color: '#fff',
fontSize: '13px',
cursor: 'pointer'
}}
>
🔍 寻号
</button>
<button
onClick={() => setFilterType('deal')}
style={{
padding: '8px 16px',
background: filterType === 'deal' ? '#f59e0b' : '#374151',
border: 'none',
borderRadius: '6px',
color: '#fff',
fontSize: '13px',
cursor: 'pointer'
}}
>
💰 行情
</button>
</div>
{/* 发布列表 */}
{loading ? (
<div style={{ color: '#9ca3af', textAlign: 'center', padding: '30px' }}>加载中...</div>
) : filteredList.length === 0 ? (
<div style={{ color: '#9ca3af', textAlign: 'center', padding: '30px', fontSize: '14px' }}>暂无发布记录</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{filteredList.map(item => (
<div key={item.id} style={{ background: '#1e293b', borderRadius: '12px', padding: '16px', border: '1px solid #334155' }}>
{/* 标题行 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px' }}>
<div style={{ color: '#fbbf24', fontSize: '15px', fontWeight: '600', flex: 1 }}>{item.title}</div>
<span style={{
background: item.info_type === 'deal' ? 'rgba(245,158,11,0.2)' : 'rgba(16,185,129,0.2)',
color: item.info_type === 'deal' ? '#f59e0b' : '#10b981',
padding: '4px 10px',
borderRadius: '20px',
fontSize: '12px'
}}>
{item.info_type === 'deal' ? '💰 行情' : '🔍 寻号'}
</span>
</div>
{/* 日期+用户名 */}
<div style={{ color: '#64748b', fontSize: '12px', marginBottom: '8px' }}>
📅 {formatDate(item.created_at)} &nbsp;|&nbsp; 👤 {item.user_name || '匿名用户'}
</div>
{/* 正文 - 默认收起,点击展开 */}
{item.content && (
<div style={{ marginBottom: '12px' }}>
<div
onClick={() => toggleExpand(item.id)}
style={{
color: '#10b981',
fontSize: '13px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
background: 'rgba(16,185,129,0.1)',
borderRadius: '6px',
border: '1px solid rgba(16,185,129,0.2)'
}}
>
<span style={{ fontSize: '16px' }}>{expandedItems[item.id] ? '▼' : '▶'}</span>
<span>{expandedItems[item.id] ? '收起详情' : '展开查看详情'}</span>
</div>
{expandedItems[item.id] && (
<div style={{
color: '#e2e8f0',
fontSize: '14px',
lineHeight: '1.8',
marginTop: '12px',
background: 'rgba(255,255,255,0.03)',
padding: '12px',
borderRadius: '8px',
borderLeft: '3px solid #10b981'
}}>
{item.content.split('\n').map((line, i) => (
<div key={i} style={{ marginBottom: i < item.content.split('\n').length - 1 ? '6px' : 0 }}>
{line || ' '}
</div>
))}
</div>
)}
</div>
)}
{/* 操作按钮 */}
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginTop: '8px' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => handleEdit(item)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid #3b82f6', background: 'transparent', color: '#3b82f6', cursor: 'pointer', fontSize: '12px' }}> 编辑</button>
<button onClick={() => handleDelete(item.id)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid #ef4444', background: 'transparent', color: '#ef4444', cursor: 'pointer', fontSize: '12px' }}>🗑 删除</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* 编辑弹窗 */}
{editingItem && (
<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, padding: '20px' }}>
<div style={{ background: '#1f2937', borderRadius: '16px', padding: '24px', width: '100%', maxWidth: '420px', border: '1px solid #374151' }}>
<h3 style={{ color: '#f9fafb', marginBottom: '20px', fontSize: '18px', fontWeight: '600' }}> 编辑信息</h3>
<div style={{ marginBottom: '16px' }}>
<label style={{ color: '#9ca3af', fontSize: '13px', display: 'block', marginBottom: '8px' }}>标题</label>
<input
type="text"
value={editingItem.title}
onChange={e => setEditingItem({ ...editingItem, title: e.target.value })}
style={{ width: '100%', padding: '12px', background: '#0f172a', border: '1px solid #374151', borderRadius: '8px', color: '#fff', boxSizing: 'border-box', fontSize: '14px' }}
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ color: '#9ca3af', fontSize: '13px', display: 'block', marginBottom: '8px' }}>内容</label>
<textarea
value={editingItem.content}
onChange={e => setEditingItem({ ...editingItem, content: e.target.value })}
rows={4}
style={{ width: '100%', padding: '12px', background: '#0f172a', border: '1px solid #374151', borderRadius: '8px', color: '#fff', boxSizing: 'border-box', fontSize: '14px', resize: 'vertical' }}
/>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button onClick={saveEdit} style={{ flex: 1, padding: '12px', background: '#10b981', border: 'none', borderRadius: '8px', color: '#fff', cursor: 'pointer', fontSize: '14px', fontWeight: '600' }}>💾 保存</button>
<button onClick={() => setEditingItem(null)} style={{ flex: 1, padding: '12px', border: '1px solid #374151', borderRadius: '8px', background: 'transparent', color: '#9ca3af', cursor: 'pointer', fontSize: '14px' }}>取消</button>
</div>
</div>
</div>
)}
{/* 一尘看板页 */}
{activeTab === 'yichens' && (
<YichensBoard />
)}
</div>
)
}
// ============ ============
function YichensBoard() {
const [stats, setStats] = useState({ posts: null, categories: [], users: null })
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(false)
const [expandedPosts, setExpandedPosts] = useState({})
const [postTypeFilter, setPostTypeFilter] = useState('all')
const API_BASE = localStorage.getItem('API_BASE') || ''
useEffect(() => {
fetchStats()
fetchPosts()
}, [])
const fetchStats = async () => {
try {
const [postsRes, catRes, usersRes] = await Promise.all([
fetch(`${API_BASE}/api/yichens/stats/posts?days=30`),
fetch(`${API_BASE}/api/yichens/stats/categories?days=30`),
fetch(`${API_BASE}/api/yichens/stats/users`)
])
const postsData = await postsRes.json()
const catData = await catRes.json()
const usersData = await usersRes.json()
setStats({ posts: postsData, categories: catData, users: usersData })
} catch (e) {
console.error('获取统计失败:', e)
}
}
const fetchPosts = async () => {
setLoading(true)
try {
let url = `${API_BASE}/api/yichens/posts?limit=50`
if (postTypeFilter !== 'all') url += `&post_type=${postTypeFilter}`
const res = await fetch(url)
const data = await res.json()
setPosts(data || [])
} catch (e) {
console.error('获取帖子失败:', e)
}
setLoading(false)
}
const togglePostExpand = (postId) => {
setExpandedPosts(prev => ({ ...prev, [postId]: !prev[postId] }))
}
const filteredPosts = postTypeFilter === 'all'
? posts
: posts.filter(p => p.post_type === postTypeFilter)
return (
<div style={{ padding: '0' }}>
{stats.posts && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px', marginBottom: '20px' }}>
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: '12px', padding: '16px', border: '1px solid #374151' }}>
<div style={{ color: '#9ca3af', fontSize: '12px', marginBottom: '4px' }}>30天帖子</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{stats.posts.total_posts}</div>
<div style={{ color: '#6b7280', fontSize: '11px' }}>出售{stats.posts.total_deals} 求购{stats.posts.total_wants}</div>
</div>
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: '12px', padding: '16px', border: '1px solid #374151' }}>
<div style={{ color: '#9ca3af', fontSize: '12px', marginBottom: '4px' }}>浏览量</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{(stats.posts.total_views / 10000).toFixed(1)}</div>
<div style={{ color: '#6b7280', fontSize: '11px' }}>回复{stats.posts.total_replies}</div>
</div>
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: '12px', padding: '16px', border: '1px solid #374151' }}>
<div style={{ color: '#9ca3af', fontSize: '12px', marginBottom: '4px' }}>用户数</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{stats.users?.total_users || 0}</div>
<div style={{ color: '#6b7280', fontSize: '11px' }}>今日新增{stats.users?.new_users_today || 0}</div>
</div>
</div>
)}
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: '12px', padding: '16px', marginBottom: '20px', border: '1px solid #374151' }}>
<div style={{ color: '#f9fafb', fontSize: '14px', fontWeight: '600', marginBottom: '12px' }}>📊 分类统计</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{stats.categories.map(cat => (
<span key={cat.category} style={{ padding: '4px 12px', background: 'rgba(59,130,246,0.2)', borderRadius: '20px', color: '#93c5fd', fontSize: '12px' }}>
{cat.category} ({cat.count})
</span>
))}
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
{[
{ key: 'all', label: '全部' },
{ key: 'deal', label: '出售' },
{ key: 'want', label: '求购' }
].map(ft => (
<button
key={ft.key}
onClick={() => { setPostTypeFilter(ft.key); fetchPosts() }}
style={{
padding: '8px 16px',
borderRadius: '8px',
border: 'none',
background: postTypeFilter === ft.key ? 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' : '#374151',
color: '#fff',
cursor: 'pointer',
fontSize: '13px'
}}
>
{ft.label}
</button>
))}
</div>
{loading ? (
<div style={{ textAlign: 'center', color: '#9ca3af', padding: '40px' }}>加载中...</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{filteredPosts.map(post => (
<div key={post.post_id} style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: '12px', padding: '16px', border: '1px solid #374151' }}>
<div onClick={() => togglePostExpand(post.post_id)} style={{ cursor: 'pointer' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<span style={{ color: '#fff', fontSize: '15px', fontWeight: '500' }}>{post.title || '无标题'}</span>
<span style={{ color: post.post_type === 'deal' ? '#10b981' : post.post_type === 'want' ? '#f59e0b' : '#9ca3af', fontSize: '12px' }}>
{post.post_type === 'deal' ? '出售' : post.post_type === 'want' ? '求购' : '普通'}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#9ca3af', fontSize: '12px' }}>
<span>{post.author_username || '未知'}</span>
<span>{post.post_time ? post.post_time.substring(0, 16) : ''}</span>
</div>
</div>
{expandedPosts[post.post_id] && (
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid #374151' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', fontSize: '13px' }}>
<div><span style={{ color: '#6b7280' }}>分类: </span><span style={{ color: '#fff' }}>{post.category || '-'}</span></div>
<div><span style={{ color: '#6b7280' }}>价格: </span><span style={{ color: '#10b981' }}>{post.price ? post.price.toLocaleString() + '元' : '待询'}</span></div>
<div><span style={{ color: '#6b7280' }}>浏览: </span><span style={{ color: '#fff' }}>{post.view_count || 0}</span></div>
<div><span style={{ color: '#6b7280' }}>回复: </span><span style={{ color: '#fff' }}>{post.reply_count || 0}</span></div>
</div>
{post.url && (
<a href={post.url} target="_blank" rel="noopener noreferrer" style={{ display: 'inline-block', marginTop: '12px', padding: '8px 16px', background: 'rgba(59,130,246,0.2)', borderRadius: '8px', color: '#60a5fa', fontSize: '13px', textDecoration: 'none' }}>
查看原帖
</a>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

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

View File

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

View File

@ -1,3 +1,9 @@
/**
* News - 资讯列表页面
* Version: 1.2.80x
* 更新
*/
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import YichensBoard from './YichensBoard' import YichensBoard from './YichensBoard'
@ -32,7 +38,12 @@ export default function News() {
const [comments, setComments] = useState({}) // const [comments, setComments] = useState({}) //
const [matchedStatus, setMatchedStatus] = useState({}) // const [matchedStatus, setMatchedStatus] = useState({}) //
const [showMatchList, setShowMatchList] = useState(false) const [showMatchList, setShowMatchList] = useState(false)
const [dealVersion, setDealVersion] = useState('龙钞')
const [dealDate, setDealDate] = useState('')
const [dealDetailItems, setDealDetailItems] = useState(null) //
const [matchCollections, setMatchCollections] = useState([]) const [matchCollections, setMatchCollections] = useState([])
const [networkMatchCollections, setNetworkMatchCollections] = useState([])
const [showNetworkMatchList, setShowNetworkMatchList] = useState(false)
const [customModal, setCustomModal] = useState({show: false, title: '', content: ''}) const [customModal, setCustomModal] = useState({show: false, title: '', content: ''})
const [seekForm, setSeekForm] = useState({ const [seekForm, setSeekForm] = useState({
edition: '龙钞', type: '', category: '寻号', price: '', matchType: '模糊', features: '', content: '', title: '', contact: getUserPhone() || '' edition: '龙钞', type: '', category: '寻号', price: '', matchType: '模糊', features: '', content: '', title: '', contact: getUserPhone() || ''
@ -41,6 +52,8 @@ export default function News() {
const [viewMode, setViewMode] = useState('all') const [viewMode, setViewMode] = useState('all')
const [expandedItems, setExpandedItems] = useState({}) // const [expandedItems, setExpandedItems] = useState({}) //
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const API_BASE = localStorage.getItem('API_BASE') || '' const API_BASE = localStorage.getItem('API_BASE') || ''
const currentUser = localStorage.getItem('token') ? { id: 'logged_in' } : null const currentUser = localStorage.getItem('token') ? { id: 'logged_in' } : null
@ -56,7 +69,7 @@ export default function News() {
// //
useEffect(() => { useEffect(() => {
fetchInfoList() fetchInfoList()
}, [activeTab]) }, [activeTab, dealDate])
// //
useEffect(() => { useEffect(() => {
@ -67,13 +80,32 @@ export default function News() {
const fetchInfoList = async () => { const fetchInfoList = async () => {
setLoading(true) setLoading(true)
try { try {
//
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
// tab
const type = activeTab === 'seek' ? 'seek' : activeTab === 'deal' ? 'deal' : 'yichen' const type = activeTab === 'seek' ? 'seek' : activeTab === 'deal' ? 'deal' : 'yichen'
// token便matched_count
const headers = token ? { Authorization: `Bearer ${token}` } : {} const headers = token ? { Authorization: `Bearer ${token}` } : {}
const res = await fetch(`${API_BASE}/api/information/list?info_type=${type}`, { headers })
// URL
// 使APIseekdeal
let url = activeTab === 'yichen'
? `${API_BASE}/api/information/list?info_type=${activeTab}`
: activeTab === 'seek'
? `${API_BASE}/api/seek/list`
: `${API_BASE}/api/deal/list`
// 500
if (activeTab === 'deal') {
url += (url.includes('?') ? '&' : '?') + 'page_size=500'
if (dealDate) {
url += '&deal_date=' + dealDate
}
} else if (activeTab === 'seek') {
url += (url.includes("?") ? "&" : "?") + "page=" + currentPage + "&page_size=500"
}
const res = await fetch(url, { headers })
//
const total = res.headers.get('X-Total-Pages') || res.headers.get('x-total-pages')
if (total) setTotalPages(parseInt(total))
// //
if (!res.ok) { if (!res.ok) {
console.error('获取资讯列表失败:', res.status) console.error('获取资讯列表失败:', res.status)
@ -83,7 +115,21 @@ export default function News() {
} }
const data = await res.json() const data = await res.json()
console.log('资讯列表:', data) console.log('资讯列表:', data)
setInfoList(data || [])
//
let sortedData = data || []
if (activeTab === 'deal' || activeTab === 'seek') {
//
sortedData = [...(data || [])].sort((a, b) => {
const dateA = a.deal_date || a.created_at?.split('T')[0] || ""
const dateB = b.deal_date || b.created_at?.split('T')[0] || ""
if (dateA !== dateB) return dateB.localeCompare(dateA)
const noA = a.deal_no || ""
const noB = b.deal_no || ""
return noB.localeCompare(noA)
})
}
setInfoList(sortedData)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
setInfoList([]) setInfoList([])
@ -147,10 +193,11 @@ export default function News() {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (!token) { alert('请先登录'); return } if (!token) { alert('请先登录'); return }
try { try {
const phone = getUserPhone()
const res = await fetch(`${API_BASE}/api/information/seek/match-confirm`, { const res = await fetch(`${API_BASE}/api/information/seek/match-confirm`, {
method: 'POST', method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ info_id: infoId }) body: JSON.stringify({ info_id: infoId, contact: phone || '用户已同意' })
}) })
const data = await res.json() const data = await res.json()
if (data.message === '匹配成功,已通知发布者') { if (data.message === '匹配成功,已通知发布者') {
@ -188,7 +235,7 @@ export default function News() {
console.log('Matched user response:', res.status) console.log('Matched user response:', res.status)
const data = await res.json() const data = await res.json()
console.log('Matched user data:', data) console.log('Matched user data:', data)
if (data.user_name) setCustomModal({show: true, title: '匹配者信息', content: `用户名: ${data.user_name}\n联系方式: ${data.matched_contact || '未提供'}`}) if (data.user_name) setCustomModal({show: true, title: '匹配者信息', content: `用户名: ${data.user_name}\n手机号: ${data.phone || '未提供'}\n\n免责声明及风险提示所有用户信息仅作参考交易请走正规平台如产生经济损失与本站无关后果自负。`})
else if (data.detail) alert(data.detail) else if (data.detail) alert(data.detail)
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
} }
@ -201,7 +248,7 @@ export default function News() {
console.log('Publisher response:', res.status) console.log('Publisher response:', res.status)
const data = await res.json() const data = await res.json()
console.log('Publisher data:', data) console.log('Publisher data:', data)
if (data.user_name) setCustomModal({show: true, title: '发布者信息', content: `用户名: ${data.user_name}\n联系方式: ${data.contact || '未提供'}`}) if (data.user_name) setCustomModal({show: true, title: '发布者信息', content: `用户名: ${data.user_name}\n手机号: ${data.phone || '未提供'}\n\n免责声明及风险提示所有用户信息仅作参考交易请走正规平台如产生经济损失与本站无关后果自负。`})
else if (data.detail) alert(data.detail) else if (data.detail) alert(data.detail)
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
} }
@ -224,6 +271,20 @@ export default function News() {
} }
} }
//
const fetchNetworkMatchCollections = async (infoId) => {
try {
const res = await fetch(`${API_BASE}/api/information/seek/network-match/${infoId}`)
const data = await res.json()
console.log('网络数据匹配结果:', data)
setNetworkMatchCollections(data.collections || [])
setShowNetworkMatchList(true)
} catch (e) {
console.error('获取网络匹配藏品失败:', e)
alert('获取网络匹配藏品失败: ' + e.message)
}
}
// ID // ID
const currentUserId = getStoredUserId() const currentUserId = getStoredUserId()
@ -235,12 +296,12 @@ export default function News() {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (!token) { alert('请先登录'); return } if (!token) { alert('请先登录'); return }
try { try {
const res = await fetch(`${API_BASE}/api/information/my/list`, { const res = await fetch(`${API_BASE}/api/seek/list`, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}) })
const data = await res.json() const data = await res.json()
// //
const seeks = Array.isArray(data) ? data.filter(item => item.info_type === 'seek' && item.user_id === currentUserId) : [] const seeks = Array.isArray(data) ? data.filter(item => item.user_id === currentUserId) : []
setMySeekList(seeks) setMySeekList(seeks)
setInfoList(seeks) // setInfoList(seeks) //
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
@ -251,7 +312,7 @@ export default function News() {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (!token) { alert('请先登录'); return } if (!token) { alert('请先登录'); return }
try { try {
const res = await fetch(`${API_BASE}/api/information/list?info_type=seek`, { const res = await fetch(`${API_BASE}/api/seek/list`, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}) })
const data = await res.json() const data = await res.json()
@ -315,9 +376,9 @@ export default function News() {
// Tab // Tab
const tabs = [ const tabs = [
{ key: 'seek', label: '🔍 寻配号' }, { key: 'seek', label: '寻配号' },
{ key: 'deal', label: '💰 成交行情' }, { key: 'deal', label: '成交行情' },
{ key: 'yichen', label: '📊 一尘看板' } { key: 'yichen', label: '一尘看板' }
] ]
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
@ -389,7 +450,7 @@ export default function News() {
readOnly={isFixed} readOnly={isFixed}
onChange={(e) => { onChange={(e) => {
if (i < 2) return if (i < 2) return
const val = e.target.value.toUpperCase().replace(/[^0-9XABCFG]/g, '') const val = e.target.value.toUpperCase().replace(/[^0-9XABCDEFG]/g, '')
const newFeatures = (seekForm.features || '').split('') const newFeatures = (seekForm.features || '').split('')
while (newFeatures.length < 8) newFeatures.push('') while (newFeatures.length < 8) newFeatures.push('')
newFeatures[i - 2] = val newFeatures[i - 2] = val
@ -437,7 +498,7 @@ export default function News() {
</div> </div>
)} )}
<h3 style={{ color: '#fff', marginBottom: '16px' }}> <h3 style={{ color: '#fff', marginBottom: '16px' }}>
{activeTab === 'seek' ? '🔍 寻配号信息' : activeTab === 'deal' ? '💰 成交行情信息' : '📊 一尘看板'} {activeTab === 'seek' ? '寻配号信息' : activeTab === 'deal' ? `成交行情信息 (${dealDate})` : '一尘看板'}
{activeTab === 'seek' && ( {activeTab === 'seek' && (
<div style={{ display: 'flex', gap: '8px', marginLeft: 'auto' }}> <div style={{ display: 'flex', gap: '8px', marginLeft: 'auto' }}>
<button onClick={() => { setViewMode('all'); fetchInfoList(); }} style={{ padding: '6px 12px', background: viewMode==='all'?'#3b82f6':'#1e293b', border: '1px solid #334155', borderRadius: '6px', color: '#fff', fontSize: '12px' }}>全部</button> <button onClick={() => { setViewMode('all'); fetchInfoList(); }} style={{ padding: '6px 12px', background: viewMode==='all'?'#3b82f6':'#1e293b', border: '1px solid #334155', borderRadius: '6px', color: '#fff', fontSize: '12px' }}>全部</button>
@ -446,9 +507,18 @@ export default function News() {
<button onClick={() => setShowSeekPublish(!showSeekPublish)} style={{ padding: '6px 12px', background: showSeekPublish ? '#6b7280' : '#10b981', border: 'none', borderRadius: '6px', color: '#fff', fontSize: '12px', cursor: 'pointer' }}>发布寻号</button> <button onClick={() => setShowSeekPublish(!showSeekPublish)} style={{ padding: '6px 12px', background: showSeekPublish ? '#6b7280' : '#10b981', border: 'none', borderRadius: '6px', color: '#fff', fontSize: '12px', cursor: 'pointer' }}>发布寻号</button>
</div> </div>
)} )}
{activeTab === 'deal' && (
<div style={{ display: 'flex', gap: '8px', marginLeft: 'auto' }}>
<input type="date" value={dealDate} onChange={e => setDealDate(e.target.value)}
style={{ padding: '6px 12px', background: '#1e293b', border: '1px solid #334155', borderRadius: '6px', color: '#fff', fontSize: '12px' }} />
</div>
)}
</h3> </h3>
{loading ? ( {/* 一尘看板独立渲染 */}
{activeTab === 'yichen' ? (
<YichensBoard />
) : loading ? (
<div style={{ color: '#94a3b8', textAlign: 'center', padding: '40px' }}>加载中...</div> <div style={{ color: '#94a3b8', textAlign: 'center', padding: '40px' }}>加载中...</div>
) : infoList.length === 0 ? ( ) : infoList.length === 0 ? (
<div style={{ color: '#94a3b8', textAlign: 'center', padding: '40px' }}> <div style={{ color: '#94a3b8', textAlign: 'center', padding: '40px' }}>
@ -456,7 +526,215 @@ export default function News() {
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{activeTab === 'yichen' ? <YichensBoard /> : infoList.map(item => { {/* 成交行情 - 价格统计表格 */}
{activeTab === 'deal' && (() => {
const versions = ['龙钞', '马钞', '蛇钞', '其他']
const packagings = ['标百', '标十', '单张']
const categories = ['带4号', '带7号', '永恒号', '永恒', '钻石号', '钻石', '如意号', '朦胧号', '朦胧王', '天马号', '金山号', '金马号', '天马王', '金山王', '金马王', '倒置号', '圆圆号', '通货', '无4']
//
const normalizeCat = (c) => {
if (c === '通货') return '带4号'
if (c === '无4') return '带7号'
return c
}
const filteredData = infoList.filter(item => {
const serial = (item.title || '').split('-')[0] || ''
let version = '其他'
if (serial.startsWith('J0')) version = '龙钞'
else if (serial.startsWith('J1')) version = '马钞'
else if (serial.startsWith('J3')) version = '蛇钞'
if (version !== dealVersion) return false
return true
})
const calcAvg = (pkg, cat) => {
const items = filteredData.filter(item => {
const content = item.content || ''
const p = content.includes('包装:') ? content.split('包装:')[1].split('\n')[0].trim() : (item.packaging || '')
let c = content.includes('分类:') ? content.split('分类:')[1].split('\n')[0].trim() : (item.category || '')
c = normalizeCat(c)
return p === pkg && c === cat
})
if (items.length === 0) return null
const sum = items.reduce((a, b) => a + (b.deal_price || 0), 0)
return { avg: Math.round(sum / items.length), count: items.length, items }
}
return (
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '12px' }}>
{versions.map(v => (
<button key={v} onClick={() => setDealVersion(v)}
style={{ padding: '6px 16px', borderRadius: '8px', border: 'none', cursor: 'pointer',
background: dealVersion === v ? '#3b82f6' : 'rgba(255,255,255,0.1)',
color: '#fff', fontSize: '13px', fontWeight: dealVersion === v ? '600' : '400' }}>
{v}
</button>
))}
</div>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr>
<th style={{ padding: '8px', textAlign: 'left', color: '#94a3b8', borderBottom: '1px solid rgba(255,255,255,0.1)' }}></th>
{packagings.map(p => (
<th key={p} style={{ padding: '8px', textAlign: 'center', color: '#94a3b8', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>{p}</th>
))}
</tr>
</thead>
<tbody>
{(() => {
//
const catAvg = categories.map(cat => {
const prices = []
packagings.forEach(pkg => {
const d = calcAvg(pkg, cat)
if (d) prices.push(d.avg)
})
const avg = prices.length > 0 ? Math.round(prices.reduce((a,b) => a+b, 0) / prices.length) : 0
return { cat, avg }
}).filter(c => c.avg > 0)
//
catAvg.sort((a, b) => a.avg - b.avg)
const categoryOrder = ['带4号', '带7号', '永恒号', '天马号', '天马王', '金山号', '金山王', '钻石号', '朦胧号', '朦胧王', '金马号', '金马王', '倒置号', '圆圆号']
const sortedCats = categoryOrder.filter(cat => catAvg.some(c => c.cat === cat))
.concat(catAvg.filter(c => !categoryOrder.includes(c.cat)).map(c => c.cat))
return sortedCats.map(cat => {
const rowData = packagings.map(pkg => calcAvg(pkg, cat))
const hasData = rowData.some(d => d !== null)
if (!hasData) return null
return (
<tr key={cat}>
<td style={{ padding: '8px', color: '#fbbf24', fontWeight: '500', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>{cat}</td>
{rowData.map((d, i) => (
<td key={i} style={{ padding: '8px', textAlign: 'center', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
{d ? (
<div style={{ color: '#22c55e', fontWeight: '600', cursor: 'pointer' }}
onClick={() => setDealDetailItems(d.items)}>
¥{d.avg.toLocaleString()}
<span style={{ color: '#64748b', fontSize: '11px', marginLeft: '4px' }}>({d.count})</span>
</div>
) : <span style={{ color: '#475569' }}>-</span>}
</td>
))}
</tr>
)
})
})()}
</tbody>
</table>
</div>
</div>
)
})()}
{/* 成交行情详情弹窗 */}
{dealDetailItems && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.8)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '20px'
}}>
<div style={{
background: '#1e293b', borderRadius: '12px', padding: '16px',
maxWidth: '600px', maxHeight: '80vh', overflow: 'auto', width: '100%'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<div style={{ color: '#fff', fontSize: '16px', fontWeight: '600' }}>成交详情列表</div>
<button onClick={() => setDealDetailItems(null)}
style={{ background: 'none', border: 'none', color: '#94a3b8', fontSize: '20px', cursor: 'pointer' }}></button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{([...dealDetailItems].sort((a, b) => (a.deal_price || 0) - (b.deal_price || 0))).map((item, idx) => {
const content = item.content || ''
const grade = content.includes('评级:') ? content.split('评级:')[1].split('\n')[0].trim() : (item.grading_score || '')
const sizeMatch = content.match(/大小号:\s*(.+?)(?:\n|$)/)
const size = sizeMatch ? sizeMatch[1].trim() : ''
const platformMatch = content.match(/平台:\s*(.+?)(?:\n|$)/)
const platform = platformMatch ? platformMatch[1].trim() : '-'
return (
<div key={idx} style={{
background: 'rgba(255,255,255,0.05)', borderRadius: '8px', padding: '12px',
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
}}>
<div>
<div style={{ color: '#fbbf24', fontWeight: '600', marginBottom: '4px' }}>
{(item.title || '').split('-')[0]}
{size && <span style={{ color: '#94a3b8', marginLeft: '8px' }}>| {size}</span>}
{grade && <span style={{ color: '#06b6d4', marginLeft: '8px' }}>| {grade}</span>}
</div>
<div style={{ color: '#94a3b8', fontSize: '12px' }}>
{item.deal_date} | {item.category} | {item.packaging} | {platform}
</div>
</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700' }}>
¥{item.deal_price?.toLocaleString()}
</div>
</div>
)
})}
</div>
</div>
</div>
)}
{infoList.map(item => {
// tab -
if (activeTab === 'deal') {
const content = item.content || ''
const categoryMatch = content.match(/分类:\s*(.+?)(?:\n|$)/)
const packagingMatch = content.match(/包装:\s*(.+?)(?:\n|$)/)
const platformMatch = content.match(/平台:\s*(.+?)(?:\n|$)/)
const sellerMatch = content.match(/出售者:\s*(.+?)(?:\n|$)/)
const dateMatch = content.match(/日期:\s*(.+?)(?:\n|$)/)
const gradeMatch = content.match(/评级:\s*(.+?)(?:\n|$)/)
const category = categoryMatch ? categoryMatch[1].trim() : item.category || '-'
const packaging = packagingMatch ? packagingMatch[1].trim() : item.packaging || '-'
const platform = platformMatch ? platformMatch[1].trim() : '-'
const seller = sellerMatch ? sellerMatch[1].trim() : '-'
const dealDate = dateMatch ? dateMatch[1].trim() : item.deal_date || '-'
const grade = gradeMatch ? gradeMatch[1].trim() : (item.grading_score || '')
const gradingCompany = item.grading_company || ''
const serial = (item.title || '').split('-')[0] || ''
let version = '其他'
if (serial.startsWith('J0')) version = '龙钞'
else if (serial.startsWith('J1')) version = '马钞'
else if (serial.startsWith('J3')) version = '蛇钞'
return (
<div key={item.id} style={{ borderBottom: '1px solid #334155', padding: '12px 0' }}>
{/* 标题行:冠字号 + 价格 */}
<div style={{ fontSize: '15px', fontWeight: '500', marginBottom: '6px' }}>
<span style={{ color: '#fbbf24' }}>{serial}</span>
<span style={{ color: '#22c55e', marginLeft: '12px' }}>¥{item.deal_price?.toLocaleString()}</span>
<span style={{ color: '#64748b', marginLeft: '12px', fontSize: '12px' }}>{item.deal_no}</span>
</div>
{/* 信息行:版别 | 包装 | 分类 | 评级 | 分数 | 平台 */}
<div style={{ fontSize: '12px', color: '#94a3b8' }}>
<span style={{ color: '#fff' }}>{version}</span>
<span style={{ margin: '0 8px' }}>|</span>
<span style={{ color: '#3b82f6' }}>{packaging}</span>
<span style={{ margin: '0 8px' }}>|</span>
<span style={{ color: '#fbbf24' }}>{category}</span>
{gradingCompany && <><span style={{ margin: '0 8px' }}>|</span><span>{gradingCompany}</span></>}
{grade && <><span style={{ margin: '0 8px' }}>|</span><span>{grade}</span></>}
<span style={{ margin: '0 8px' }}>|</span>
<span>{platform}</span>
<span style={{ margin: '0 8px' }}>|</span>
<span>{dealDate}</span>
</div>
</div>
)
}
// //
const content = item.content || '' const content = item.content || ''
const featuresMatch = content.match(/号码特征[:\s]*(.+?)(?:\n|$)/) const featuresMatch = content.match(/号码特征[:\s]*(.+?)(?:\n|$)/)
@ -527,8 +805,16 @@ export default function News() {
style={{ color: '#10b981', fontSize: '13px', fontWeight: 'bold', cursor: 'pointer' }} style={{ color: '#10b981', fontSize: '13px', fontWeight: 'bold', cursor: 'pointer' }}
onClick={() => fetchMatchCollections(item.id)} onClick={() => fetchMatchCollections(item.id)}
> >
配号结果: {item.matched_count || 0}条藏品匹配成功 自有{item.matched_count || 0}条藏品匹配成功
</span> </span>
{item.network_matched_count !== undefined && (
<span
style={{ color: '#3b82f6', fontSize: '13px', fontWeight: 'bold', cursor: 'pointer', marginLeft: '12px' }}
onClick={() => fetchNetworkMatchCollections(item.id)}
>
网络数据{item.network_matched_count}条匹配成功
</span>
)}
</div> </div>
)} )}
{/* 正文 - 默认收起,点击展开 */} {/* 正文 - 默认收起,点击展开 */}
@ -615,23 +901,24 @@ export default function News() {
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}> <div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<button <button
onClick={() => { onClick={() => {
if (item.is_matched === 'matched') return
if (item.matched_count > 0) { if (item.matched_count > 0) {
matchAndContact(item.id) matchAndContact(item.id)
} else { } else {
setCustomModal({show: true, title: '提示', content: '暂无匹配藏品,无法匹配'}) setCustomModal({show: true, title: '提示', content: '暂无匹配藏品,无法匹配'})
} }
}} }}
disabled={item.matched_count === 0} disabled={item.matched_count === 0 || item.is_matched === 'matched'}
style={{ style={{
flex: 1, flex: 1,
padding: '8px 12px', padding: '8px 12px',
borderRadius: '6px', borderRadius: '6px',
border: 'none', border: 'none',
fontSize: '12px', fontSize: '12px',
background: item.matched_count > 0 ? '#3b82f6' : '#4b5563', background: (item.matched_count > 0 && item.is_matched !== 'matched') ? '#3b82f6' : '#4b5563',
color: '#fff', color: '#fff',
cursor: item.matched_count > 0 ? 'pointer' : 'not-allowed', cursor: (item.matched_count > 0 && item.is_matched !== 'matched') ? 'pointer' : 'not-allowed',
opacity: item.matched_count > 0 ? 1 : 0.5 opacity: (item.matched_count > 0 && item.is_matched !== 'matched') ? 1 : 0.5
}} }}
> >
匹配并联系藏友 匹配并联系藏友
@ -753,6 +1040,74 @@ export default function News() {
</div> </div>
)} )}
{/* 网络数据匹配藏品列表弹窗 */}
{showNetworkMatchList && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '20px', width: '90%', maxHeight: '80vh', overflow: 'auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3 style={{ color: '#fff', margin: 0 }}>匹配藏品清单网络数据</h3>
<button onClick={() => setShowNetworkMatchList(false)} style={{ background: 'transparent', border: 'none', color: '#94a3b8', fontSize: '20px', cursor: 'pointer' }}>×</button>
</div>
{networkMatchCollections.length === 0 ? (
<div style={{ color: '#94a3b8', textAlign: 'center', padding: '20px' }}>暂无匹配藏品</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{networkMatchCollections.map(c => (
<div
key={c.id}
style={{ background: '#0f172a', borderRadius: '8px', padding: '12px', cursor: 'pointer' }}
onClick={() => {
if (c.post_url) {
window.open(c.post_url, '_blank')
}
setShowNetworkMatchList(false)
}}
>
<div style={{ color: '#e2e8f0', fontSize: '13px' }}>
<span style={{ color: '#94a3b8' }}>名称: </span>{c.name || '-'}
</div>
<div style={{ color: '#e2e8f0', fontSize: '13px', marginTop: '4px' }}>
<span style={{ color: '#94a3b8' }}>冠字号: </span>{c.crown_code}
</div>
{c.price && (
<div style={{ color: '#e2e8f0', fontSize: '13px', marginTop: '4px' }}>
<span style={{ color: '#94a3b8' }}>价格: </span>¥{c.price}
</div>
)}
<div style={{ color: '#3b82f6', fontSize: '12px', marginTop: '6px' }}>
点击查看原帖
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* 分页组件 */}
{infoList.length > 0 && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '8px', padding: '16px', marginTop: '8px' }}>
<button
onClick={() => { if (currentPage > 1) { setCurrentPage(currentPage - 1); fetchInfoList() } }}
disabled={currentPage === 1}
style={{ padding: '8px 16px', borderRadius: '6px', border: 'none', background: currentPage === 1 ? '#374151' : '#3b82f6', color: currentPage === 1 ? '#6b7280' : '#fff', cursor: currentPage === 1 ? 'not-allowed' : 'pointer', fontSize: '13px' }}
>
上一页
</button>
<span style={{ color: '#94a3b8', fontSize: '13px' }}>
{currentPage} / {totalPages}
</span>
<button
onClick={() => { if (currentPage < totalPages) { setCurrentPage(currentPage + 1); fetchInfoList() } }}
disabled={currentPage >= totalPages}
style={{ padding: '8px 16px', borderRadius: '6px', border: 'none', background: currentPage >= totalPages ? '#374151' : '#3b82f6', color: currentPage >= totalPages ? '#6b7280' : '#fff', cursor: currentPage >= totalPages ? 'not-allowed' : 'pointer', fontSize: '13px' }}
>
下一页
</button>
</div>
)}
{/* 我的寻号弹窗 */} {/* 我的寻号弹窗 */}
{showMySeeks && ( {showMySeeks && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>

View File

@ -0,0 +1,224 @@
/**
* 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>
)
}

View File

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

View File

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

View File

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

128
frontend/src/pages/news.py Normal file
View File

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

@ -0,0 +1,129 @@
/**
* 号码分类工具
* 根据冠字号自动分类为圆圆号倒置号金马王金马号金山王天马王金山号天马号朦胧号如意号钻石号永恒号带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

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

39
scripts/check_version.sh Executable file
View File

@ -0,0 +1,39 @@
#!/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 "========================================="

13
start.sh Executable file
View File

@ -0,0 +1,13 @@
#!/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后端服务已启动'