Compare commits

...

49 Commits
main ... new

Author SHA1 Message Date
甲辰生产 98b3917acc v0.2.7 - 新增龙钞宣传功能:分享海报+百科页面 2026-07-19 10:30:40 +08:00
甲辰生产 02711d73c9 v0.2.6 - 移除注册时的手机号和短信验证功能(C环境) 2026-06-13 11:15:12 +08:00
甲辰生产 645819844e v0.2.5 - 修复排序按created_at 2026-05-11 23:39:38 +08:00
甲辰生产 9b960c1d12 v0.2.4 - 修复弹窗排序 2026-05-11 23:37:28 +08:00
甲辰生产 b1d5398220 v0.2.4 - 修复排序(创建新数组+修复NULL日期) 2026-05-11 22:14:48 +08:00
甲辰生产 9b7f634ad3 v0.2.3 - 修复成交行情统计表格排序 2026-05-11 21:31:23 +08:00
甲辰生产 33ca624e70 v0.2.2 - 寻配号支持评级字段 2026-05-11 13:46:03 +08:00
甲辰生产 73075a1474 v0.2.0 - 新增认购群管理员审批功能
- 创建认购群需管理员审批(pending状态)
- 新增管理员审批API:pending-approval、approve
- 普通用户创建群状态设为pending
- 管理员创建直接active
- 加入群时检查pending/rejected状态
- 模型新增approved_by、approved_at字段
2026-05-02 13:50:21 +08:00
甲辰生产 bae5b6073a v0.1.9 - B环境修复:auth认证+seek统计+数据库字段+短信配置 2026-05-02 00:53:22 +08:00
甲辰生产 aa10dc4cc7 v0.1.8 - 更新VERSION.json和CHANGELOG 2026-05-01 21:11:49 +08:00
甲辰生产 87da78d6d8 v0.1.8 - 行情页新增成交行情筛选功能(版别、包装类型、号码分类) 2026-05-01 21:10:02 +08:00
甲辰生产 3eb8aac2be v0.1.6 - 成交行情页面增强:新增搜索+版别+包装+号码分类筛选 2026-05-01 10:00:17 +08:00
甲辰生产 f695dfbfa0 v0.1.2 - 创建群表单新增6个必填字段 2026-04-30 21:36:02 +08:00
甲辰生产 a31d6cc153 v0.1.2 - 认购功能完善(冠字号J0输入框、编辑弹窗、字段完整) 2026-04-30 00:39:55 +08:00
甲辰生产 5a5ec0ee2c v0.1.1 - 认购功能优化(已结束群灰色显示、标题显示) 2026-04-29 21:23:33 +08:00
甲辰生产 1fef5177f3 v0.1.0 - 认购功能完善版 2026-04-29 20:48:41 +08:00
甲辰生产 ee728a5f4e v0.0.10 - 认购功能初版 2026-04-26 21:13:00 +08:00
甲辰生产 d8aac9bc3e v0.0.4 - 首页更新
- 修复寻配号数据统计(API改为/api/seek/*)
- 修复token作用域问题
- 可匹配数据=藏品数+网络数据
2026-04-24 15:39:19 +08:00
甲辰生产 a2e6d328f0 v0.0.9 - 寻配号功能稳定版
- 自有匹配:正常计算
- 网络匹配:使用缓存方式(数据库字段)
- 加载速度:0.06秒

News.jsx: 0.0.7 → 0.0.8
seek.py: 0.0.8 → 0.0.9
2026-04-24 14:34:01 +08:00
甲辰生产 d1a245c340 feat: network_matched_count缓存机制优化
1. 创建寻配号时初始化network_matched_count
2. 列表接口从数据库读取,如为0则计算一次
3. 点击查看网络匹配时更新数据库

News.jsx: 0.0.6 → 0.0.7
seek.py: 0.0.7 → 0.0.8
seek_info.py: 添加network_matched_count字段
2026-04-24 14:10:14 +08:00
甲辰生产 3a2258d2d3 fix: 修复network_matched_count显示为0的问题
News.jsx: 0.0.5 → 0.0.6
seek.py: 0.0.6 → 0.0.7
2026-04-24 14:02:42 +08:00
甲辰生产 877f314617 fix: 修复寻配号两个问题
1. matched_count为0:添加match_collections_count计算
2. 留言功能失效:迁移到/api/seek/comment和/api/seek/comments/

News.jsx: 0.0.4 → 0.0.5
seek.py: 0.0.5 → 0.0.6
2026-04-24 13:52:10 +08:00
甲辰生产 c326d184f9 v0.0.4 - 更新文件版本号
News.jsx: 0.0.3 → 0.0.4 (留言时间显示)
seek.py: 0.0.4 → 0.0.5 (路由顺序修复)
2026-04-24 13:43:27 +08:00
甲辰生产 c17525c1e1 fix: 调整seek路由顺序,将/{seek_id}移到匹配API之后
问题:FastAPI路由匹配顺序问题,/{seek_id}在/my-match之前导致404
解决:把my-match/network-match等具体路由移到/{seek_id}之前
2026-04-24 13:30:26 +08:00
甲辰生产 875e40a79d v0.0.8 - 版本更新 2026-04-24 13:15:30 +08:00
socool 2dc0e6a1de 修复:更新config/VERSION为v0.0.7,前端构建时读取此文件更新版本号 2026-04-24 11:59:27 +08:00
socool bb8ae49ec3 修复:注释information.py中残留的评论函数(CommentRequest未定义) 2026-04-24 11:50:37 +08:00
socool 9d623821f6 v0.0.7 - 完善版本管理:更新文件头部和README
- seek.py: 0.0.3 → 0.0.4
- News.jsx: 0.0.2 → 0.0.3
- VERSION.json: seek=0.0.4, News=0.0.3, frontend config=0.0.7
- README.md: v1.2.38 → v0.0.7
2026-04-24 11:29:12 +08:00
socool 0b56cd7891 v0.0.7 - 版本更新:代码清理完成
- VERSION.json: 0.0.6 → 0.0.7
- CHANGELOG.md: 更新v0.0.7更新记录

主要更新:
1. coolbot_matcher.py - 一尘匹配工具模块
2. seek.py - 优化列表加载,添加缺失端点
3. information.py - 注释废弃端点
4. News.jsx - API迁移到seek
2026-04-24 11:23:18 +08:00
socool 7100f2d40e 清理:注释information.py中废弃的seek/deal重复端点
已废弃并注释的端点(统一使用 /api/seek/* 或 /api/deal/*):
- /information/seek/match → /api/seek/my-match
- /information/seek/network-match/{id} → /api/seek/network-match/{id}
- /information/my-seeks → /api/seek/list
- /information/seek/match-confirm → /api/seek/match-confirm
- /information/seek/matched-user/{id} → /api/seek/matched-user/{id}
- /information/seek/publisher/{id} → /api/seek/publisher/{id}
- /information/seek/stats → /api/seek/stats
- /information/deal/stats → /api/deal/stats

注意:评论相关端点保留(comment, comments),表为空但代码存在
2026-04-24 11:21:41 +08:00
socool 41b3353613 优化:列表接口移除网络匹配实时计算,提升加载速度
- seek.py /list 端点不再实时计算 network_matched_count
- 网络匹配仅在点击时通过 /network-match/{id} 单独查询
2026-04-24 11:20:17 +08:00
socool e6a0b4fb34 清理废弃代码:(1)创建coolbot_matcher.py工具模块 (2)更新seek.py添加缺失端点并导入新工具 (3)更新News.jsx迁移API到seek.py
Changes:
- backend/app/utils/coolbot_matcher.py: 新建一尘数据库匹配工具模块
- backend/app/routers/seek.py: 更新导入,添加match-confirm/matched-user/publisher端点
- frontend/src/pages/News.jsx: API调用从information迁移到seek

Note: Skill文档已单独更新到47.96.181.36
2026-04-24 11:14:56 +08:00
甲辰生产 ae87b10fe0 v0.0.6 - 修复seek/match API同时支持seek_info表 2026-04-24 10:49:39 +08:00
甲辰生产 3cd660eb13 修复:修正SeekInfo模块导入路径 2026-04-24 10:48:07 +08:00
甲辰生产 eea94ec381 修复:seek/match API同时支持information和seek_info表查询 2026-04-24 10:46:36 +08:00
甲辰生产 7d0f92b1eb 修复寻配号三个问题:1.限制冠字号最多8位 2.显示真实用户名 3.添加匹配数量计算 2026-04-24 10:20:42 +08:00
甲辰生产 d47a002495 v0.0.5 - 寻配号功能优化 2026-04-23 23:26:00 +08:00
甲辰生产 6667671f46 v0.0.4 - 修复寻配号和管理页面API 2026-04-21 01:17:44 +08:00
甲辰生产 c70a4f1221 v0.0.4 - 修复寻配号API,添加user_name字段 2026-04-20 21:42:52 +08:00
甲辰生产 65d5603f9a v0.0.3 - 快捷操作黑色背景,所有页面添加文件版本号显示 2026-04-20 10:04:22 +08:00
甲辰生产 5959d4c41a v0.0.3 - 一尘看板号码筛选功能完成 2026-04-20 01:40:17 +08:00
甲辰生产 0aab735feb v0.0.2 - 修复useEffect中添加numberFilter参数 2026-04-20 01:32:14 +08:00
甲辰生产 0b1643ae5b v0.0.2 - 修复分页统计显示,筛选后更新totalPosts 2026-04-20 01:09:14 +08:00
甲辰生产 3faac301a0 v0.0.2 - 修复分页按钮,添加numberFilter参数 2026-04-20 00:58:39 +08:00
甲辰生产 9c70250234 v0.0.2 - 修复Vite配置禁用优化,确保numberFilter代码被正确编译 2026-04-20 00:47:21 +08:00
甲辰生产 64776a6e14 v0.0.2 - 修复号码筛选功能 2026-04-20 00:38:25 +08:00
甲辰生产 45a0bd30d8 v0.0.2 - 一尘看板新增号码特色筛选按钮(带4、无4、无47等) 2026-04-19 23:52:57 +08:00
甲辰生产 dbddb10823 v0.0.1 - 添加CI/CD版本管理脚本 2026-04-19 23:37:08 +08:00
甲辰生产 0bd2d78697 v0.0.1 - 重置所有版本号为0.0.1 2026-04-19 22:22:45 +08:00
49 changed files with 5595 additions and 3858 deletions

View File

@ -6,9 +6,9 @@
| 项目 | 内容 |
|------|------|
| **版本** | v1.2.38 |
| **代号** | 最终优化版本 |
| **发布日期** | 2026-03-31 |
| **版本** | v0.0.7 |
| **代号** | 寻配号重构版本 |
| **发布日期** | 2026-04-24 |
## 技术栈

1
VERSION Normal file
View File

@ -0,0 +1 @@
1.2.98

48
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"
}
}
}
}

View File

@ -1 +1 @@
1.2.98
VERSION=v0.2.1

View File

@ -59,10 +59,14 @@ def decode_access_token(token: str) -> Optional[dict]:
def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(lambda: SessionLocal())
) -> Optional[User]:
"""获取当前用户可返回None"""
) -> User:
"""获取当前用户"""
if not credentials:
return None
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未登录",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
payload = decode_access_token(token)
@ -84,6 +88,10 @@ def get_current_user(
user = db.query(User).filter(User.f99_90_id == user_id).first()
if not user:
return None
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在",
headers={"WWW-Authenticate": "Bearer"},
)
return user

View File

@ -19,6 +19,7 @@ from app.routers import information as information_router
from app.routers import yichens as yichens_router
from app.routers import seek as seek_router
from app.routers import deal as deal_router
from app.routers import purchase as purchase_router
# 版本信息 - 从 config/VERSION 文件读取
def get_version():
@ -88,6 +89,7 @@ app.include_router(information_router.router)
app.include_router(yichens_router.router) # 一尘看板
app.include_router(seek_router.router) # 寻配号
app.include_router(deal_router.router) # 成交行情
app.include_router(purchase_router.router) # 认购群
@app.get("/")

View File

@ -0,0 +1,74 @@
# purchase - 认购群模型
# Version: 0.0.3 (2026-05-02)
# 认购群、成员、藏品管理
from sqlalchemy import Column, String, Text, Integer, DECIMAL, DateTime, ForeignKey, Boolean
from sqlalchemy.sql import func
from app.core.database import Base
import uuid
def generate_uuid():
return str(uuid.uuid4())
class PurchaseGroup(Base):
"""认购群表"""
__tablename__ = "purchase_group"
id = Column(String(36), primary_key=True, default=generate_uuid)
group_code = Column(String(20), nullable=False, unique=True) # 年+序号,如 26001
name = Column(String(100), nullable=False) # 认购群名称
description = Column(Text, nullable=True) # 描述
creator_id = Column(String(36), nullable=False) # 创建者ID
creator_name = Column(String(50), nullable=True) # 创建者名字
status = Column(String(20), default="pending") # pending/active/closed
approved_by = Column(String(36), nullable=True) # 审批管理员ID
approved_at = Column(DateTime(timezone=True), nullable=True) # 审批时间
total_members = Column(Integer, default=0) # 总参与人数
total_pending = Column(Integer, default=0) # 待审批人数
total_collections = Column(Integer, default=0) # 总藏品数
total_confirmed = Column(Integer, default=0) # 已确认数
total_amount = Column(DECIMAL(12,2), default=0) # 总金额
cycle_months = Column(Integer, nullable=True) # 认购周期(月)
start_date = Column(DateTime(timezone=True), nullable=True) # 开始日期
avg_price = Column(DECIMAL(10,2), default=0) # 均价
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
class PurchaseMember(Base):
"""认购成员表"""
__tablename__ = "purchase_member"
id = Column(String(36), primary_key=True, default=generate_uuid)
group_id = Column(String(36), ForeignKey("purchase_group.id"), nullable=False)
user_id = Column(String(36), nullable=False)
user_name = Column(String(50), nullable=True)
user_avatar = Column(String(255), nullable=True)
role = Column(String(20), default="member") # creator/admin/member
status = Column(String(20), default="pending") # pending/approved/rejected
# 成员扩展信息
wechat_name = Column(String(50), nullable=True) # 微信名
contact = Column(String(50), nullable=True) # 联系方式
purchase_count = Column(Integer, default=0) # 认购数量
paid_amount = Column(DECIMAL(12,2), default=0) # 已付认购款
notes = Column(Text, nullable=True) # 备注
joined_at = Column(DateTime(timezone=True), server_default=func.now())
class PurchaseCollection(Base):
"""认购藏品表"""
__tablename__ = "purchase_collection"
id = Column(String(36), primary_key=True, default=generate_uuid)
group_id = Column(String(36), ForeignKey("purchase_group.id"), nullable=False)
user_id = Column(String(36), nullable=False)
user_name = Column(String(50), nullable=True)
collection_name = Column(String(100), nullable=True) # 藏品名称
collection_code = Column(String(50), nullable=True) # 藏品编号 (自动生成001开始)
number = Column(String(50), nullable=False) # 冠字号 (必填)
grading = Column(String(50), nullable=False) # 评级 (必填)
boss_name = Column(String(50), nullable=False) # 认购老板 (必填,从成员中选择)
price = Column(DECIMAL(10,2), nullable=False) # 认购价格 (必填)
source = Column(String(20), nullable=False) # 来源: yichen/live/friend/other (必填)
paid = Column(Boolean, default=False) # 认购款是否结清
submit_date = Column(DateTime(timezone=True), server_default=func.now()) # 提交日期
status = Column(String(20), default="pending") # pending/confirmed
submitted_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@ -24,6 +24,12 @@ class SeekInfo(Base):
expect_price_min = Column(Float, nullable=True) # 期望最低价
expect_price_max = Column(Float, nullable=True) # 期望最高价
# 评级字段
number = Column(String(50), nullable=True) # 号码
is_graded = Column(Boolean, default=False) # 是否评级
grading_company = Column(String(100), nullable=True) # 评级公司
grading_score = Column(String(20), nullable=True) # 评级分数
# 匹配状态
status = Column(String(20), default="active") # active/closed/expired
is_matched = Column(String(10), default="false") # 是否已匹配
@ -33,6 +39,7 @@ class SeekInfo(Base):
# 统计
view_count = Column(Integer, default=0)
contact_count = Column(Integer, default=0)
network_matched_count = Column(Integer, default=0) # 网络匹配数量(缓存)
# 时间
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@ -1,5 +1,5 @@
# auth - 认证路由
# Version: 1.2.90
# Version: 0.0.1
# 更新:
from fastapi import APIRouter
@ -91,21 +91,7 @@ def register(user_data: UserCreate, db: Session = Depends(get_db)):
# 更新:
# 更新:
# 检查手机号是否已存在
# 更新:
if user_data.phone:
# 更新:
existing_phone = db.query(User).filter(User.phone == user_data.phone).first()
# 更新:
if existing_phone:
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_400_BAD_REQUEST,
# 更新:
detail="E00040:该手机号已被注册,请更换手机号"
# 更新:
)
# 检查手机号是否已存在移除2026-06-13
# 更新:
if user_data.email:
# 更新:

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
# deal - 成交行情路由
# Version: 1.2.85
# Version: 0.0.1
# 更新:
from fastapi import APIRouter, Depends, Query, HTTPException
@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
# 更新:
from pydantic import BaseModel
# 更新:
from typing import Optional
from typing import Optional, List
# 更新:
from datetime import datetime, date
# 更新:
@ -176,7 +176,7 @@ def generate_deal_no(db: Session):
# 更新:
# ============ API ============
# 更新:
@router.get("/list", response_model=list[DealInfoResponse])
@router.get("/list", response_model=List[DealInfoResponse])
# 更新:
def get_deal_list(
# 更新:

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
# news - 新闻路由
# Version: 1.2.80
# Version: 0.0.1
# 更新:
from fastapi import APIRouter, Depends, HTTPException, Query

View File

@ -1,5 +1,5 @@
# ocr - OCR识别路由
# Version: 1.2.75
# Version: 0.0.1
# 更新:
import os

View File

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

View File

@ -0,0 +1,759 @@
# purchase - 认购群API路由
# Version: 0.3.0 (2026-05-02)
# 新增:管理员审批功能
# 认购群、成员、藏品管理
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.auth import get_current_user
from app.models.models import User
from app.models.purchase import PurchaseGroup, PurchaseMember, PurchaseCollection
router = APIRouter(prefix="/api/purchase", tags=["认购"])
# ============ Schema ============
class PurchaseGroupCreate(BaseModel):
name: str
description: str
total_members: int
total_collections: int
cycle_months: int
start_date: str
class PurchaseGroupResponse(BaseModel):
cycle_months: Optional[int] = None
start_date: Optional[datetime] = None
id: str
group_code: str
name: str
description: Optional[str]
creator_id: str
creator_name: Optional[str]
status: str
total_members: int
total_pending: int
total_collections: int
total_confirmed: int
total_amount: float
avg_price: Optional[float] = None
created_at: Optional[datetime]
class PurchaseCollectionCreate(BaseModel):
collection_name: Optional[str] = None
collection_code: Optional[str] = None
number: Optional[str] = None
collection_code: Optional[str] = None
grading: Optional[str] = None
boss_name: Optional[str] = None
price: Optional[float] = None
source: Optional[str] = None
paid: Optional[bool] = False
class PurchaseCollectionUpdate(BaseModel):
collection_name: Optional[str] = None
number: Optional[str] = None
grading: Optional[str] = None
boss_name: Optional[str] = None
price: Optional[float] = None
source: Optional[str] = None
paid: Optional[bool] = None
status: Optional[str] = None
class PurchaseCollectionResponse(BaseModel):
id: str
group_id: str
user_id: str
user_name: Optional[str]
collection_name: Optional[str]
collection_code: Optional[str]
number: Optional[str] = None
collection_code: Optional[str] = None
grading: Optional[str] = None
boss_name: Optional[str] = None
price: Optional[float] = None
source: Optional[str] = None
paid: bool
status: str
submit_date: Optional[datetime]
submitted_at: Optional[datetime]
class MemberResponse(BaseModel):
id: str
user_id: str
user_name: Optional[str]
user_avatar: Optional[str]
role: str
status: str
joined_at: Optional[datetime]
wechat_name: Optional[str] = None
contact: Optional[str] = None
purchase_count: Optional[int] = 0
paid_amount: Optional[float] = 0
notes: Optional[str] = None
# ============ 工具函数 ============
def generate_group_code(db: Session) -> str:
"""生成群编码:年+序号,如 26001"""
year = datetime.now().year % 100 # 26
# 查找当前年份最大的编号
last = db.query(PurchaseGroup).filter(
PurchaseGroup.group_code.like(f"{year}%")
).order_by(PurchaseGroup.group_code.desc()).first()
if last and last.group_code:
try:
num = int(last.group_code) + 1
except:
num = 1
else:
num = 1
return f"{year}{num:03d}" # 26001
# ============ 认购群API ============
@router.get("/groups", response_model=List[PurchaseGroupResponse])
def get_groups(
status: str = Query("active", description="过滤状态"),
db: Session = Depends(get_db)
):
"""获取所有认购群列表"""
query = db.query(PurchaseGroup)
if status:
query = query.filter(PurchaseGroup.status == status)
groups = query.order_by(PurchaseGroup.created_at.desc()).all()
return groups
@router.get("/my-groups", response_model=List[PurchaseGroupResponse])
def get_my_groups(
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取我参与的认购群(仅已批准的)"""
if not current_user:
return []
member_groups = db.query(PurchaseMember).filter(
PurchaseMember.user_id == current_user.f99_90_id,
PurchaseMember.status == "approved"
).all()
group_ids = [m.group_id for m in member_groups]
if not group_ids:
return []
groups = db.query(PurchaseGroup).filter(
PurchaseGroup.id.in_(group_ids)
).all()
return groups
@router.get("/groups/{group_id}/pending-members", response_model=List[MemberResponse])
def get_pending_members(
group_id: str,
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取待审批成员列表"""
group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="认购群不存在")
if group.creator_id != current_user.f99_90_id:
raise HTTPException(status_code=403, detail="只有群主可以查看")
members = db.query(PurchaseMember).filter(
PurchaseMember.group_id == group_id,
PurchaseMember.status == "pending"
).order_by(PurchaseMember.joined_at.asc()).all()
return members
@router.post("/groups/{group_id}/members/{member_id}/approve")
def approve_member(
group_id: str,
member_id: str,
action: str = Query(..., description="approve/reject"),
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""审批或拒绝成员"""
group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="认购群不存在")
if group.creator_id != current_user.f99_90_id:
raise HTTPException(status_code=403, detail="只有群主可以审批")
member = db.query(PurchaseMember).filter(
PurchaseMember.id == member_id,
PurchaseMember.group_id == group_id
).first()
if not member:
raise HTTPException(status_code=404, detail="成员不存在")
if action == "approve":
member.status = "approved"
group.total_members += 1
group.total_pending -= 1
db.commit()
return {"message": "已批准加入"}
elif action == "reject":
member.status = "rejected"
group.total_pending -= 1
db.commit()
return {"message": "已拒绝加入"}
else:
raise HTTPException(status_code=400, detail="无效操作")
@router.post("/groups", response_model=PurchaseGroupResponse)
def create_group(
data: PurchaseGroupCreate,
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""创建认购群"""
if not current_user:
raise HTTPException(status_code=401, detail="请先登录")
# 普通用户最多创建3个认购群
if current_user.role not in ["admin", "superadmin"]:
existing_count = db.query(PurchaseGroup).filter(
PurchaseGroup.creator_id == current_user.f99_90_id
).count()
if existing_count >= 3:
raise HTTPException(status_code=400, detail="普通用户最多创建3个认购群")
group_code = generate_group_code(db)
# 处理日期
start_date = None
if data.start_date:
try:
start_date = datetime.fromisoformat(data.start_date.replace('Z', '+08:00'))
except:
start_date = datetime.now()
# 管理员直接创建通过审批
if current_user.role in ["admin", "superadmin"]:
initial_status = "active"
else:
# 普通用户创建需要审批
initial_status = "pending"
group = PurchaseGroup(
group_code=group_code,
name=data.name,
description=data.description,
creator_id=current_user.f99_90_id,
creator_name=current_user.f01_01_name,
status=initial_status,
total_members=data.total_members,
total_collections=data.total_collections,
cycle_months=data.cycle_months,
start_date=start_date
)
db.add(group)
db.commit()
# 创建者直接成为成员
member = PurchaseMember(
group_id=group.id,
user_id=current_user.f99_90_id,
user_name=current_user.f01_01_name,
user_avatar=current_user.avatar,
role="creator",
status="approved"
)
db.add(member)
group.total_members = 1
db.commit()
db.refresh(group)
return group
@router.get("/groups/{group_id}", response_model=PurchaseGroupResponse)
def get_group(group_id: str, db: Session = Depends(get_db)):
"""获取认购群详情"""
group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="认购群不存在")
return group
@router.post("/groups/{group_id}/join")
def join_group(
group_id: str,
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""申请加入认购群"""
if not current_user:
raise HTTPException(status_code=401, detail="请先登录")
group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="认购群不存在")
if group.status == "pending":
raise HTTPException(status_code=400, detail="该认购群正在等待审批,请耐心等待")
if group.status == "rejected":
raise HTTPException(status_code=400, detail="该认购群申请已被拒绝")
if group.status != "active":
raise HTTPException(status_code=400, detail="该认购群已结束")
existing = db.query(PurchaseMember).filter(
PurchaseMember.group_id == group_id,
PurchaseMember.user_id == current_user.f99_90_id
).first()
if existing:
if existing.status == "approved":
raise HTTPException(status_code=400, detail="您已加入该认购群")
else:
# pending/rejected/其他状态重新激活为pending
existing.status = "pending"
existing.joined_at = datetime.now()
existing.user_name = current_user.f01_01_name
group.total_pending += 1
db.commit()
return {"message": "申请成功,等待审批"}
member = PurchaseMember(
group_id=group_id,
user_id=current_user.f99_90_id,
user_name=current_user.f01_01_name,
user_avatar=current_user.avatar,
role="member",
status="pending"
)
db.add(member)
group.total_pending += 1
db.commit()
return {"message": "申请成功,等待群主审批"}
@router.get("/groups/{group_id}/members", response_model=List[MemberResponse])
def get_group_members(
group_id: str,
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取认购群成员列表"""
members = db.query(PurchaseMember).filter(
PurchaseMember.group_id == group_id,
PurchaseMember.status == "approved"
).order_by(PurchaseMember.joined_at.asc()).all()
return [
{
"id": m.id,
"user_id": m.user_id,
"user_name": m.user_name,
"user_avatar": m.user_avatar,
"role": m.role,
"status": m.status,
"joined_at": m.joined_at,
"wechat_name": m.wechat_name,
"contact": m.contact,
"purchase_count": m.purchase_count or 0,
"paid_amount": float(m.paid_amount or 0),
"notes": m.notes
}
for m in members
]
@router.get("/groups/{group_id}/collections", response_model=List[PurchaseCollectionResponse])
def get_group_collections(group_id: str, db: Session = Depends(get_db)):
"""获取认购群藏品列表"""
collections = db.query(PurchaseCollection).filter(
PurchaseCollection.group_id == group_id
).order_by(PurchaseCollection.submitted_at.desc()).all()
return [
{
"id": c.id,
"group_id": c.group_id,
"user_id": c.user_id,
"user_name": c.user_name,
"collection_name": c.collection_name,
"collection_code": c.collection_code,
"number": c.number,
"grading": c.grading,
"boss_name": c.boss_name,
"price": float(c.price),
"source": c.source,
"paid": c.paid,
"status": c.status,
"submit_date": c.submit_date,
"submitted_at": c.submitted_at
}
for c in collections
]
@router.post("/groups/{group_id}/collections", response_model=PurchaseCollectionResponse)
def add_collection(
group_id: str,
data: PurchaseCollectionCreate,
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""提交认购藏品"""
if not current_user:
raise HTTPException(status_code=401, detail="请先登录")
# All fields now optional - frontend will send what it has
# No validation needed as all fields are Optional in schema
group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="认购群不存在")
member = db.query(PurchaseMember).filter(
PurchaseMember.group_id == group_id,
PurchaseMember.user_id == current_user.f99_90_id,
PurchaseMember.status == "approved"
).first()
if not member:
raise HTTPException(status_code=400, detail="请先加入该认购群并通过审批")
max_code = db.query(PurchaseCollection).filter(
PurchaseCollection.group_id == group_id
).order_by(PurchaseCollection.collection_code.desc()).first()
if max_code and max_code.collection_code:
try:
next_num = int(max_code.collection_code) + 1
except:
next_num = 1
else:
next_num = 1
collection_code = f"{next_num:03d}"
collection = PurchaseCollection(
group_id=group_id,
user_id=current_user.f99_90_id,
user_name=current_user.f01_01_name,
collection_name=data.collection_name,
collection_code=collection_code,
number=data.number,
grading=data.grading,
boss_name=data.boss_name,
price=data.price,
source=data.source,
paid=data.paid,
status="pending"
)
db.add(collection)
group.total_collections += 1
from decimal import Decimal
group.total_amount += Decimal(str(data.price))
if group.total_collections > 0:
group.avg_price = group.total_amount / group.total_collections
db.commit()
db.refresh(collection)
return collection
@router.put("/groups/{group_id}/collections/{collection_id}", response_model=PurchaseCollectionResponse)
def update_collection(
group_id: str,
collection_id: str,
data: PurchaseCollectionUpdate,
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""编辑认购藏品 (仅群主可操作)"""
if not current_user:
raise HTTPException(status_code=401, detail="请先登录")
group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="认购群不存在")
if group.creator_id != current_user.f99_90_id:
raise HTTPException(status_code=403, detail="只有群主可以编辑")
collection = db.query(PurchaseCollection).filter(
PurchaseCollection.id == collection_id,
PurchaseCollection.group_id == group_id
).first()
if not collection:
raise HTTPException(status_code=404, detail="藏品不存在")
old_price = float(collection.price)
# 更新字段
if data.collection_name is not None:
collection.collection_name = data.collection_name
if data.number is not None:
collection.number = data.number
if data.grading is not None:
collection.grading = data.grading
if data.boss_name is not None:
collection.boss_name = data.boss_name
if data.source is not None:
collection.source = data.source
if data.paid is not None:
collection.paid = data.paid
# 处理状态变化
new_status = data.status
if new_status and new_status != collection.status:
if new_status == "confirmed" and collection.status != "confirmed":
group.total_confirmed += 1
elif collection.status == "confirmed" and new_status != "confirmed":
group.total_confirmed -= 1
collection.status = new_status
# 处理价格变化
if data.price is not None and data.price != old_price:
from decimal import Decimal
group.total_amount = group.total_amount - Decimal(str(old_price)) + Decimal(str(data.price))
if group.total_collections > 0:
group.avg_price = group.total_amount / group.total_collections
db.commit()
db.refresh(collection)
return collection
@router.post("/groups/{group_id}/close")
def close_group(
group_id: str,
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""结束认购群"""
if not current_user:
raise HTTPException(status_code=401, detail="请先登录")
group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="认购群不存在")
if group.creator_id != current_user.f99_90_id:
raise HTTPException(status_code=403, detail="只有群主可以结束认购群")
group.status = "closed"
db.commit()
return {"message": "认购群已结束"}
@router.delete("/groups/{group_id}")
def delete_group(
group_id: str,
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""删除认购群 (仅管理员可操作)"""
if not current_user:
raise HTTPException(status_code=401, detail="请先登录")
# 管理员权限检查
if current_user.role not in ["admin", "superadmin"]:
raise HTTPException(status_code=403, detail="只有管理员可以删除认购群")
group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="认购群不存在")
# 删除相关成员和藏品
db.query(PurchaseMember).filter(PurchaseMember.group_id == group_id).delete()
db.query(PurchaseCollection).filter(PurchaseCollection.group_id == group_id).delete()
db.delete(group)
db.commit()
return {"message": "认购群已删除"}
class MemberUpdate(BaseModel):
user_name: Optional[str] = None
wechat_name: Optional[str] = None
contact: Optional[str] = None
purchase_count: Optional[int] = None
paid_amount: Optional[float] = None
notes: Optional[str] = None
class GroupUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
cycle_months: Optional[int] = None
start_date: Optional[str] = None
# 更新成员信息
@router.put("/groups/{group_id}/members/{member_id}")
def update_member(
group_id: str,
member_id: str,
data: MemberUpdate,
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""更新成员信息 (成员自己或群主可编辑)"""
if not current_user:
raise HTTPException(status_code=401, detail="请先登录")
group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="认购群不存在")
member = db.query(PurchaseMember).filter(
PurchaseMember.id == member_id,
PurchaseMember.group_id == group_id
).first()
if not member:
raise HTTPException(status_code=404, detail="成员不存在")
# 只有成员自己或群主可以修改
if member.user_id != current_user.f99_90_id and group.creator_id != current_user.f99_90_id:
raise HTTPException(status_code=403, detail="无权修改")
if data.user_name is not None:
member.user_name = data.user_name
if data.wechat_name is not None:
member.wechat_name = data.wechat_name
if data.contact is not None:
member.contact = data.contact
if data.purchase_count is not None:
member.purchase_count = data.purchase_count
if data.paid_amount is not None:
from decimal import Decimal
member.paid_amount = Decimal(str(data.paid_amount))
if data.notes is not None:
member.notes = data.notes
db.commit()
db.refresh(member)
return {"message": "更新成功", "member_id": member.id}
# 更新群信息
@router.put("/groups/{group_id}", response_model=PurchaseGroupResponse)
def update_group(
group_id: str,
data: GroupUpdate,
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""更新群信息 (仅群主可编辑)"""
if not current_user:
raise HTTPException(status_code=401, detail="请先登录")
group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="认购群不存在")
if group.creator_id != current_user.f99_90_id:
raise HTTPException(status_code=403, detail="只有群主可以修改")
if data.name is not None:
group.name = data.name
if data.description is not None:
group.description = data.description
if data.cycle_months is not None:
group.cycle_months = data.cycle_months
if data.start_date is not None:
try:
group.start_date = datetime.fromisoformat(data.start_date.replace('Z', '+08:00'))
except:
pass
db.commit()
db.refresh(group)
return group
# ============ 管理员审批API ============
class GroupApprovalResponse(BaseModel):
id: str
group_code: str
name: str
description: Optional[str]
creator_id: str
creator_name: Optional[str]
status: str
total_members: int
total_collections: int
created_at: Optional[datetime]
@router.get("/pending-approval", response_model=List[GroupApprovalResponse])
def get_pending_approval_groups(
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取待审批的认购群列表(仅管理员可见)"""
if not current_user:
raise HTTPException(status_code=401, detail="请先登录")
if current_user.role not in ["admin", "superadmin"]:
raise HTTPException(status_code=403, detail="只有管理员可以查看待审批列表")
groups = db.query(PurchaseGroup).filter(
PurchaseGroup.status == "pending"
).order_by(PurchaseGroup.created_at.asc()).all()
return groups
@router.post("/groups/{group_id}/approve")
def approve_group(
group_id: str,
action: str = Query(..., description="approve/reject"),
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""管理员审批或拒绝认购群"""
if not current_user:
raise HTTPException(status_code=401, detail="请先登录")
if current_user.role not in ["admin", "superadmin"]:
raise HTTPException(status_code=403, detail="只有管理员可以审批认购群")
group = db.query(PurchaseGroup).filter(PurchaseGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="认购群不存在")
if group.status != "pending":
raise HTTPException(status_code=400, detail="该认购群不在待审批状态")
if action == "approve":
group.status = "active"
group.approved_by = current_user.f99_90_id
group.approved_at = datetime.now()
db.commit()
return {"message": "已批准该认购群", "status": "active"}
elif action == "reject":
group.status = "rejected"
group.approved_by = current_user.f99_90_id
group.approved_at = datetime.now()
db.commit()
return {"message": "已拒绝该认购群", "status": "rejected"}
else:
raise HTTPException(status_code=400, detail="无效操作")
@router.get("/my-pending-groups", response_model=List[PurchaseGroupResponse])
def get_my_pending_groups(
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取我创建的待审批认购群"""
if not current_user:
return []
groups = db.query(PurchaseGroup).filter(
PurchaseGroup.creator_id == current_user.f99_90_id,
PurchaseGroup.status == "pending"
).all()
return groups

View File

@ -1,384 +1,516 @@
# seek - 寻匹配路由
# Version: 1.2.70
# 更新:
# seek - 寻号路由
# Version: 0.2.0 (2026-05-11)
# 稳定版支持评级字段number, is_graded, grading_company, grading_score
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 typing import Optional, List
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"
# 更新:
from app.models.models import User, Collection
from app.routers.information import match_collections_count, match_collections_count_from_coolbot, match_pattern
from app.utils.coolbot_matcher import (
match_self_collections_count,
match_collections_count_from_coolbot,
match_collections_list_from_coolbot
)
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
# 评级字段
number: Optional[str] = None
is_graded: Optional[bool] = False
grading_company: Optional[str] = None
grading_score: Optional[str] = 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
# 评级字段
number: Optional[str] = None
is_graded: Optional[bool] = False
grading_company: Optional[str] = None
grading_score: 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]
user_name: Optional[str] = None # 发布者用户名
matched_count: Optional[int] = 0 # 自有匹配数量
network_matched_count: Optional[int] = 0 # 网络匹配数量
# 评级字段
number: Optional[str] = None
is_graded: Optional[bool] = False
grading_company: Optional[str] = None
grading_score: Optional[str] = None
class Config:
from_attributes = True
class MatchConfirmRequest(BaseModel):
info_id: str
contact: Optional[str] = None
collection_id: Optional[str] = None
# ============ 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()
result = []
for item in items:
user = db.query(User).filter(User.f99_90_id == item.user_id).first()
user_name = user.f01_01_name if user else '匿名用户'
# 构建响应(不实时计算网络匹配,网络匹配在点击时再查)
# 网络匹配数量从数据库读取不再实时计算初始为0
network_matched_count = 0
if hasattr(item, 'network_matched_count') and item.network_matched_count:
network_matched_count = item.network_matched_count
# 自有匹配数量使用缓存方式如为0则计算一次
matched_count = 0
if item.expect_number and len(item.expect_number) == 10:
if current_user and current_user.f99_90_id:
matched_count = match_collections_count(db, current_user.f99_90_id, item.expect_number)
result.append(SeekInfoResponse(
id=item.id,
user_id=item.user_id,
title=item.title,
content=item.content,
expect_category=item.expect_category,
expect_version=item.expect_version,
expect_packaging=item.expect_packaging,
expect_number=item.expect_number,
expect_price_min=item.expect_price_min,
expect_price_max=item.expect_price_max,
status=item.status,
is_matched=item.is_matched,
matched_user_id=item.matched_user_id,
matched_contact=item.matched_contact,
view_count=item.view_count,
contact_count=item.contact_count,
created_at=item.created_at,
updated_at=item.updated_at,
user_name=user_name,
matched_count=matched_count,
network_matched_count=network_matched_count
))
return result
@router.get("/stats")
def get_seek_stats(
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="请先登录")
# 计算网络匹配数量(创建时初始化)
network_matched_count = 0
if data.expect_number and len(data.expect_number) == 10:
network_matched_count = match_collections_count_from_coolbot(data.expect_number)
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",
view_count=0,
contact_count=0,
network_matched_count=network_matched_count, # 保存初始化值
# 评级字段
number=data.number,
is_graded=data.is_graded,
grading_company=data.grading_company,
grading_score=data.grading_score
)
# 更新:
db.add(seek)
# 更新:
db.commit()
# 更新:
db.refresh(seek)
# 更新:
return seek
# 更新:
# 更新:
# ============ 匹配相关API ============
@router.get("/my-match")
def get_seek_match(
info_id: str,
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取符合条件的我的藏品推荐"""
if not current_user:
raise HTTPException(status_code=401, detail="请先登录")
info = db.query(SeekInfo).filter(SeekInfo.id == info_id).first()
if not info:
raise HTTPException(status_code=404, detail="寻配号信息不存在")
# 获取用户所有藏品
collections = db.query(Collection).filter(
Collection.f99_91_user_id == current_user.f99_90_id,
Collection.f01_04_status == "in_collection"
).all()
# 按号码特征模式匹配
matched = []
if info.expect_number and len(info.expect_number) == 10:
pattern = info.expect_number[2:]
for c in collections:
number = c.f02_10_prefix_serial or ''
if len(number) >= 10 and number.startswith('J0'):
col_pattern = number[2:10]
if match_pattern(col_pattern, pattern):
matched.append(c)
elif len(number) >= 8:
col_pattern = number[:8]
if match_pattern(col_pattern, pattern):
matched.append(c)
else:
matched = collections
return {
"matched_count": len(matched),
"collections": [
{"id": c.f99_90_id, "code": c.f01_02_code or '', "name": c.f01_01_name,
"number": c.f02_10_prefix_serial, "status": c.f01_04_status}
for c in matched[:20]
]
}
@router.get("/network-match/{info_id}")
def get_network_match(
info_id: str,
limit: int = Query(100, ge=1, le=500),
db: Session = Depends(get_db)
):
"""获取一尘数据库中匹配的藏品列表点击时更新network_matched_count"""
info = db.query(SeekInfo).filter(SeekInfo.id == info_id).first()
if not info:
raise HTTPException(status_code=404, detail="寻配号信息不存在")
if not info.expect_number:
return {"matched_count": 0, "collections": []}
# 获取匹配列表
matched = match_collections_list_from_coolbot(info.expect_number, limit=limit)
actual_count = len(matched)
# 点击查看时更新network_matched_count
info.network_matched_count = actual_count
db.commit()
return {"matched_count": actual_count, "collections": matched}
@router.post("/match-confirm")
def match_seek_confirm(
request: MatchConfirmRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""确认匹配寻号 - 用户愿意交换联系方式给发布者"""
info = db.query(SeekInfo).filter(
SeekInfo.id == request.info_id,
SeekInfo.status == "active"
).first()
if not info:
raise HTTPException(status_code=404, detail="寻配号信息不存在")
# 检查是否已被匹配(一个寻号只能被一个用户匹配)
if info.is_matched == "matched":
raise HTTPException(status_code=400, detail="该寻号已被其他用户匹配")
# 检查是否是自己发布的
if info.user_id == current_user.f99_90_id:
raise HTTPException(status_code=400, detail="不能匹配自己发布的寻号")
# 更新匹配状态
info.is_matched = "matched"
info.matched_user_id = current_user.f99_90_id
info.matched_contact = request.contact or ''
# 更新发布寻号者的内容,显示有藏品被匹配
original_content = info.content or ""
match_info = f"\n藏品被{current_user.f01_01_name}匹配成功!联系方式:{request.collection_id or '已交换'}"
info.content = original_content + match_info
db.commit()
return {"message": "匹配成功,已通知发布者", "is_matched": "matched"}
@router.get("/matched-user/{info_id}")
def get_matched_user(
info_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取寻号的匹配者信息(仅发布者可见)"""
info = db.query(SeekInfo).filter(SeekInfo.id == info_id).first()
if not info:
raise HTTPException(status_code=404, detail="寻配号信息不存在")
# 只有发布者可以看到匹配者信息
if info.user_id != current_user.f99_90_id:
raise HTTPException(status_code=403, detail="无权访问")
if not info.matched_user_id:
return {"message": "暂无匹配者"}
# 获取匹配者信息
matched_user = db.query(User).filter(User.f99_90_id == info.matched_user_id).first()
if not matched_user:
return {"message": "匹配者不存在"}
return {
"matched_user_id": info.matched_user_id,
"user_name": matched_user.f01_01_name,
"phone": matched_user.phone,
"matched_contact": info.matched_contact,
}
@router.get("/publisher/{info_id}")
def get_publisher_info(
info_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取寻号的发布者信息(仅匹配者可见)"""
info = db.query(SeekInfo).filter(SeekInfo.id == info_id).first()
if not info:
raise HTTPException(status_code=404, detail="寻配号信息不存在")
# 只有匹配者可以看到发布者信息
if not info.matched_user_id or info.matched_user_id != current_user.f99_90_id:
raise HTTPException(status_code=403, detail="无权访问")
# 获取发布者信息
publisher = db.query(User).filter(User.f99_90_id == info.user_id).first()
if not publisher:
return {"message": "发布者不存在"}
# 从content中解析联系方式
contact = ''
if info.content:
import re
match = re.search(r'联系方式[:\s]*(.+?)(?:\n|$)', info.content)
if match:
contact = match.group(1).strip()
return {
"user_id": info.user_id,
"user_name": publisher.f01_01_name,
"phone": publisher.phone,
"contact": contact,
}
# ============ 通配路由(必须放最后,避免匹配冲突)============
@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()
# 更新:
# 更新:
seek = db.query(SeekInfo).filter(SeekInfo.id == seek_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)
# 更新:
# 更新:
if seek.user_id != current_user.f99_90_id:
raise HTTPException(status_code=403, detail="无权限")
if data.title is not None:
seek.title = data.title
if data.content is not None:
seek.content = data.content
if data.expect_category is not None:
seek.expect_category = data.expect_category
if data.expect_version is not None:
seek.expect_version = data.expect_version
if data.expect_packaging is not None:
seek.expect_packaging = data.expect_packaging
if data.status is not None:
seek.status = data.status
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()
# 更新:
# 更新:
seek = db.query(SeekInfo).filter(SeekInfo.id == seek_id).first()
if not seek:
# 更新:
raise HTTPException(status_code=404, detail="寻配号不存在")
# 更新:
# 更新:
if seek.user_id != current_user.f99_90_id:
raise HTTPException(status_code=403, detail="无权限")
seek.status = "deleted"
# 更新:
db.commit()
# 更新:
# 更新:
return {"message": "删除成功"}
# 更新:
# ============ 留言功能从information.py迁移============
from pydantic import BaseModel
class CommentRequest(BaseModel):
information_id: str
content: str
@router.post("/comment")
def add_comment(
request: CommentRequest,
current_user = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""添加留言 - 支持seek_info表"""
if not current_user:
raise HTTPException(status_code=401, detail="请先登录")
# 查询seek_info表
info = db.query(SeekInfo).filter(SeekInfo.id == request.information_id).first()
if not info:
raise HTTPException(status_code=404, detail="寻配号不存在")
# 创建留言
from app.models.models import InformationComment
comment = InformationComment(
information_id=request.information_id,
user_id=current_user.f99_90_id,
content=request.content
)
db.add(comment)
db.commit()
return {
"message": "留言成功",
"comment": {
"id": comment.id,
"content": comment.content,
"user_name": current_user.f01_01_name,
"user_avatar": current_user.avatar,
"created_at": comment.created_at
}
}
@router.get("/comments/{information_id}")
def get_comments(
information_id: str,
db: Session = Depends(get_db)
):
"""获取寻配号的评论列表"""
from app.models.models import InformationComment
comments = db.query(InformationComment).filter(
InformationComment.information_id == information_id
).order_by(InformationComment.created_at.desc()).all()
return [
{
"id": c.id,
"content": c.content,
"user_name": c.user.f01_01_name if c.user else '匿名用户',
"user_avatar": c.user.avatar if c.user else None,
"created_at": c.created_at
}
for c in comments
]

View File

@ -1,5 +1,5 @@
# users.py - 用户管理路由
# Version: 1.2.98 (2026-04-19)
# Version: 0.0.1 (2026-04-19)
# 更新:新增 dealCount 字段,从 Information 表统计用户发布的行情数量
from typing import Optional
@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.auth import get_current_user
from app.models.models import User, Collection
from app.models.models import User, Collection, Information
from app.schemas.schemas import UserResponse, UserUpdate
router = APIRouter(prefix="/api", tags=["用户"])

View File

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

View File

@ -20,6 +20,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase):
password: str = Field(..., min_length=6)
invite_code: Optional[str] = Field(None, alias="inviteCode") # 填写的邀请码(选填)
# 移除手机号和短信验证2026-06-13
class UserUpdate(BaseModel):

View File

@ -0,0 +1,262 @@
# coolbot_matcher - 一尘数据库号码匹配工具
# Version: 0.0.2 (2026-04-30)
# 描述:从一尘数据库(coolbot_data)查询匹配的藏品号码
# 更新:号码分类规则修正
from typing import List, Optional
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.coolbot_db import coolbot_engine
def classify_number(number: str) -> str:
"""根据号码特征分类
规则2026-04-30修正版
首先区分标百标十单张
- 单张J后面9位
- 标十J后面8位最后一位是1
- 标百J后面7位最后两位是01
分类优先级
| 类型 | 排除 | 可用数字 | 必须包含 |
|------|------|----------|----------|
| 通货 | - | - | 4 |
| 带7号 | 4 | - | 7 |
| 永恒号 | 47 | - | - |
| 圆圆号 | 123457 | 0689 | - |
| 倒置号 | 23457 | 01689 | 1 |
| 金马王 | 12347 | 05689 | 5 |
| 金马号 | 2347 | 015689 | 1,5 |
| 金山王 | 12457 | 03689 | 3 |
| 天马王 | 1247 | 035689 | 3,5 |
| 金山号 | 2457 | 013689 | 1,3 |
| 天马号 | 247 | 0135689 | 1,3,5 |
| 朦胧王 | 13457 | - | - |
| 朦胧号 | 3457 | - | - |
| 如意号 | 1347 | - | - |
| 钻石号 | 347 | - | - |
"""
if not number:
return "未知"
if number.startswith('J0'):
digits = number[2:]
elif number.startswith('J'):
digits = number[1:]
else:
return "未知"
if len(digits) == 9:
d = digits
elif len(digits) == 8:
d = digits[:8]
elif len(digits) == 7:
d = digits
else:
return "未知"
unique = set(d)
# 1. 通货带4
if '4' in unique:
return "通货"
# 2. 带7号无4有7
if '7' in unique:
return "带7号"
# 3. 圆圆号无1234570689四个数字任意组合
if unique <= {'0', '6', '8', '9'}:
return "圆圆号"
# 4. 永恒号只有0和1无47但不符合圆圆号/倒置号)
if unique <= {'0', '1'}:
return "永恒号"
# 5. 倒置号01689组合必须有1且包含6/8/9中的至少一个
if unique <= {'0', '1', '6', '8', '9'} and '1' in unique and unique & {'6', '8', '9'}:
return "倒置号"
# 6. 金马王无1234705689组合必须有5排除1
if unique <= {'0', '5', '6', '8', '9'} and '5' in unique and '1' not in unique:
return "金马王"
# 7. 金马号无2347015689组合必须有1和5包含1
if unique <= {'0', '1', '5', '6', '8', '9'} and '1' in unique and '5' in unique:
return "金马号"
# 8. 金山王无1245703689组合必须有3排除1和5
if unique <= {'0', '3', '6', '8', '9'} and '3' in unique and '1' not in unique and '5' not in unique:
return "金山王"
# 9. 天马王无1247035689组合必须有3和5排除1
if unique <= {'0', '3', '5', '6', '8', '9'} and '3' in unique and '5' in unique and '1' not in unique:
return "天马王"
# 10. 金山号无2457013689组合必须有1和3排除5
if unique <= {'0', '1', '3', '6', '8', '9'} and '1' in unique and '3' in unique and '5' not in unique:
return "金山号"
# 11. 天马号无2470135689组合必须有1、3和5
if unique <= {'0', '1', '3', '5', '6', '8', '9'} and '1' in unique and '3' in unique and '5' in unique:
return "天马号"
# 12. 朦胧王无13457
if not unique & {'1', '3', '4', '5', '7'}:
return "朦胧王"
# 13. 朦胧号无3457
if not unique & {'3', '4', '5', '7'}:
return "朦胧号"
# 14. 如意号无1347
if not unique & {'1', '3', '4', '7'}:
return "如意号"
# 15. 钻石号无347
if not unique & {'3', '4', '7'}:
return "钻石号"
# 16. 永恒号兜底无47
if '7' not in unique:
return "永恒号"
return "其他"
def check_match(col_number: str, expect_number: str, expect_category: str) -> bool:
"""检查藏品号码是否符合期望的分类"""
if not col_number or not expect_category:
return False
col_cat = classify_number(col_number)
if col_cat == expect_category:
return True
if expect_category == "其他":
return check_number_pattern(col_number, expect_number)
return False
def check_number_pattern(col_number: str, expect_number: str) -> bool:
"""检查号码特征匹配"""
if not col_number or not expect_number:
return False
if col_number.startswith('J0'):
col_digits = col_number[2:]
else:
col_digits = col_number[1:] if len(col_number) > 1 else col_number
if expect_number.startswith('J0'):
exp_digits = expect_number[2:]
else:
exp_digits = expect_number[1:] if len(expect_number) > 1 else expect_number
min_len = min(len(col_digits), len(exp_digits))
return col_digits[:min_len] == exp_digits[:min_len]
def get_match_type(expect_number: str) -> str:
"""判断匹配类型"""
if not expect_number:
return "unknown"
if expect_number.startswith('J0'):
digits = expect_number[2:]
elif expect_number.startswith('J'):
digits = expect_number[1:]
else:
return "unknown"
if len(digits) == 8:
return "ten"
elif len(digits) == 7:
return "hundred"
elif len(digits) == 9:
return "single"
return "unknown"
def match_self_collections_count(db: Session, user_id: str, expect_number: str, expect_category: str) -> int:
"""计算匹配藏品数量(用户自有藏品)"""
from app.models.models import Collection
if not expect_number:
return 0
collections = db.query(Collection).filter(
Collection.f99_91_user_id == user_id,
Collection.f01_04_status == "in_collection"
).all()
count = 0
for c in collections:
number = c.f02_10_prefix_serial or ''
if check_match(number, expect_number, expect_category):
count += 1
return count
def match_collections_count_from_coolbot(expect_number: str, expect_category: str) -> int:
"""计算匹配藏品数量(一尘数据库)"""
if not expect_number:
return 0
query = text("""
SELECT id, crown_code FROM collections
WHERE crown_code IS NOT NULL
AND crown_code != ''
AND crown_code LIKE 'J0%'
""")
try:
with coolbot_engine.connect() as conn:
result = conn.execute(query)
return sum(1 for row in result if check_match(row[1], expect_number, expect_category))
except Exception as e:
print(f"Error: {e}")
return 0
def match_collections_list_from_coolbot(expect_number: str, expect_category: str, limit: int = 20) -> List[dict]:
"""获取匹配的藏品列表(一尘数据库)"""
if not expect_number:
return []
query = text("""
SELECT id, name, category, crown_code, price, post_title, post_url, author, post_crawled_at
FROM collections
WHERE crown_code IS NOT NULL
AND crown_code != ''
AND crown_code LIKE 'J0%'
""")
try:
with coolbot_engine.connect() as conn:
result = conn.execute(query)
matched = []
for row in result:
if check_match(row[3], expect_number, expect_category):
matched.append({
"id": row[0],
"name": row[1],
"category": row[2],
"crown_code": row[3],
"price": float(row[4]) if row[4] else None,
"post_title": row[5],
"post_url": row[6],
"author": row[7],
"post_crawled_at": row[8].isoformat() if row[8] else None
})
if len(matched) >= limit:
break
return matched
except Exception as e:
print(f"Error: {e}")
return []

View File

@ -1,5 +1,222 @@
# 版本更新记录
## v0.2.7 (2026-07-19) - 龙钞宣传功能
### 前端更新
- **Wiki.jsx (新增)**
- 龙钞百科页面,包含:什么是龙钞、号码分类、术语解释、评级知识、保养指南、交易指南
- 新手入门必备指南
- **SharePoster.jsx (新增)**
- 藏品分享海报组件
- 一键生成精美海报,朋友圈分享
- **List.jsx**
- 新增分享按钮,每个藏品卡片右上角显示"📤 分享"按钮
- 点击生成精美海报,保存到相册
- **Home.jsx**
- 快捷操作新增"📚 龙钞百科"入口
### 依赖更新
- 新增 html2canvas 库,用于生成海报图片
---
## v0.2.5 (2026-05-13)
### 前端更新
- **News.jsx**
- 移除成交行情价格统计表格,只显示筛选后的结果
- 成交行情获取数量改为500条
- **YichensBoard.jsx**
- 增加复制链接功能,点击可复制原帖链接
### 环境配置
- C环境Nginx配置为代理到B环境后端
## v0.2.3 (2026-05-11)
### 前端更新
- **News.jsx**
- 修复:统计表格点击均价弹出的成交详情列表按成交日期倒序排序
- 更新calcAvg函数过滤后对items按deal_date排序
## v0.2.2 (2026-05-11)
### 后端更新
- **seek.py**
- 新增寻配号支持评级字段number, is_graded, grading_company, grading_score
- 更新SeekInfoCreate/Update/Response Schema 新增评级字段支持
- **seek_info模型**
- 新增number, is_graded, grading_company, grading_score 字段
## v0.2.1 (2026-05-07)
### 前端更新
- **Add.jsx**
- 修复:行情录入时评级信息(is_graded, grading_company, grading_score)未保存到数据库的问题
- 修复:表单重置后未清空评级字段的问题
- 同步修复:批量录入时的评级字段问题
- **News.jsx**
- 修复:成交行情列表按成交日期(deal_date)倒序排序
- 修复:统计表格详情弹窗列表按成交日期倒序排序
### 后端更新
- **purchase.py**
- 新增:管理员审批功能 - 创建认购群需要管理员审批
- 新增pending-approval接口 - 获取待审批群列表(仅管理员可见)
- 新增approve接口 - 管理员审批/拒绝认购群
- 新增my-pending-groups接口 - 获取我创建的待审批群
- 修改普通用户创建群状态设为pending管理员创建直接active
- 修改加入群时检查pending/rejected状态
- **purchase模型**
- 新增approved_by字段 - 审批管理员ID
- 新增approved_at字段 - 审批时间
- 修改status字段扩展支持pending/active/closed/rejected状态
## v0.1.9 (2026-05-02)
### 后端更新
- **auth.py**
- 修复get_current_user在未登录时返回None导致500错误现在会返回401
- 修复用户不存在时返回None导致500错误现在返回401
- 修复所有需要认证的接口现在会正确返回401
- **seek.py**
- 修复:/api/seek/stats接口现在不需要登录公开接口
- **users.py**
- 修复添加Information模型导入
### 数据库更新
- seek_info表添加network_matched_count字段
### 环境配置
- B环境短信配置AccessKey/SignName/TemplateCode更新
- A环境短信配置添加短信环境变量
## v0.1.8 (2026-05-01)
### 前端更新
- **News.jsx**
- 新增:成交行情筛选功能(版别筛选:龙钞/马钞/蛇钞/其他)
- 新增:包装类型筛选(全部/标百/标十/单张)
- 新增:号码分类筛选(全部/带4号/带7号/永恒/钻石/天马/金山/金马/倒置/圆圆)
- 新增:清除筛选按钮
- 筛选同时作用于行情清单列表和统计表格
## v0.1.6 (2026-05-01)
### 前端更新
- **List.jsx**
- 新增:搜索框增强 - 支持搜索冠字号、编号、价格
- 新增:版别筛选按钮 - 20版/19版/18版/17版/16版
- 新增:包装类型筛选 - 单张/标十/标百
- 新增:号码分类筛选 - 圆圆号/倒置号/金马号/天马号/钻石号/永恒号/带7号/带4号
- 新增:清除筛选按钮
- 优化:筛选结果显示当前已选条件
## v0.1.5 (2026-05-01)
### 前端更新
- **Purchase.jsx**
- 新增待审批Tab黄色按钮- 显示需要审批的成员
- 新增:待审批成员列表 - 显示申请人信息+同意/拒绝按钮
- 新增:审批后状态 - 变灰+显示已批准/已拒绝
- 新增:申请加入后待审批状态 - 列表显示黄色"待审批"
- 修复:待审批数量计算准确
- 修复:只有群主能看到自己创建的群的待审批
### 后端更新
- **purchase.py**
- 修复join_group接口处理状态
- 新增MemberResponse扩展字段wechat_name, contact等
## v0.1.4 (2026-05-01)
### 前端更新
- **Purchase.jsx**
- 修复:列表隐藏群介绍,点进详情后才显示
- 修复:未登录不黑屏
- 新增:修改群信息弹窗(群主)
- 新增:修改个人信息弹窗(成员)
- 修复冠字号输入框maxLength
## v0.1.3 (2026-04-30)
### 前端更新
- **Home.jsx**
- 修复:我的认购入口字体颜色改为蓝色 #3b82f6
- **Purchase.jsx**
- 新增:权限控制 - 未加入的群只能看到基本信息(群名、编号、群主、介绍)
- 新增:申请加入按钮 - 未加入的群可申请加入
- 修复:敏感信息(统计、成员、藏品)只有已加入才可见
- 修复user.id改为user.f99_90_id
## v0.1.2 (2026-04-30)
### 前端更新
- **Purchase.jsx**
- 新增创建群表单6个必填字段
- 认购群名称
- 认购介绍(群介绍)
- 参与人数
- 总认购数
- 认购周期(月)
- 开始日期
### 后端更新
- **purchase.py**
- 新增:创建群接口支持新字段 total_members、total_collections
## v0.0.12 (2026-04-29)
### 前端更新
- **Purchase.jsx**
- 完善:入群申请审批流程 (待审批成员列表)
- 完善:新增认购藏品字段
- 编号自动生成001开始
- 冠字号:必填
- 评级:必填 (下拉选择)
- 认购老板:必填 (从成员中选择)
- 价格:必填
- 来源:必填 (一尘/直播/群友/其他)
- 认购款结清checkbox
- 优化:表单布局
### 后端更新
- **purchase.py**
- 新增:入群审批接口 /approve
- 新增:待审批成员列表 /pending-members
- 新增:认购藏品新字段 (grading, boss_name, source, paid, submit_date)
---
## v0.0.7 (2026-04-24)
### 代码清理
- **coolbot_matcher.py** (新建)
- 功能:创建一尘数据库号码匹配工具模块
- 包含match_pattern, match_self_collections_count, match_collections_count_from_coolbot, match_collections_list_from_coolbot
- **seek.py**
- 功能:更新导入,添加缺失端点
- 新增端点:/match-confirm, /matched-user/{id}, /publisher/{id}
- 优化:移除列表接口网络匹配实时计算,提升加载速度
- **information.py**
- 清理注释废弃的seek/deal重复端点统一使用/api/seek/*和/api/deal/*
- **News.jsx**
- 迁移seek相关API从/api/information/*迁移到/api/seek/*
---
## v1.2.98 (2026-04-19)
### 前端更新
@ -33,3 +250,8 @@
### 更新内容
- (待记录)
## v0.0.6 (2026-04-24)
### Bug修复
- 修复"自有X条藏品匹配成功"点击后没有藏品列表的问题
- 原因seek/match API只查询information表但实际数据在seek_info表
- 修复API同时支持information表和seek_info表查询

View File

@ -1 +1 @@
1.2.98
VERSION=0.2.5

View File

@ -1,47 +1,29 @@
{
"version": "1.2.98",
"updated": "2026-04-19",
"version": "0.2.7",
"updated": "2026-07-19",
"modules": {
"frontend": {
"version": "1.2.98",
"version": "0.2.3",
"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"
"Login": "0.0.2",
"Add": "0.0.2",
"News": "0.1.2",
"YichensBoard": "0.0.5",
"Wiki": "1.0.0",
"SharePoster": "1.0.0",
"List": "0.1.7"
}
},
"backend": {
"version": "1.2.98",
"version": "0.2.2",
"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"
"auth": "0.0.2",
"seek": "0.2.0",
"purchase": "0.3.0"
},
"app": {
"core": "1.2.0",
"models": "1.2.0",
"schemas": "1.2.0",
"services": "1.2.0",
"utils": "1.2.0"
"models": {
"seek_info": "0.2.0",
"purchase": "0.0.3"
}
}
}

449
docs/DEPLOYMENT.md Normal file
View File

@ -0,0 +1,449 @@
# 🐉 甲辰藏品管理系统 环境部署手册
> **版本**: v1.0
> **更新日期**: 2026-04-24
> **维护人**: 龙大
---
## 📋 环境总览
| 环境 | 定位 | 域名 | 前端IP | 后端IP | 数据库 | 状态 |
|------|------|------|--------|--------|--------|------|
| **C** | 测试开发 | socoolbot.com.cn | 47.103.29.111 | 47.103.9.192:3000 | 本地PostgreSQL | ✅ 开放 |
| **A** | 灰度发布 | socoolbot.com | 120.26.133.10 | 120.26.144.208:3000 | RDS | 🔒 封闭 |
| **B** | 主系统(生产) | jiachenlong.com | 120.55.81.21 | 47.110.37.129:3000 | RDS | 🔒 封闭 |
| **D** | 备用环境 | socoolbot.cn | 47.111.184.210 | 47.111.184.210:3000 | RDS | 🔒 封闭 |
### 部署流程
```
开发(C) → 灰度(A测试) → 主系统(B发布) → 备用(D保留)
```
---
## ⚠️ 绝对准则
1. **环境操作准则**A、B环境的系统更新和操作必须经过酷博特允许否则绝对不允许操作
2. **版本管理准则**:版本管理必须经过酷博特确认,不许擅自更新版本号
3. **数据操作准则**RDS数据库中的数据没有酷博特允许不许进行增删改的操作
4. **操作原则**:以上三条为绝对准则,任何情况下都不得违反
---
## 🧪 C环境测试开发环境
### 特点
- **用途**:开发测试,所有新功能先在这里开发测试
- **数据库**本地PostgreSQL不连接RDS
- **权限**:开放,可自行操作
- **网络**:可直接访问公网
### 服务器信息
| 角色 | IP | SSH密码 | 服务 |
|------|-----|---------|------|
| 前端 | 47.103.29.111 | Test1234 | Nginx + React构建产物 |
| 后端 | 47.103.9.192 | Test1234 | FastAPI + 本地PostgreSQL |
### 访问链接
- 前端http://47.103.29.111
- APIhttp://47.103.9.192:3000
### 部署步骤
#### 前端部署
```bash
# 1. 本地构建
cd /root/.openclaw/workspace/jiachenlong/frontend
npm run build
# 2. 复制到服务器
sshpass -p 'Test1234' scp -o StrictHostKeyChecking=no \
/root/.openclaw/workspace/jiachenlong/frontend/dist/* \
root@47.103.29.111:/var/www/frontend/
# 3. 验证
curl -s http://47.103.29.111/ | grep -o 'index-[^"]*\.js'
```
#### 后端部署
```bash
# 1. 同步代码
sshpass -p 'Test1234' scp -o StrictHostKeyChecking=no \
-r /root/.openclaw/workspace/jiachenlong/backend \
root@47.103.9.192:/root/jiachenlong/
# 2. 启动服务C环境使用本地数据库不设置DATABASE_URL
sshpass -p 'Test1234' ssh -o StrictHostKeyChecking=no root@47.103.9.192 \
'cd /root/jiachenlong/backend && \
export SECRET_KEY="dev-secret-key-12345" && \
export OSS_ACCESS_KEY_ID="LTAI5t6HUnpFBLEK9194kPVG" && \
export OSS_ACCESS_KEY_SECRET="LEr4Q8yRxb8D5b24cKfCwlt4MMoke1" && \
pkill -f uvicorn 2>/dev/null; \
sleep 1; \
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 > /tmp/uvicorn.log 2>&1 &'
# 3. 验证
curl -s http://47.103.9.192:3000/
```
### 部署后检查
```bash
# 1. 前端访问
curl -s -o /dev/null -w "前端: %{http_code}" http://47.103.29.111/
# 2. 后端API
curl -s -o /dev/null -w "后端: %{http_code}" http://47.103.9.192:3000/
# 3. 登录功能
curl -s -X POST http://47.103.9.192:3000/api/auth/login \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password=admin123" | grep -q "token" && echo "登录OK"
# 4. 列表加载速度(应<1秒
time curl -s "http://47.103.9.192:3000/api/seek/list?page=1&page_size=500" -o /dev/null
```
### 常见问题
| 问题 | 原因 | 解决方案 |
|------|------|----------|
| 前端显示旧版本 | 浏览器缓存 | Ctrl+F5 强制刷新 |
| 后端启动失败 | 环境变量未设置 | 检查 start_backend.sh 脚本 |
| 数据库连接失败 | 使用了RDS地址 | C环境不使用DATABASE_URL环境变量 |
| 加载很慢 | seek列表计算网络匹配 | 检查seek.py是否移除了match_collections_count_from_coolbot |
---
## 🏭 A环境灰度发布环境
### 特点
- **用途**:发布前测试验证
- **数据库**阿里云RDS (pgm-bp1t5w248t7s1pvr)
- **权限**:🔒 封闭,需酷博特授权
- **域名**socoolbot.com
### 服务器信息
| 角色 | IP | 内网IP | SSH密码 |
|------|-----|--------|---------|
| WebA | 120.26.133.10 | 172.26.29.103 | AJiachen123 |
| AppA | 120.26.144.208 | 172.26.30.31 | AJiachen123 |
### 访问链接
- https://socoolbot.com
### 短信配置
- 签名:苏州双人旁
- 模板SMS_505015231
- AccessKeyLTAI5t86bc1nNKVNyYv4Af6x
### 部署步骤(需授权)
#### 部署前准备
1. 确认要部署的版本
2. 联系酷博特获取授权
3. 确认数据库密码
#### 第一步:前端部署
```bash
# SSH连接
ssh root@120.26.133.10 # 密码: AJiachen123
# 克隆代码
cd /root && rm -rf jiachenlong
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git
# 构建
cd /root/jiachenlong/frontend && npm install && npm run build
# 复制到Nginx目录
rm -rf /var/www/frontend/*
cp -r /root/jiachenlong/frontend/dist/* /var/www/frontend/
# 复制Logo
mkdir -p /var/www/frontend/static/images
cp /root/jiachenlong/static/images/jiachenlong-logo.png /var/www/frontend/static/images/
# 复制用户协议
cp /root/jiachenlong/frontend/dist/user_agreement.html /var/www/frontend/
# 重启Nginx
nginx -s reload
```
#### 第二步:后端部署
```bash
# SSH连接
ssh root@120.26.144.208 # 密码: AJiachen123
# 克隆代码
cd /root && rm -rf jiachenlong
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git
# 安装依赖
cd /root/jiachenlong/backend && pip3 install -r requirements.txt
# 创建启动脚本
cat > /root/jiachenlong/start.sh << 'EOF'
#!/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-a-env"
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"
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 > /tmp/uvicorn.log 2>&1 &
echo "A环境后端已启动"
EOF
chmod +x /root/jiachenlong/start.sh
# 启动服务
pkill -f uvicorn; sleep 1; /root/jiachenlong/start.sh
```
#### 第三步:验证部署
```bash
# 1. 版本检查
curl -s https://socoolbot.com/ | grep -o 'v[0-9.]*'
# 2. API检查
curl -s -o /dev/null -w "API: %{http_code}" https://socoolbot.com/api/
# 3. 登录测试
curl -s -X POST https://socoolbot.com/api/auth/login \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password=admin123"
```
### 部署后检查清单
- [ ] 前端页面可访问HTTP和HTTPS
- [ ] 版本号显示正确
- [ ] 登录功能正常
- [ ] 藏品列表加载正常
- [ ] 藏品新增/编辑功能正常
- [ ] 图片上传功能正常
- [ ] 短信发送功能正常
- [ ] 历史数据完整(用户数、藏品数)
---
## 🏭 B环境主系统生产环境
### 特点
- **用途**:主系统生产环境,统一入口
- **数据库**阿里云RDS (pgm-bp1t5w248t7s1pvr)
- **权限**:🔒 封闭,需酷博特授权
- **域名**jiachenlong.com
### 服务器信息
| 角色 | IP | 内网IP | SSH密码 |
|------|-----|--------|---------|
| WebB | 120.55.81.21 | 172.26.30.30 | Jiachen123 |
| AppB | 47.110.37.129 | 172.26.29.98 | Jiachen123 |
### 访问链接
- https://jiachenlong.com
### 短信配置
- 签名:双人旁文化传媒
- 模板SMS_504740243
- AccessKeyLTAI5tPzZDqbM1J6zbk8Wy4q
### 部署步骤(需授权)
#### 第一步:前端部署
```bash
ssh root@120.55.81.21 # 密码: Jiachen123
# 克隆代码并构建同A环境
cd /root && rm -rf jiachenlong
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git
cd /root/jiachenlong/frontend && npm install && npm run build
rm -rf /var/www/frontend/*
cp -r /root/jiachenlong/frontend/dist/* /var/www/frontend/
mkdir -p /var/www/frontend/static/images
cp /root/jiachenlong/static/images/jiachenlong-logo.png /var/www/frontend/static/images/
cp /root/jiachenlong/frontend/dist/user_agreement.html /var/www/frontend/
nginx -s reload
```
#### 第二步:后端部署
```bash
ssh root@47.110.37.129 # 密码: Jiachen123
# 克隆代码
cd /root && rm -rf jiachenlong
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git
cd /root/jiachenlong/backend && pip3 install -r requirements.txt
# 创建启动脚本注意B环境使用不同的短信配置
cat > /root/jiachenlong/start.sh << 'EOF'
#!/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="LTAI5tPzZDqbM1J6zbk8Wy4q"
export SMS_ACCESS_KEY_SECRET="ErT3jH9kL2mN5pQ7rS8uV0wX1yZ4aB6c"
export SMS_SIGN_NAME="双人旁文化传媒"
export SMS_TEMPLATE_CODE="SMS_504740243"
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 --workers 1 > /tmp/uvicorn.log 2>&1 &
echo "B环境后端已启动"
EOF
chmod +x /root/jiachenlong/start.sh
pkill -f uvicorn; sleep 1; /root/jiachenlong/start.sh
```
### 部署后检查清单
- [ ] 前端页面可访问HTTP和HTTPS
- [ ] 版本号显示正确
- [ ] 登录功能正常
- [ ] 所有功能正常
- [ ] 短信发送正常
- [ ] 数据完整
- [ ] 向酷博特汇报部署完成
---
## 🧪 D环境备用环境
### 特点
- **用途**:备用保留
- **数据库**阿里云RDS
- **权限**:🔒 封闭,需酷博特授权
### 服务器信息
| 角色 | IP | 内网IP | SSH密码 |
|------|-----|--------|---------|
| WebD | 47.111.184.210 | 172.26.30.32 | (同A/B) |
| AppD | 47.111.184.210 | 172.26.30.33 | (同A/B) |
### 访问链接
- http://47.111.184.210
- socoolbot.cn
### 短信配置
- 签名:双人旁文化传媒
- 模板SMS_504740243
---
## 🔧 常见问题处理
### 1. 前端显示旧版本
```bash
# 原因:浏览器缓存
# 解决Ctrl+F5 强制刷新或清理Nginx缓存
```
### 2. 后端启动失败
```bash
# 检查日志
tail -50 /tmp/uvicorn.log
# 常见错误:
# - SECRET_KEY未设置 → 添加环境变量
# - 数据库连接失败 → 检查DATABASE_URL格式密码需要URL编码
# - 端口被占用 → pkill -f uvicorn 后重试
```
### 3. 加载很慢seek列表
```bash
# 检查seek.py是否移除了match_collections_count_from_coolbot调用
grep -n "network_matched_count" /root/jiachenlong/backend/app/routers/seek.py
# 确认返回的network_matched_count为0
curl -s "http://后端IP:3000/api/seek/list?page=1&page_size=5" | grep network_matched_count
```
### 4. 图片无法上传
```bash
# 检查OSS配置
curl -s http://后端IP:3000/api/collections/test-upload
# 检查Nginx上传大小限制
grep client_max_body_size /etc/nginx/nginx.conf
```
### 5. 短信发送失败
```bash
# 检查环境变量
ssh root@后端IP "env | grep SMS"
# 检查模板代码和签名是否匹配当前环境
```
### 6. 数据库连接超时
```bash
# 检查RDS白名单
# C环境使用本地数据库不连RDS
# A/B/D环境需要检查安全组是否允许访问RDS
```
---
## 📝 版本管理
### 更新版本流程
1. 修改代码文件头部Version注释
2. 更新config/VERSION.json
3. 更新config/CHANGELOG.md
4. 提交Git
5. 部署后验证
### 版本检查命令
```bash
# 本地
cat /root/.openclaw/workspace/jiachenlong/config/VERSION.json
# 服务器
curl -s http://服务器IP/ | grep -o 'v[0-9.]*'
```
---
## 📞 紧急联系人
- **酷博特**:项目总负责,所有重大操作需汇报
- **龙大**:环境部署负责人
---
## 📝 部署记录模板
```
## 部署记录 - [日期]
### 环境:[A/B/C/D]
### 版本:[v0.0.x]
### 部署人:龙大
#### 部署步骤
1. [步骤1]
2. [步骤2]
#### 检查结果
- [x] 前端OK
- [x] 后端OK
- [x] 登录OK
#### 问题记录
#### 汇报状态
已向酷博特汇报
```

6
frontend/fix_version.js Normal file
View File

@ -0,0 +1,6 @@
const fs = require('fs');
const htmlPath = './index.html';
let html = fs.readFileSync(htmlPath, 'utf-8');
html = html.replace('v=0.0.3', 'v=0.0.4');
fs.writeFileSync(htmlPath, html);
console.log('Done');

View File

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

View File

@ -9,6 +9,7 @@
"version": "1.2.82",
"dependencies": {
"axios": "^1.7.9",
"html2canvas": "^1.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.0"
@ -1239,6 +1240,15 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.8",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
@ -1352,6 +1362,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -1673,6 +1692,19 @@
"node": ">= 0.4"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -1998,6 +2030,15 @@
"node": ">=0.10.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -2046,6 +2087,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz",

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.7.9",
"html2canvas": "^1.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.0"

View File

@ -5,10 +5,12 @@ import List from './pages/List'
import Add from './pages/Add'
import Stats from './pages/Stats'
import News from './pages/News'
import Purchase from './pages/Purchase'
import Login from './pages/Login'
import Detail from './pages/Detail'
import Edit from './pages/Edit'
import Admin from './pages/Admin'
import Wiki from './pages/Wiki'
export default function App() {
const [path, setPath] = useState(window.location.hash.slice(1) || '/')
@ -30,12 +32,14 @@ export default function App() {
const basePath = path.split('?')[0]
if (basePath === '/') return <Home />
if (basePath === '/news') return <News />
if (basePath === '/purchase') return <Purchase />
if (basePath === '/stats') return <Stats />
if (basePath === '/list') return <List />
if (basePath === '/add') return <Add />
if (basePath === '/login') return <Login />
if (basePath === '/settings') return <Settings />
if (basePath === '/admin') return <Admin />
if (basePath === '/wiki') return <Wiki />
if (basePath.startsWith('/edit')) return <Edit />
if (basePath.startsWith('/detail')) return <Detail />
return <Home />

View File

@ -0,0 +1,404 @@
/**
* SharePoster - 藏品分享海报组件
* 生成精美的藏品分享图片用于朋友圈分享
* Version: 1.0.0
*/
import React, { useState, useRef } from 'react'
import html2canvas from 'html2canvas'
export default function SharePoster({ collection, onClose }) {
const posterRef = useRef(null)
const [generating, setGenerating] = useState(false)
const [generated, setGenerated] = useState(false)
//
const getNumberCategoryColor = (cat) => {
const colors = {
'圆圆号': '#ef4444',
'倒置号': '#f97316',
'金马王': '#eab308',
'金马号': '#84cc16',
'金山王': '#22c55e',
'天马王': '#14b8a6',
'金山号': '#06b6d4',
'天马号': '#0ea5e9',
'朦胧王': '#3b82f6',
'朦胧号': '#6366f1',
'如意号': '#8b5cf6',
'钻石号': '#a855f7',
'永恒号': '#d946ef',
'带7号': '#ec4899',
'带4号': '#f43f5e',
}
return colors[cat] || '#94a3b8'
}
//
const getStatusColor = (status) => {
const colors = {
'in_collection': '#22c55e',
'selling': '#f59e0b',
'sold': '#ef4444',
'grading': '#06b6d4',
'repairing': '#8b5cf6',
'transit': '#6366f1',
'seeking': '#f97316',
'other': '#94a3b8'
}
return colors[status] || '#94a3b8'
}
//
const getStatusText = (status) => {
const texts = {
'in_collection': '收藏中',
'selling': '出售中',
'sold': '已售',
'grading': '送评中',
'repairing': '修复中',
'transit': '在途中',
'seeking': '寻号中',
'other': '其他'
}
return texts[status] || status
}
//
const formatPrefixSerial = (ps) => {
if (!ps) return '-'
const match = ps.match(/^([A-Z]+)(\d+)$/i)
if (match) {
return match[1].toUpperCase() + ' ' + match[2].match(/.{1,4}/g).join(' ')
}
return ps
}
//
const generatePoster = async () => {
if (!posterRef.current) return
setGenerating(true)
try {
const canvas = await html2canvas(posterRef.current, {
scale: 2,
backgroundColor: '#0f172a',
useCORS: true,
allowTaint: true
})
//
const link = document.createElement('a')
link.download = `龙钞-${collection.code || collection.prefixSerial || '藏品'}.png`
link.href = canvas.toDataURL('image/png')
link.click()
setGenerated(true)
} catch (e) {
console.error('生成海报失败:', e)
alert('生成海报失败,请重试')
}
setGenerating(false)
}
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.9)',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}>
{/* 标题栏 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
maxWidth: '400px',
marginBottom: '20px'
}}>
<h3 style={{ color: '#fff', margin: 0, fontSize: '18px' }}>📤 生成分享海报</h3>
<button
onClick={onClose}
style={{
background: 'rgba(255,255,255,0.1)',
border: 'none',
borderRadius: '50%',
width: '36px',
height: '36px',
color: '#fff',
fontSize: '18px',
cursor: 'pointer'
}}
></button>
</div>
{/* 海报预览区域 */}
<div ref={posterRef} style={{
width: '320px',
minHeight: '480px',
background: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)',
borderRadius: '16px',
padding: '20px',
boxSizing: 'border-box',
position: 'relative',
overflow: 'hidden'
}}>
{/* 背景装饰 */}
<div style={{
position: 'absolute',
top: '-50%',
right: '-30%',
width: '200px',
height: '200px',
background: 'radial-gradient(circle, rgba(251,191,36,0.15) 0%, transparent 70%)',
borderRadius: '50%'
}} />
{/* 头部 */}
<div style={{ textAlign: 'center', marginBottom: '20px', position: 'relative' }}>
<div style={{
fontSize: '28px',
fontWeight: 'bold',
background: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
marginBottom: '4px'
}}>
🐉 甲辰龙钞
</div>
<div style={{ color: '#94a3b8', fontSize: '11px' }}>
一甲辰藏品管理系统
</div>
</div>
{/* 分割线 */}
<div style={{
height: '1px',
background: 'linear-gradient(90deg, transparent, rgba(251,191,36,0.3), transparent)',
marginBottom: '20px'
}} />
{/* 藏品信息 */}
<div style={{ marginBottom: '16px' }}>
{/* 冠字号 - 大字体 */}
<div style={{
textAlign: 'center',
marginBottom: '16px',
padding: '12px',
background: 'rgba(251,191,36,0.1)',
borderRadius: '12px',
border: '1px solid rgba(251,191,36,0.2)'
}}>
<div style={{ color: '#94a3b8', fontSize: '10px', marginBottom: '4px' }}>冠字号</div>
<div style={{
color: '#fbbf24',
fontSize: '24px',
fontFamily: 'monospace',
fontWeight: 'bold',
letterSpacing: '2px'
}}>
{formatPrefixSerial(collection.prefixSerial)}
</div>
{collection.code && (
<div style={{ color: '#64748b', fontSize: '12px', marginTop: '4px' }}>
编号: {collection.code}
</div>
)}
</div>
{/* 基本信息网格 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '16px' }}>
<div style={{ background: 'rgba(255,255,255,0.03)', padding: '10px', borderRadius: '8px' }}>
<div style={{ color: '#64748b', fontSize: '10px' }}>版别</div>
<div style={{ color: '#fff', fontSize: '13px', fontWeight: 'bold' }}>{collection.version || '-'}</div>
</div>
<div style={{ background: 'rgba(255,255,255,0.03)', padding: '10px', borderRadius: '8px' }}>
<div style={{ color: '#64748b', fontSize: '10px' }}>包装</div>
<div style={{ color: '#fff', fontSize: '13px', fontWeight: 'bold' }}>{collection.packaging || '-'}</div>
</div>
<div style={{ background: 'rgba(255,255,255,0.03)', padding: '10px', borderRadius: '8px' }}>
<div style={{ color: '#64748b', fontSize: '10px' }}>珍惜度</div>
<div style={{ color: '#a78bfa', fontSize: '13px', fontWeight: 'bold' }}>{collection.rarity || '-'}</div>
</div>
<div style={{ background: 'rgba(255,255,255,0.03)', padding: '10px', borderRadius: '8px' }}>
<div style={{ color: '#64748b', fontSize: '10px' }}>状态</div>
<div style={{ color: getStatusColor(collection.status), fontSize: '13px', fontWeight: 'bold' }}>
{getStatusText(collection.status)}
</div>
</div>
</div>
{/* 号码分类标签 */}
{collection.numberCategory && (
<div style={{
textAlign: 'center',
marginBottom: '16px',
padding: '8px',
background: getNumberCategoryColor(collection.numberCategory) + '20',
borderRadius: '8px',
border: `1px solid ${getNumberCategoryColor(collection.numberCategory)}40`
}}>
<span style={{
color: getNumberCategoryColor(collection.numberCategory),
fontSize: '16px',
fontWeight: 'bold'
}}>
{collection.numberCategory}
</span>
</div>
)}
{/* 评级信息 */}
{collection.isGraded && (
<div style={{
display: 'flex',
gap: '8px',
marginBottom: '16px',
justifyContent: 'center'
}}>
{collection.gradingCompany && (
<span style={{
background: 'rgba(6,182,212,0.15)',
color: '#06b6d4',
padding: '4px 10px',
borderRadius: '6px',
fontSize: '11px'
}}>
{collection.gradingCompany}
</span>
)}
{collection.gradingScore && (
<span style={{
background: 'rgba(249,115,22,0.2)',
color: '#f97316',
padding: '4px 10px',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 'bold'
}}>
{collection.gradingScore}
</span>
)}
{collection.threeStar && (
<span style={{
background: 'rgba(6,182,212,0.15)',
color: '#06b6d4',
padding: '4px 10px',
borderRadius: '6px',
fontSize: '11px'
}}>
</span>
)}
</div>
)}
{/* 价格信息 */}
<div style={{
display: 'flex',
justifyContent: 'space-around',
padding: '12px',
background: 'rgba(255,255,255,0.03)',
borderRadius: '8px'
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#64748b', fontSize: '10px' }}>成本</div>
<div style={{ color: '#e2e8f0', fontSize: '14px' }}>
{collection.costPrice ? `¥${collection.costPrice}` : '-'}
</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#64748b', fontSize: '10px' }}>目标价</div>
<div style={{ color: '#fbbf24', fontSize: '14px' }}>
{collection.targetPrice ? `¥${collection.targetPrice}` : '-'}
</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#64748b', fontSize: '10px' }}>售价</div>
<div style={{ color: '#4ade80', fontSize: '14px' }}>
{collection.goalPrice ? `¥${collection.goalPrice}` : '-'}
</div>
</div>
</div>
</div>
{/* 底部 */}
<div style={{
position: 'absolute',
bottom: '15px',
left: '20px',
right: '20px',
textAlign: 'center'
}}>
<div style={{
color: '#64748b',
fontSize: '10px',
marginBottom: '4px'
}}>
长按识别二维码了解更多
</div>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '8px'
}}>
<span style={{ fontSize: '20px' }}>🐉</span>
<span style={{ color: '#94a3b8', fontSize: '11px' }}>jiachenlong.com</span>
</div>
</div>
</div>
{/* 操作按钮 */}
<div style={{
display: 'flex',
gap: '12px',
marginTop: '20px',
width: '100%',
maxWidth: '400px'
}}>
<button
onClick={onClose}
style={{
flex: 1,
padding: '14px',
background: 'rgba(255,255,255,0.1)',
border: 'none',
borderRadius: '12px',
color: '#fff',
fontSize: '15px',
cursor: 'pointer'
}}
>
取消
</button>
<button
onClick={generatePoster}
disabled={generating}
style={{
flex: 2,
padding: '14px',
background: generating ? '#94a3b8' : 'linear-gradient(135deg, #fbbf24, #f59e0b)',
border: 'none',
borderRadius: '12px',
color: '#1e293b',
fontSize: '15px',
fontWeight: 'bold',
cursor: generating ? 'not-allowed' : 'pointer'
}}
>
{generating ? '生成中...' : generated ? '已保存 ✓' : '保存到相册'}
</button>
</div>
</div>
)
}

View File

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

View File

@ -1,7 +1,7 @@
/**
* Add - 添加藏品页面
* Version: 1.2.90x
* 更新
* Version: 0.0.2
* 更新修复行情录入时评级信息(is_graded, grading_company, grading_score)未保存的问题
*/
import React, { useState, useRef } from 'react'
@ -863,12 +863,15 @@ export default function Add() {
size_type: sizeType,
platform: dealForm.platform,
seller: dealForm.seller || '',
buyer: dealForm.buyer || ''
buyer: dealForm.buyer || '',
is_graded: !!dealForm.gradingCompany,
grading_company: dealForm.gradingCompany || '',
grading_score: dealForm.gradingScore || ''
})
})
if (response.ok) {
alert('行情录入成功!')
setDealForm({ serial: '', category: '', packaging: '标十', price: '', platform: '抖音', seller: '', buyer: '', date: new Date().toISOString().split('T')[0] })
setDealForm({ serial: '', category: '', packaging: '标十', price: '', platform: '抖音', seller: '', buyer: '', date: new Date().toISOString().split('T')[0], gradingCompany: '', gradingScore: '' })
} else {
const data = await response.json()
alert('录入失败: ' + (data.detail || '未知错误'))
@ -1055,7 +1058,7 @@ export default function Add() {
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]}`,
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]}\n评级: ${item.grading_company ? item.grading_company + ' ' + item.grading_score : '未评级'}`,
deal_price: parseFloat(item.price),
deal_date: item.deal_date || new Date().toISOString().split('T')[0],
packaging: item.packaging || '单张',
@ -1064,7 +1067,10 @@ export default function Add() {
size_type: size_type,
platform: item.platform || '-',
seller: item.seller || '',
buyer: item.buyer || ''
buyer: item.buyer || '',
is_graded: !!item.grading_company,
grading_company: item.grading_company || '',
grading_score: item.grading_score || ''
})
})
count++

View File

@ -1,6 +1,6 @@
/**
* Admin - 管理员用户管理页面
* Version: 1.2.98 (2026-04-19)
* Version: 0.0.2 (2026-04-19)
* 更新新增 dealCount 字段显示用户发布的行情数量
*/
@ -589,7 +589,7 @@ export default function Admin() {
{/* 版本号 - 移到表格下方,避免被底部导航遮挡 */}
<div style={{ textAlign: 'center', padding: '20px', color: 'rgba(255,255,255,0.3)', fontSize: '12px' }}>
v{APP_VERSION}
文件版本 v0.0.2 | 主版本 v{APP_VERSION}
</div>
</div>
)

View File

@ -1,6 +1,6 @@
/**
* Detail - 藏品详情页面
* Version: 1.2.90x
* Version: 0.0.1
* 更新
*/
@ -388,6 +388,9 @@ export default function Detail() {
</div>
</div>
)}
{/* 版本号显示 */}
<div style={{ position: 'fixed', bottom: '70px', right: '12px', color: 'rgba(255,255,255,0.2)', fontSize: '10px' }}>v0.0.1</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
/**
* Edit - 编辑藏品页面
* Version: 1.2.90x
* Version: 0.0.1
* 更新
*/
@ -492,6 +492,9 @@ export default function Edit() {
🔄 返回列表
</button>
</div>
{/* 版本号显示 */}
<div style={{ position: 'fixed', bottom: '70px', right: '12px', color: 'rgba(255,255,255,0.2)', fontSize: '10px' }}>v0.0.1</div>
</div>
)
}

View File

@ -1,7 +1,7 @@
/**
* Home - 首页
* Version: 1.2.95x
* 更新
* Version: 0.0.4 (2026-04-24)
* 更新修复寻配号数据统计API
*/
import React, { useState, useEffect } from 'react'
@ -66,27 +66,31 @@ export default function Home() {
//
useEffect(() => {
fetch('/api/yichens/stats/dragons-today').then(res => res.json()).then(data => {
setDragonStats(data || {})
const token = localStorage.getItem('token')
const headers = token ? { 'Authorization': 'Bearer ' + token } : {}
//
fetch('/api/seek/stats').then(res => res.json()).then(data => {
Promise.all([
fetch('/api/collections/stats', { headers }),
fetch('/api/yichens/posts?limit=1&offset=0')
]).then(([colRes, yichenRes]) => Promise.all([colRes.json(), yichenRes.json()])).then(([colData, yichenData]) => {
const colCount = colData.totalCount || 0
const yichenTotal = yichenData.total || yichenData.pagination?.total || 0
setSeekStats({
seekCount: data.total || 0,
matchedCount: data.matched || 0,
userMatchedCount: data.matched || 0,
totalMatchedCount: colCount + yichenTotal
})
}).catch(() => {
setSeekStats({ seekCount: data.total||0, matchedCount: data.matched||0, userMatchedCount: data.matched||0, totalMatchedCount: 0 })
})
}).catch(() => {})
fetch('/api/yichens/stats/today').then(res => res.json()).then(data => {
setYichensStats(data || {})
}).catch(() => {})
//
fetch('/api/yichens/stats/dragons-today').then(res => res.json()).then(data => {
setDragonStats(data || {})
}).catch(() => {})
fetch('/api/yichens/posts?limit=20&offset=0').then(res => res.json()).then(data => {
setRecentPosts(data.posts || data || [])
}).catch(() => {})
//
fetch('/api/information/seek/stats').then(res => res.json()).then(data => {
setSeekStats(data || {})
}).catch(() => {})
fetch('/api/yichens/stats/dragons-today').then(res=>res.json()).then(d=>setDragonStats(d||{})).catch(()=>{})
fetch('/api/yichens/stats/today').then(res=>res.json()).then(d=>setYichensStats(d||{})).catch(()=>{})
fetch('/api/yichens/posts?limit=20&offset=0').then(res=>res.json()).then(d=>setRecentPosts(d.posts||d||[])).catch(()=>{})
}, [])
//
@ -178,9 +182,10 @@ export default function Home() {
<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%)',
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.02) 100%)',
borderRadius: '12px',
padding: '12px 16px',
border: '1px solid rgba(255,255,255,0.08)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
@ -188,13 +193,14 @@ export default function Home() {
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 style={{ color: '#22c55e', fontSize: '18px', fontWeight: '600', whiteSpace: 'nowrap' }}>藏品录入</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px', marginTop: '2px' }}>AI识别添加</div>
</div>
<div onClick={() => window.location.hash = '#/add?mode=deal'} style={{
background: 'linear-gradient(135deg, #ec4899 0%, #db2777 100%)',
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.02) 100%)',
borderRadius: '12px',
padding: '12px 16px',
border: '1px solid rgba(255,255,255,0.08)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
@ -202,13 +208,14 @@ export default function Home() {
justifyContent: 'center',
textAlign: 'center'
}}>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>行情录入</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>录入成交行情</div>
<div style={{ color: '#ec4899', fontSize: '18px', fontWeight: '600', whiteSpace: 'nowrap' }}>行情录入</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px', marginTop: '2px' }}>录入成交行情</div>
</div>
<div onClick={() => window.location.hash = '#/news?type=seek'} style={{
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.02) 100%)',
borderRadius: '12px',
padding: '12px 16px',
border: '1px solid rgba(255,255,255,0.08)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
@ -216,13 +223,14 @@ export default function Home() {
justifyContent: 'center',
textAlign: 'center'
}}>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>发布寻号</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>发布寻配需求</div>
<div style={{ color: '#f59e0b', fontSize: '18px', fontWeight: '600', whiteSpace: 'nowrap' }}>发布寻号</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px', marginTop: '2px' }}>发布寻配需求</div>
</div>
<div onClick={() => window.location.hash = '#/add?mode=manual'} style={{
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
<div onClick={() => window.location.hash = '#/purchase'} style={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.02) 100%)',
borderRadius: '12px',
padding: '12px 16px',
border: '1px solid rgba(255,255,255,0.08)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
@ -230,8 +238,23 @@ export default function Home() {
justifyContent: 'center',
textAlign: 'center'
}}>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>发布藏品</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>手动发布</div>
<div style={{ color: '#3b82f6', fontSize: '18px', fontWeight: '600', whiteSpace: 'nowrap' }}>我的认购</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px', marginTop: '2px' }}>认购群入口</div>
</div>
<div onClick={() => window.location.hash = '#/wiki'} style={{
background: 'linear-gradient(135deg, rgba(251,191,36,0.15) 0%, rgba(251,191,36,0.05) 100%)',
borderRadius: '12px',
padding: '12px 16px',
border: '1px solid rgba(251,191,36,0.2)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center'
}}>
<div style={{ color: '#fbbf24', fontSize: '18px', fontWeight: '600', whiteSpace: 'nowrap' }}>📚 龙钞百科</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px', marginTop: '2px' }}>新手入门指南</div>
</div>
</div>
</div>
@ -257,7 +280,7 @@ export default function Home() {
</div>
<div>
<div style={{ color: '#a78bfa', fontSize: '18px', fontWeight: '700' }}>{seekStats.totalMatchedCount || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>总共匹配</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>总共匹配数据</div>
</div>
</div>
@ -399,14 +422,16 @@ export default function Home() {
{(isAdmin ? [
{ icon: '🏠', label: '首页', hash: '#/', idx: 0 },
{ icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
{ icon: '', label: '录入', hash: '#/ocr', idx: 2 },
{ icon: '📊', label: '统计', hash: '#/stats', idx: 3 },
{ icon: '⚙️', label: '管理', hash: '#/admin', idx: 4 }
{ icon: '🎯', label: '认购', hash: '#/purchase', idx: 2 },
{ icon: '', label: '录入', hash: '#/ocr', idx: 3 },
{ icon: '📊', label: '统计', hash: '#/stats', idx: 4 },
{ icon: '⚙️', label: '管理', hash: '#/admin', idx: 5 }
] : [
{ icon: '🏠', label: '首页', hash: '#/', idx: 0 },
{ icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
{ icon: '', label: '录入', hash: '#/ocr', idx: 2 },
{ icon: '📊', label: '统计', hash: '#/stats', idx: 3 }
{ icon: '🎯', label: '认购', hash: '#/purchase', idx: 2 },
{ icon: '', label: '录入', hash: '#/ocr', idx: 3 },
{ icon: '📊', label: '统计', hash: '#/stats', idx: 4 }
]).map((item) => (
<div key={item.idx} onClick={() => window.location.hash = item.hash} style={{
display: 'flex',
@ -421,6 +446,17 @@ export default function Home() {
))}
</div>
<div style={{ height: '70px' }}></div>
{/* 版本号显示 */}
<div style={{
position: 'fixed',
bottom: '70px',
right: '12px',
color: 'rgba(255,255,255,0.2)',
fontSize: '10px'
}}>
v0.0.2
</div>
</div>
)
}

View File

@ -1,11 +1,12 @@
/**
* List - 藏品列表页面
* Version: 1.2.93x
* 更新
* Version: 0.1.7
* 更新新增分享海报功能
*/
import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version'
import SharePoster from '../components/SharePoster'
const API_BASE = localStorage.getItem('API_BASE') || ''
export default function List() {
@ -30,6 +31,11 @@ export default function List() {
const [dealSortOrder, setDealSortOrder] = useState('desc')
const [myDeals, setMyDeals] = useState([])
const [dealsLoading, setDealsLoading] = useState(false)
//
const [dealVersionFilter, setDealVersionFilter] = useState('') //
const [dealPackagingFilter, setDealPackagingFilter] = useState('') //
const [dealNumberCatFilter, setDealNumberCatFilter] = useState('') //
const [shareCollection, setShareCollection] = useState(null) //
useEffect(() => {
//
@ -106,10 +112,33 @@ export default function List() {
//
const filteredDeals = myDeals.filter(deal => {
if (!dealSearch) return true
// 1.
if (dealSearch) {
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))
if (!fields.some(f => f.includes(s))) return false
}
// 2. - content
if (dealVersionFilter) {
let version = ''
if (deal.content) {
const match = deal.content.match(/版别:\s*([^\n]+)/)
if (match) version = match[1].trim()
}
if (version !== dealVersionFilter) return false
}
// 3.
if (dealPackagingFilter && deal.packaging !== dealPackagingFilter) return false
// 4. - content
if (dealNumberCatFilter) {
let numberCat = ''
if (deal.content) {
const match = deal.content.match(/号码分类:\s*([^\n]+)/)
if (match) numberCat = match[1].trim()
}
if (numberCat !== dealNumberCatFilter) return false
}
return true
}).sort((a, b) => {
let aVal = a[dealSortField] || ''
let bVal = b[dealSortField] || ''
@ -470,7 +499,26 @@ export default function List() {
}
const ListItem = ({ item }) => (
<div onClick={() => goDetail(item.id)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '12px', padding: '10px', marginBottom: '8px', cursor: 'pointer' }}>
<div onClick={() => goDetail(item.id)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '12px', padding: '10px', marginBottom: '8px', cursor: 'pointer', position: 'relative' }}>
{/* 分享按钮 */}
<button
onClick={(e) => { e.stopPropagation(); setShareCollection(item) }}
style={{
position: 'absolute',
top: '8px',
right: '8px',
background: 'rgba(251, 191, 36, 0.15)',
border: 'none',
borderRadius: '6px',
padding: '4px 8px',
fontSize: '12px',
cursor: 'pointer',
color: '#fbbf24',
zIndex: 10
}}
>
📤 分享
</button>
{/* 第1行编号 + 冠字号 + 包装(蓝色) | 状态 + 持仓类型 + 珍惜度(紫色) */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
@ -660,13 +708,13 @@ export default function List() {
{/* 行情搜索框 */}
<div style={{ marginBottom: '12px' }}>
<input type="text"
placeholder="🔍 搜索行情..."
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)',
border: dealSearch ? '1px solid rgba(59, 130, 246, 0.5)' : '1px solid rgba(255,255,255,0.1)',
color: '#fff',
padding: '10px 12px',
borderRadius: '8px',
@ -675,6 +723,134 @@ export default function List() {
boxSizing: 'border-box'
}}
/>
{dealSearch && (
<button
onClick={() => setDealSearch('')}
style={{
position: 'absolute',
right: '24px',
top: '50%',
transform: 'translateY(-50%)',
background: 'rgba(255,255,255,0.1)',
border: 'none',
borderRadius: '50%',
width: '24px',
height: '24px',
color: '#fff',
fontSize: '12px',
cursor: 'pointer'
}}
>
</button>
)}
</div>
{/* 筛选条件按钮 */}
<div style={{ marginBottom: '12px', padding: '10px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px' }}>
{/* 第一行:版别筛选 */}
<div style={{ marginBottom: '8px' }}>
<span style={{ color: '#94a3b8', fontSize: '11px', marginRight: '8px' }}>版别:</span>
{[
{ value: '', label: '全部' },
{ value: '20', label: '20版' },
{ value: '19', label: '19版' },
{ value: '18', label: '18版' },
{ value: '17', label: '17版' },
{ value: '16', label: '16版' },
].map(item => (
<button key={item.value} onClick={() => setDealVersionFilter(item.value)}
style={{
marginRight: '6px',
marginBottom: '4px',
padding: '4px 10px',
borderRadius: '6px',
fontSize: '11px',
cursor: 'pointer',
background: dealVersionFilter === item.value ? '#fbbf24' : 'rgba(255,255,255,0.08)',
color: dealVersionFilter === item.value ? '#1e293b' : '#94a3b8',
border: dealVersionFilter === item.value ? 'none' : '1px solid rgba(255,255,255,0.1)'
}}>
{item.label}
</button>
))}
</div>
{/* 第二行:包装类型筛选 */}
<div style={{ marginBottom: '8px' }}>
<span style={{ color: '#94a3b8', fontSize: '11px', marginRight: '8px' }}>包装:</span>
{[
{ value: '', label: '全部' },
{ value: '单张', label: '单张' },
{ value: '标十', label: '标十' },
{ value: '标百', label: '标百' },
].map(item => (
<button key={item.value} onClick={() => setDealPackagingFilter(item.value)}
style={{
marginRight: '6px',
marginBottom: '4px',
padding: '4px 10px',
borderRadius: '6px',
fontSize: '11px',
cursor: 'pointer',
background: dealPackagingFilter === item.value ? '#a78bfa' : 'rgba(255,255,255,0.08)',
color: dealPackagingFilter === item.value ? '#fff' : '#94a3b8',
border: dealPackagingFilter === item.value ? 'none' : '1px solid rgba(255,255,255,0.1)'
}}>
{item.label}
</button>
))}
</div>
{/* 第三行:号码分类筛选 */}
<div>
<span style={{ color: '#94a3b8', fontSize: '11px', marginRight: '8px' }}>号码:</span>
{[
{ value: '', label: '全部' },
{ value: '圆圆号', label: '圆圆号' },
{ value: '倒置号', label: '倒置号' },
{ value: '金马号', label: '金马号' },
{ value: '天马号', label: '天马号' },
{ value: '钻石号', label: '钻石号' },
{ value: '永恒号', label: '永恒号' },
{ value: '带7号', label: '带7号' },
{ value: '带4号', label: '带4号' },
].map(item => (
<button key={item.value} onClick={() => setDealNumberCatFilter(item.value)}
style={{
marginRight: '6px',
marginBottom: '4px',
padding: '4px 10px',
borderRadius: '6px',
fontSize: '11px',
cursor: 'pointer',
background: dealNumberCatFilter === item.value ? '#22c55e' : 'rgba(255,255,255,0.08)',
color: dealNumberCatFilter === item.value ? '#fff' : '#94a3b8',
border: dealNumberCatFilter === item.value ? 'none' : '1px solid rgba(255,255,255,0.1)'
}}>
{item.label}
</button>
))}
</div>
{/* 清除筛选按钮 */}
{(dealVersionFilter || dealPackagingFilter || dealNumberCatFilter) && (
<button
onClick={() => {
setDealVersionFilter('')
setDealPackagingFilter('')
setDealNumberCatFilter('')
}}
style={{
marginTop: '8px',
padding: '4px 10px',
borderRadius: '6px',
fontSize: '11px',
cursor: 'pointer',
background: 'rgba(239, 68, 68, 0.2)',
color: '#ef4444',
border: 'none'
}}>
清除筛选
</button>
)}
</div>
{dealsLoading ? (
@ -682,7 +858,16 @@ export default function List() {
) : 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 style={{ color: '#64748b', marginTop: '16px' }}>
{(dealSearch || dealVersionFilter || dealPackagingFilter || dealNumberCatFilter)
? '没有匹配的行情'
: '暂无行情记录'}
</div>
{(dealSearch || dealVersionFilter || dealPackagingFilter || dealNumberCatFilter) && (
<div style={{ color: '#64748b', fontSize: '11px', marginTop: '8px' }}>
已选筛选{dealSearch && `搜索"${dealSearch}" `}{dealVersionFilter && `版别${dealVersionFilter} `}{dealPackagingFilter && `包装${dealPackagingFilter} `}{dealNumberCatFilter && `号码${dealNumberCatFilter}`}
</div>
)}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
@ -949,6 +1134,17 @@ function DealListItem({ deal, onRefresh }) {
)}
</div>
)}
{/* 版本号显示 */}
<div style={{ position: 'fixed', bottom: '70px', right: '12px', color: 'rgba(255,255,255,0.2)', fontSize: '10px' }}>v0.0.1</div>
{/* 分享海报弹窗 */}
{shareCollection && (
<SharePoster
collection={shareCollection}
onClose={() => setShareCollection(null)}
/>
)}
</div>
)
}

View File

@ -1,7 +1,7 @@
/**
* Login - 登录页面
* Version: 1.2.85x
* 更新
* Version: 0.0.2 (2026-06-13)
* 更新移除注册时的手机号和短信验证功能
*/
import React, { useState, useEffect } from 'react'
@ -23,79 +23,22 @@ export default function Login() {
password: '',
confirmPassword: '',
email: '',
phone: '',
verifyCode: '',
inviteCode: ''
})
const [registerLoading, setRegisterLoading] = useState(false)
const [registerError, setRegisterError] = useState('')
//
const [sendingCode, setSendingCode] = useState(false)
const [codeCountdown, setCodeCountdown] = useState(0)
const [codeSent, setCodeSent] = useState(false)
//
useEffect(() => {
generateCaptcha()
}, [])
//
useEffect(() => {
if (codeCountdown > 0) {
const timer = setTimeout(() => setCodeCountdown(codeCountdown - 1), 1000)
return () => clearTimeout(timer)
}
}, [codeCountdown])
const generateCaptcha = () => {
const num1 = Math.floor(Math.random() * 10)
const num2 = Math.floor(Math.random() * 10)
setCaptcha({ num1, num2, answer: '' })
}
//
const handleSendCode = async () => {
if (!registerData.phone) {
setRegisterError('请先输入手机号')
return
}
//
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(registerData.phone)) {
setRegisterError('请输入正确的手机号')
return
}
setSendingCode(true)
setRegisterError('')
try {
const res = await fetch('/api/auth/send-verification-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ phone: registerData.phone })
})
const data = await res.json()
if (data.success) {
setCodeSent(true)
setCodeCountdown(60)
setRegisterError('')
} else {
setRegisterError(data.message || '发送失败')
}
} catch (err) {
setRegisterError('发送失败,请稍后重试')
} finally {
setSendingCode(false)
}
}
//
const handleRegister = async () => {
//
@ -124,33 +67,13 @@ export default function Login() {
return
}
//
if (!registerData.phone) {
setRegisterError('手机号为必填项')
return
}
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(registerData.phone)) {
setRegisterError('请输入正确的手机号')
return
}
//
if (!registerData.verifyCode) {
setRegisterError('请输入短信获取')
return
}
setRegisterLoading(true)
setRegisterError('')
try {
const payload = {
username: registerData.username,
password: registerData.password,
phone: registerData.phone,
verifyCode: registerData.verifyCode
password: registerData.password
}
if (registerData.email) {
payload.email = registerData.email
@ -175,9 +98,7 @@ export default function Login() {
alert('注册成功!请登录')
setShowRegister(false)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '', inviteCode: '' })
setCodeSent(false)
setCodeCountdown(0)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', inviteCode: '' })
generateCaptcha()
} catch (err) {
console.error('注册错误:', err)
@ -491,68 +412,6 @@ export default function Login() {
/>
</div>
{/* 手机号 */}
<div style={{ marginBottom: '16px' }}>
<input
type="tel"
value={registerData.phone}
onChange={(e) => setRegisterData(prev => ({ ...prev, phone: e.target.value }))}
placeholder="手机号11位"
maxLength={11}
style={{
width: '100%',
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
</div>
{/* 短信获取 */}
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="text"
value={registerData.verifyCode}
onChange={(e) => setRegisterData(prev => ({ ...prev, verifyCode: e.target.value }))}
placeholder="短信验证码"
maxLength={6}
style={{
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
<button
onClick={handleSendCode}
disabled={sendingCode || codeCountdown > 0}
style={{
minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: 'none',
background: codeCountdown > 0 ? 'rgba(148, 163, 184, 0.3)' : 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
color: '#fff',
fontSize: '14px',
fontWeight: '600',
cursor: codeCountdown > 0 ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap'
}}
>
{codeCountdown > 0 ? `${codeCountdown}` : sendingCode ? '发送中...' : '获取验证码'}
</button>
</div>
</div>
{/* 邮箱(可选) */}
<div style={{ marginBottom: '16px' }}>
<input
@ -656,10 +515,8 @@ export default function Login() {
<button
onClick={() => {
setShowRegister(false)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '', inviteCode: '' })
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', inviteCode: '' })
setRegisterError('')
setCodeSent(false)
setCodeCountdown(0)
}}
style={{
flex: 1, minWidth: "90px", width: "auto",
@ -696,6 +553,9 @@ export default function Login() {
</div>
</div>
)}
{/* 版本号显示 */}
<div style={{ position: 'fixed', bottom: '70px', right: '12px', color: 'rgba(255,255,255,0.2)', fontSize: '10px' }}>v0.0.1</div>
</div>
)
}

View File

@ -1,7 +1,7 @@
/**
* News - 资讯列表页面
* Version: 1.2.80x
* 更新
* Version: 0.1.2 (2026-05-13)
* 更新移除成交行情价格统计表格增加获取数量到500
*/
import React, { useState, useEffect } from 'react'
@ -39,11 +39,15 @@ export default function News() {
const [matchedStatus, setMatchedStatus] = useState({}) //
const [showMatchList, setShowMatchList] = useState(false)
const [dealVersion, setDealVersion] = useState('龙钞')
const [dealPackagingFilter, setDealPackagingFilter] = useState('') //
const [dealNumberCatFilter, setDealNumberCatFilter] = useState('') //
const [dealDate, setDealDate] = useState('')
const [dealDetailItems, setDealDetailItems] = useState(null) //
const [matchCollections, setMatchCollections] = useState([])
const [networkMatchCollections, setNetworkMatchCollections] = useState([])
const [showNetworkMatchList, setShowNetworkMatchList] = useState(false)
const [networkMatchInfoId, setNetworkMatchInfoId] = useState(null)
const [networkMatchTotalCount, setNetworkMatchTotalCount] = useState(0)
const [customModal, setCustomModal] = useState({show: false, title: '', content: ''})
const [seekForm, setSeekForm] = useState({
edition: '龙钞', type: '', category: '寻号', price: '', matchType: '模糊', features: '', content: '', title: '', contact: getUserPhone() || ''
@ -71,9 +75,10 @@ export default function News() {
fetchInfoList()
}, [activeTab, dealDate])
//
// 8
useEffect(() => {
const title = `「寻号 ${seekForm.edition || '龙钞,马钞,蛇钞'} J0${seekForm.features || 'XXXXXXXX'}`
const features = (seekForm.features || '').slice(0, 8).padEnd(8, 'X')
const title = `「寻号 ${seekForm.edition || '龙钞,马钞,蛇钞'} J0${features}`
setSeekForm(prev => ({...prev, title}))
}, [seekForm.edition, seekForm.features])
@ -92,7 +97,7 @@ export default function News() {
? `${API_BASE}/api/seek/list`
: `${API_BASE}/api/deal/list`
// 500
//
if (activeTab === 'deal') {
url += (url.includes('?') ? '&' : '?') + 'page_size=500'
if (dealDate) {
@ -144,7 +149,7 @@ export default function News() {
const fetchComments = async (infoId) => {
//
try {
const res = await fetch(`${API_BASE}/api/information/comments/${infoId}`)
const res = await fetch(`${API_BASE}/api/seek/comments/${infoId}`)
const data = await res.json()
setComments(prev => ({...prev, [infoId]: data || []}))
} catch (e) {
@ -158,7 +163,7 @@ export default function News() {
const token = localStorage.getItem('token')
if (!token) { alert('请先登录'); return }
try {
const res = await fetch(`${API_BASE}/api/information/comment`, {
const res = await fetch(`${API_BASE}/api/seek/comment`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ information_id: infoId, content: commentText })
@ -194,7 +199,7 @@ export default function News() {
if (!token) { alert('请先登录'); return }
try {
const phone = getUserPhone()
const res = await fetch(`${API_BASE}/api/information/seek/match-confirm`, {
const res = await fetch(`${API_BASE}/api/seek/match-confirm`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ info_id: infoId, contact: phone || '用户已同意' })
@ -231,7 +236,7 @@ export default function News() {
if (!token) { alert('请先登录'); return }
}
try {
const res = await fetch(`${API_BASE}/api/information/seek/matched-user/${infoId}`, { headers: { Authorization: `Bearer ${token}` } })
const res = await fetch(`${API_BASE}/api/seek/matched-user/${infoId}`, { headers: { Authorization: `Bearer ${token}` } })
console.log('Matched user response:', res.status)
const data = await res.json()
console.log('Matched user data:', data)
@ -244,7 +249,7 @@ export default function News() {
const token = localStorage.getItem('token')
if (!token) { alert('请先登录'); return }
try {
const res = await fetch(`${API_BASE}/api/information/seek/publisher/${infoId}`, { headers: { Authorization: `Bearer ${token}` } })
const res = await fetch(`${API_BASE}/api/seek/publisher/${infoId}`, { headers: { Authorization: `Bearer ${token}` } })
console.log('Publisher response:', res.status)
const data = await res.json()
console.log('Publisher data:', data)
@ -256,7 +261,7 @@ export default function News() {
const token = localStorage.getItem('token')
if (!token) { alert('请先登录'); return }
try {
const res = await fetch(`${API_BASE}/api/information/seek/match?info_id=${infoId}`, {
const res = await fetch(`${API_BASE}/api/seek/my-match?info_id=${infoId}`, {
headers: { Authorization: `Bearer ${token}` }
})
const data = await res.json()
@ -274,7 +279,7 @@ export default function News() {
//
const fetchNetworkMatchCollections = async (infoId) => {
try {
const res = await fetch(`${API_BASE}/api/information/seek/network-match/${infoId}`)
const res = await fetch(`${API_BASE}/api/seek/network-match/${infoId}`)
const data = await res.json()
console.log('网络数据匹配结果:', data)
setNetworkMatchCollections(data.collections || [])
@ -330,7 +335,7 @@ export default function News() {
try {
//
const content = (editingSeek.content || '') + '\n联系方式: ' + editingSeek.contact
await fetch(`${API_BASE}/api/information/${editingSeek.id}`, {
await fetch(`${API_BASE}/api/seek/${editingSeek.id}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -354,14 +359,13 @@ export default function News() {
const token = localStorage.getItem('token')
// editioncategory
const categoryMap = { '龙钞': '龙钞', '马钞': '马钞', '蛇钞': '蛇钞' }
const res = await fetch(`${API_BASE}/api/information/`, {
const res = await fetch(`${API_BASE}/api/seek/`, {
method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: seekForm.title,
content,
info_type: 'seek',
expect_category: seekForm.edition,
expect_number: seekForm.features ? 'J0' + seekForm.features : None
expect_number: seekForm.features ? 'J0' + seekForm.features : null
})
})
const data = await res.json()
@ -451,12 +455,22 @@ export default function News() {
onChange={(e) => {
if (i < 2) return
const val = e.target.value.toUpperCase().replace(/[^0-9XABCDEFG]/g, '')
if (!val) {
//
const newFeatures = (seekForm.features || '').split('')
newFeatures[i - 2] = ''
setSeekForm({...seekForm, features: newFeatures.join('').slice(0, 8)})
return
}
// 8
let currentLen = (seekForm.features || '').length
if (currentLen >= 8 && i - 2 >= currentLen) return // 8
const newFeatures = (seekForm.features || '').slice(0, 8).split('')
while (newFeatures.length < 8) newFeatures.push('')
newFeatures[i - 2] = val
setSeekForm({...seekForm, features: newFeatures.join('')})
setSeekForm({...seekForm, features: newFeatures.join('').slice(0, 8)})
//
if (val && i < 9) {
if (i < 9) {
setTimeout(() => {
const nextInput = document.querySelector(`input[data-index="${i+1}"]`)
if (nextInput) nextInput.focus()
@ -546,6 +560,22 @@ export default function News() {
else if (serial.startsWith('J1')) version = '马钞'
else if (serial.startsWith('J3')) version = '蛇钞'
if (version !== dealVersion) return false
//
if (dealPackagingFilter) {
const content = item.content || ''
const p = content.includes('包装:') ? content.split('包装:')[1].split('\n')[0].trim() : (item.packaging || '')
if (p !== dealPackagingFilter) return false
}
//
if (dealNumberCatFilter) {
const content = item.content || ''
let c = content.includes('分类:') ? content.split('分类:')[1].split('\n')[0].trim() : (item.category || '')
c = normalizeCat(c)
if (c !== dealNumberCatFilter) return false
}
return true
})
@ -558,13 +588,21 @@ export default function News() {
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 }
//
const sortedItems = [...items].sort((a, b) => {
const timeA = a.created_at || '1970-01-01T00:00:00'
const timeB = b.created_at || '1970-01-01T00:00:00'
return timeB.localeCompare(timeA)
})
const sum = sortedItems.reduce((a, b) => a + (b.deal_price || 0), 0)
return { avg: Math.round(sum / sortedItems.length), count: sortedItems.length, items: sortedItems }
}
return (
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ marginBottom: '16px' }}>
{/* 版别筛选 */}
<div style={{ color: '#94a3b8', fontSize: '11px', marginBottom: '8px' }}>版别筛选:</div>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '12px' }}>
{versions.map(v => (
<button key={v} onClick={() => setDealVersion(v)}
@ -575,60 +613,52 @@ export default function News() {
</button>
))}
</div>
{/* 包装类型筛选 */}
<div style={{ color: '#94a3b8', fontSize: '11px', marginBottom: '8px' }}>包装筛选:</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' }}>
{[{value: '', label: '全部'}, ...packagings.map(p => ({value: p, label: p}))].map(item => (
<button key={item.value} onClick={() => setDealPackagingFilter(item.value)}
style={{ padding: '4px 12px', borderRadius: '6px', border: 'none', cursor: 'pointer',
background: dealPackagingFilter === item.value ? '#8b5cf6' : 'rgba(255,255,255,0.1)',
color: '#fff', fontSize: '12px' }}>
{item.label}
</button>
))}
</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>
{/* 号码分类筛选 */}
<div style={{ color: '#94a3b8', fontSize: '11px', marginBottom: '8px' }}>号码分类:</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginBottom: '12px' }}>
{[
{value: '', label: '全部'},
{value: '带4号', label: '带4号'},
{value: '带7号', label: '带7号'},
{value: '永恒号', label: '永恒'},
{value: '钻石号', label: '钻石'},
{value: '天马号', label: '天马'},
{value: '金山号', label: '金山'},
{value: '金马号', label: '金马'},
{value: '倒置号', label: '倒置'},
{value: '圆圆号', label: '圆圆'}
].map(item => (
<button key={item.value} onClick={() => setDealNumberCatFilter(item.value)}
style={{ padding: '4px 10px', borderRadius: '6px', border: 'none', cursor: 'pointer',
background: dealNumberCatFilter === item.value ? '#22c55e' : 'rgba(255,255,255,0.08)',
color: '#fff', fontSize: '11px' }}>
{item.label}
</button>
))}
</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>
{/* 清除筛选 */}
{(dealPackagingFilter || dealNumberCatFilter) && (
<button onClick={() => { setDealPackagingFilter(''); setDealNumberCatFilter(''); }}
style={{ padding: '4px 10px', borderRadius: '6px', border: 'none', cursor: 'pointer',
background: 'rgba(239,68,68,0.2)', color: '#ef4444', fontSize: '11px' }}>
清除筛选
</button>
)}
</div>
</div>
)
@ -651,7 +681,10 @@ export default function News() {
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) => {
{/* 按创建时间倒序排序 */}
{dealDetailItems
.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''))
.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|$)/)
@ -684,7 +717,42 @@ export default function News() {
</div>
)}
{infoList.map(item => {
{infoList.filter(item => {
// tab -
if (activeTab === 'deal') {
const content = item.content || ''
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
//
const packagingMatch = content.match(/包装:\s*(.+?)(?:\n|$)/)
const packaging = packagingMatch ? packagingMatch[1].trim() : (item.packaging || '')
if (dealPackagingFilter && packaging !== dealPackagingFilter) return false
//
const categoryMatch = content.match(/分类:\s*(.+?)(?:\n|$)/)
let category = categoryMatch ? categoryMatch[1].trim() : (item.category || '')
//
if (category === '通货' || category === '无4') category = '带4号'
if (category === '永恒') category = '永恒号'
if (category === '钻石') category = '钻石号'
if (category === '天马' || category === '天马号') category = '天马号'
if (category === '金山' || category === '金山号') category = '金山号'
if (category === '金马' || category === '金马号') category = '金马号'
if (category === '倒置') category = '倒置号'
if (category === '圆圆') category = '圆圆号'
if (dealNumberCatFilter && category !== dealNumberCatFilter) return false
return true
}
return true
}).map(item => {
// tab -
if (activeTab === 'deal') {
const content = item.content || ''
@ -752,7 +820,7 @@ export default function News() {
</div>
{/* 创建日期 + 用户名 */}
<div style={{ color: '#64748b', fontSize: '12px', marginBottom: '8px' }}>
📅 {formatDate(item.created_at)} &nbsp;|&nbsp; 👤 {(item.content && item.content.match(/发布者: (.+)/)?.[1]) || item.user_name || '匿名用户'}
📅 {formatDate(item.created_at)} &nbsp;|&nbsp; 👤 {item.user_name || '匿名用户'}
</div>
{/* 号码特征 */}
{features && (
@ -807,7 +875,7 @@ export default function News() {
>
自有{item.matched_count || 0}条藏品匹配成功
</span>
{item.network_matched_count !== undefined && (
{item.network_matched_count !== undefined && item.network_matched_count > 0 && (
<span
style={{ color: '#3b82f6', fontSize: '13px', fontWeight: 'bold', cursor: 'pointer', marginLeft: '12px' }}
onClick={() => fetchNetworkMatchCollections(item.id)}
@ -952,7 +1020,10 @@ export default function News() {
<div style={{ marginBottom: '8px' }}>
{comments[item.id].map((c, idx) => (
<div key={c.id || idx} style={{ padding: '8px 0', borderBottom: '1px solid #334155' }}>
<div style={{ color: '#3b82f6', fontSize: '12px', marginBottom: '4px' }}>{c.user_name || '匿名用户'}</div>
<div style={{ color: '#3b82f6', fontSize: '12px', marginBottom: '4px' }}>
{c.user_name || '匿名用户'}
{c.created_at && <span style={{ color: '#64748b', marginLeft: '8px' }}>{new Date(c.created_at).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</span>}
</div>
<div style={{ color: '#e2e8f0', fontSize: '13px' }}>{c.content}</div>
</div>
))}
@ -1045,7 +1116,7 @@ export default function News() {
<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>
<h3 style={{ color: '#fff', margin: 0 }}>匹配藏品清单网络数据{networkMatchTotalCount > 0 ? `${networkMatchTotalCount}` : ''}</h3>
<button onClick={() => setShowNetworkMatchList(false)} style={{ background: 'transparent', border: 'none', color: '#94a3b8', fontSize: '20px', cursor: 'pointer' }}>×</button>
</div>
{networkMatchCollections.length === 0 ? (
@ -1245,6 +1316,9 @@ export default function News() {
</div>
</div>
)}
{/* 版本号显示 */}
<div style={{ position: 'fixed', bottom: '70px', right: '12px', color: 'rgba(255,255,255,0.2)', fontSize: '10px' }}>v0.0.1</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
/**
* News_YichensBoard - 一尘帖子页面
* Version: 1.2.75x
* Version: 0.0.1
* 更新
*/
@ -219,6 +219,9 @@ export default function YichensBoard() {
</div>
</div>
)}
{/* 版本号显示 */}
<div style={{ position: 'fixed', bottom: '70px', right: '12px', color: 'rgba(255,255,255,0.2)', fontSize: '10px' }}>v0.0.1</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
/**
* Settings - 设置页面
* Version: 1.2.75x
* Version: 0.0.1
* 更新
*/
@ -435,6 +435,9 @@ export default function Settings() {
退出登录
</button>
</div>
{/* 版本号显示 */}
<div style={{ position: 'fixed', bottom: '70px', right: '12px', color: 'rgba(255,255,255,0.2)', fontSize: '10px' }}>v0.0.1</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
/**
* Stats - 统计页面
* Version: 1.2.70x
* Version: 0.0.1
* 更新
*/

401
frontend/src/pages/Wiki.jsx Normal file
View File

@ -0,0 +1,401 @@
/**
* Wiki - 龙钞百科页面
* Version: 1.0.0
* 新手入门知识号码分类术语解释
*/
import React, { useState } from 'react'
import { APP_VERSION } from '../config/version'
export default function Wiki() {
const [activeSection, setActiveSection] = useState('intro')
//
const sections = {
intro: {
title: '🐉 什么是龙钞',
content: `
## 中国人民银行龙钞纪念钞
**龙钞**全称第24届冬季奥林匹克运动会纪念钞是中国人民银行于2022年发行的纪念钞包含**冰上运动****雪上运动**两枚一套
### 基本信息
| 项目 | 内容 |
|------|------|
| 发行时间 | 2022年12月 |
| 发行量 | 2亿套 |
| 面值 | 20 |
| 规格 | 145mm × 70mm |
### 收藏价值
1. **题材热门** - 冬奥会是国际大型赛事题材热度高
2. **发行量适中** - 2亿的发行量在纪念钞中属于中等
3. **设计精美** - 首次采用塑料基片+纸基的组合设计
4. **号码玩法多** - 冠字号+8位数字组合丰富多样
`
},
category: {
title: '🔢 号码分类',
content: `
## 龙钞号码分类大全
### 顶级号码收藏价值最高
| 分类 | 条件 | 优先级 |
|------|------|--------|
| 🏆 圆圆号 | 不含0123457 | 1 |
| 🔄 倒置号 | 不含23457 | 2 |
| 🐎 金马王 | 不含12347 | 3 |
| 🐎 金马号 | 不含2347 | 4 |
| 金山王 | 不含12457 | 5 |
| 天马王 | 不含1247 | 6 |
### 高级号码
| 分类 | 条件 | 备注 |
|------|------|------|
| 🏔 金山号 | 不含2457 | |
| 🐎 天马号 | 不含247 | |
| 🌫 朦胧王 | 不含13457 | |
| 🌫 朦胧号 | 不含3457 | |
| 💎 如意号 | 不含1347 | |
| 💎 钻石号 | 不含347 | |
| 永恒号 | 不含47 | |
### 普通号码
| 分类 | 条件 | 备注 |
|------|------|------|
| 🔥 带7号 | 不含4 | 不带4的号码 |
| 带4号 | 包含4 | 传统说法带4不太好 |
### 分类说明
- **"无X"规则**号码中不包含数字X
- **就高不就低**同时满足多个条件时按最高价值分类
- **散钞看8位**散钞看全部8位数字
- **标十看7位**标十看后7位
- **标百看6位**标百看后6位
`
},
terms: {
title: '📖 术语解释',
content: `
## 收藏术语大全
### 基础术语
| 术语 | 解释 |
|------|------|
| 冠字号 | J+8位数字如J01234567 |
| 散钞 | 单张收藏的钞票 |
| 标十 | 10连号一刀 |
| 标百 | 100连号一捆 |
| 整刀 | 100张连号 |
| 整捆 | 1000张连号 |
### 评级术语
| 术语 | 解释 |
|------|------|
| 评级 | 由专业机构鉴定真伪品相 |
| 评级公司 | PCGSNGCPMG等 |
| 分数 | 评级公司给出的品相分数70-100 |
| 69+ | 顶级分数收藏价值高 |
| 68 | 高分品相极佳 |
| 67 | 不错品相良好 |
### 状态术语
| 术语 | 解释 |
|------|------|
| 自持 | 自己收藏 |
| 寄存 | 存放在别人那里 |
| 寄售 | 委托他人代卖 |
| 送评 | 送去评级公司评级 |
| 修复 | 经过专业修复 |
### 价格术语
| 术语 | 解释 |
|------|------|
| 成本价 | 入手的价格 |
| 目标价 | 预期出售价格 |
| 成交价 | 实际交易价格 |
| 行情 | 当前市场的买卖价格 |
`
},
grade: {
title: '⭐ 评级知识',
content: `
## 评级公司介绍
### 国际三大评级公司
#### 1. PCGS美国
- 成立于1986年
- 业界权威评级标准严格
- 特别擅长现代币评级
#### 2. NGC美国
- 成立于1987年
- 评级数量大
- 封装设计美观
#### 3. PMG美国
- 专注于纸币评级
- 评级标准清晰
- 国内认知度高
### 评分标准
| 分数 | 描述 | 等级 |
|------|------|------|
| 70 | 完美未流通 | Gem MS |
| 69 | 接近完美 | Gem MS |
| 68 | 极美 | MS |
| 67 | 精美 | MS |
| 66 | 优美 | MS |
| 65 | 精美 | MS |
### 评级建议
1. **高端收藏** - 建议送评69分以上
2. **普通收藏** - 67-68分性价比高
3. **投资需求** - 选择68分以上的评级钞
### 注意事项
- 评级有风险可能降级
- 评级费用几十到几百元不等
- 评级周期通常2-4
`
},
care: {
title: '💎 保养指南',
content: `
## 龙钞保养指南
### 存放环境
#### 温度与湿度
- **最佳温度**18-25°C
- **最佳湿度**40-55%
- 避免温差大的环境
- 避免潮湿易发霉
#### 存放方式
- 使用专用纸币保护套
- 避免直接用手触摸
- 远离阳光直射
- 远离热源暖气空调
### 注意事项
#### 禁止行为
- 用手指直接触摸钞面
- 折叠弯曲钞票
- 在钞面上写字
- 暴露在潮湿环境中
- 靠近化学物品
#### 正确做法
- 佩戴手套操作
- 使用镊子夹取
- 平放保存
- 定期检查状态
- 使用干燥剂
### 常见问题
**Q: 钞票有折痕怎么办**
A: 建议送专业修复或评级遮盖瑕疵
**Q: 钞票发黄怎么办**
A: 避免潮湿环境可使用干燥剂
**Q: 如何判断品相**
A: 对光查看是否有磨损污渍折痕
`
},
platform: {
title: '🛒 交易指南',
content: `
## 交易注意事项
### 交易平台
1. **专业钱币交易平台**
- 一尘网
- 华夏收藏网
- 钱币天堂
2. **二手交易平台**
- 闲鱼需谨慎
3. **微信群交易**
- 诚信商家直供
- 群内私下交易
### 防骗指南
#### 常见骗局
- 低价诱惑先款后货
- 假称货在朋友处
- 改号换号
- 假评级假分数
#### 安全交易
1. **走平台交易**
- 使用有保障的平台
- 保留交易凭证
2. **先看后买**
- 要求实拍视频
- 确认号码无误
3. **金额较大**
- 建议走中介
- 或面交交易
### 价格参考
- **散钞**面值+10~50
- **标十**150-300
- **标百**1500-3000
- **特殊号码**价格面议
### 投资建议
1. 理性投资量力而行
2. 注重品相号码为辅
3. 长期持有静待升值
4. 分散投资不要all in
`
}
}
// Markdown
const renderContent = (content) => {
const lines = content.trim().split('\n')
return lines.map((line, idx) => {
//
if (line.startsWith('## ')) {
return <h3 key={idx} style={{ color: '#fbbf24', fontSize: '16px', marginTop: '20px', marginBottom: '10px', borderLeft: '3px solid #fbbf24', paddingLeft: '10px' }}>{line.replace('## ', '')}</h3>
}
//
if (line.startsWith('### ')) {
return <h4 key={idx} style={{ color: '#fff', fontSize: '14px', marginTop: '16px', marginBottom: '8px' }}>{line.replace('### ', '')}</h4>
}
//
if (line.startsWith('|') && !line.startsWith('|---')) {
const cells = line.split('|').filter(c => c.trim())
if (cells.length > 1) {
const isHeader = idx > 0 && lines[idx-1]?.includes('---')
return (
<div key={idx} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: '4px', padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
{cells.map((cell, i) => (
<div key={i} style={{ color: isHeader ? '#94a3b8' : '#e2e8f0', fontSize: '12px', padding: '4px', background: isHeader ? 'rgba(255,255,255,0.02)' : 'transparent' }}>
{cell.trim()}
</div>
))}
</div>
)
}
return null
}
//
if (line.startsWith('- ') || line.startsWith('1. ')) {
const text = line.replace(/^[-1-9.]\s*/, '')
return <div key={idx} style={{ color: '#cbd5e1', fontSize: '13px', paddingLeft: '16px', marginBottom: '6px', position: 'relative' }}><span style={{ position: 'absolute', left: '4px', color: '#fbbf24' }}></span>{text}</div>
}
//
if (line.trim()) {
//
let text = line.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#fbbf24">$1</strong>')
return <p key={idx} style={{ color: '#cbd5e1', fontSize: '13px', lineHeight: '1.8', marginBottom: '8px' }} dangerouslySetInnerHTML={{ __html: text }} />
}
return null
})
}
return (
<div style={{ minHeight: '100vh', background: '#0f172a', paddingBottom: '80px' }}>
{/* 头部 */}
<div style={{ padding: '20px', background: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)', borderBottom: '1px solid rgba(251,191,36,0.2)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button onClick={() => window.location.hash = '#/home'} style={{ background: 'none', border: 'none', color: '#fbbf24', fontSize: '18px', cursor: 'pointer' }}></button>
<h1 style={{ color: '#fff', fontSize: '20px', margin: 0 }}>📚 龙钞百科</h1>
</div>
<span style={{ color: 'rgba(255,255,255,0.3)', fontSize: '11px' }}>v{APP_VERSION}</span>
</div>
{/* 简介文字 */}
<p style={{ color: '#94a3b8', fontSize: '13px', margin: 0 }}>
新手入门指南 · 号码分类 · 术语解释 · 保养知识
</p>
</div>
{/* 导航标签 */}
<div style={{
display: 'flex',
gap: '6px',
padding: '12px 16px',
overflowX: 'auto',
background: 'rgba(255,255,255,0.02)'
}}>
{Object.entries(sections).map(([key, section]) => (
<button
key={key}
onClick={() => setActiveSection(key)}
style={{
padding: '8px 14px',
borderRadius: '20px',
border: 'none',
fontSize: '12px',
whiteSpace: 'nowrap',
cursor: 'pointer',
background: activeSection === key ? '#fbbf24' : 'rgba(255,255,255,0.08)',
color: activeSection === key ? '#1e293b' : '#94a3b8',
fontWeight: activeSection === key ? 'bold' : 'normal',
transition: 'all 0.2s'
}}
>
{section.title}
</button>
))}
</div>
{/* 内容区域 */}
<div style={{ padding: '16px' }}>
<div style={{
background: 'rgba(255,255,255,0.03)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(255,255,255,0.05)'
}}>
{renderContent(sections[activeSection].content)}
</div>
</div>
{/* 底部提示 */}
<div style={{
position: 'fixed',
bottom: '70px',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(251,191,36,0.1)',
padding: '8px 16px',
borderRadius: '20px',
border: '1px solid rgba(251,191,36,0.2)'
}}>
<span style={{ color: '#fbbf24', fontSize: '12px' }}>
💡 了解更多请访问 jiachenlong.com
</span>
</div>
</div>
)
}

View File

@ -1,7 +1,7 @@
/**
* YichensBoard - 一尘看板页面
* Version: 1.2.70x
* 更新
* Version: 0.0.5 (2026-05-13)
* 更新增加复制链接功能
*/
import React, { useState, useEffect } from 'react'
@ -15,6 +15,7 @@ export default function YichensBoard() {
const [expandedPosts, setExpandedPosts] = useState({})
const [postTypeFilter, setPostTypeFilter] = useState('all')
const [categoryFilter, setCategoryFilter] = useState('')
const [numberFilter, setNumberFilter] = useState('')
const [page, setPage] = useState(1)
const [totalPosts, setTotalPosts] = useState(0)
const [searchKeyword, setSearchKeyword] = useState('')
@ -41,18 +42,20 @@ export default function YichensBoard() {
}
}
const fetchPosts = async (p, cat, kw) => {
const fetchPosts = async (p, cat, kw, numFilter) => {
setLoading(true)
setError(null)
const currentPage = p !== undefined ? p : page
const currentCat = cat !== undefined ? cat : categoryFilter
const searchKw = kw !== undefined ? kw : searchKeyword
const numFilterVal = numFilter !== undefined ? numFilter : numberFilter
let url = '/api/yichens/posts?limit=390&offset=' + ((currentPage - 1) * 390)
if (postTypeFilter === 'deal') url += '&post_type=deal'
else if (postTypeFilter === 'want') url += '&post_type=want'
else if (postTypeFilter === 'other') url += '&post_type=normal'
if (searchKw && searchKw.trim()) url += '&keyword=' + encodeURIComponent(searchKw.trim())
if (numFilterVal && numFilterVal !== '') url += '&number_filter=' + encodeURIComponent(numFilterVal)
try {
console.log('YichensBoard: 请求 posts', url)
@ -75,6 +78,29 @@ export default function YichensBoard() {
data = data.filter(p => p.category && !p.category.includes('龙') && !p.category.includes('纪念钞') && !p.category.includes('蛇') && !p.category.includes('马'))
}
}
//
if (numberFilter && Array.isArray(data)) {
const filterKeywords = {
'带4': ['带4', '带四', '通货', '标四'],
'无4': ['无4', '无四', '带7'],
'无47': ['无47', '无四七', '永恒'],
'无247': ['无247', '无二四七', '天马', '金山'],
'无347': ['无347', '无三四七', '钻石', '如意', '朦胧', '金马'],
'年份': ['年份', '生日', '生日号', '纪念'],
'特色': ['倒置', '圆圆', '豹子', '老虎', '狮子', '大象', '龙三', '龙二']
}
const kw = filterKeywords[numberFilter] || []
if (kw.length > 0) {
data = data.filter(p => {
const text = (p.title || '') + (p.content || '') + (p.description || '')
return kw.some(k => text.includes(k))
})
}
// totalPosts
if (numberFilter && data.length > 0) {
setTotalPosts(data.length)
}
}
// data
if (!Array.isArray(data)) {
data = data.posts || data.data || []
@ -107,14 +133,14 @@ export default function YichensBoard() {
useEffect(() => {
if (todayStats.total > 0) {
fetchPosts(1, categoryFilter, searchKeyword)
fetchPosts(1, categoryFilter, searchKeyword, numberFilter)
}
}, [todayStats, categoryFilter, postTypeFilter])
}, [todayStats, categoryFilter, postTypeFilter, numberFilter])
useEffect(() => {
setPage(1)
fetchPosts(1, categoryFilter, searchKeyword)
}, [searchKeyword])
fetchPosts(1, categoryFilter, searchKeyword, numberFilter)
}, [searchKeyword, numberFilter])
const togglePost = id => setExpandedPosts(p => ({ ...p, [id]: !p[id] }))
@ -199,6 +225,16 @@ export default function YichensBoard() {
))}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
{[{key:'',label:'全部'},{key:'带4',label:'带4'},{key:'无4',label:'无4'},{key:'无47',label:'无47'},{key:'无247',label:'无247'},{key:'无347',label:'无347'},{key:'年份',label:'年份'},{key:'特色',label:'其他'}].map(k => (
<button key={k.key} onClick={() => { setNumberFilter(k.key); setPage(1); }}
style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
background: numberFilter===k.key ? 'linear-gradient(135deg, #ec4899 0%, #db2777 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 }}>
@ -229,7 +265,12 @@ export default function YichensBoard() {
<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>}
{post.url && (
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<a href={post.url} target='_blank' rel='noopener noreferrer' style={{ padding:'8px 16px', background:'rgba(59,130,246,0.2)', borderRadius:8, color:'#60a5fa', textDecoration:'none', fontSize:13 }}>查看原帖</a>
<button onClick={() => { navigator.clipboard.writeText(post.url); alert('链接已复制!'); }} style={{ padding:'8px 16px', background:'rgba(34,197,94,0.2)', borderRadius:8, color:'#22c55e', border:'none', cursor:'pointer', fontSize:13 }}>复制链接</button>
</div>
)}
</div>
)}
</div>
@ -243,12 +284,12 @@ export default function YichensBoard() {
</div>
<div style={{ display: 'flex', gap: 8 }}>
{page > 1 ? (
<button onClick={() => { const newPage = page - 1; setPage(newPage); fetchPosts(newPage, categoryFilter, searchKeyword) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>上一页</button>
<button onClick={() => { const newPage = page - 1; setPage(newPage); fetchPosts(newPage, categoryFilter, searchKeyword, numberFilter) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>上一页</button>
) : (
<span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>上一页</span>
)}
{posts.length >= 390 && totalPosts > page * 390 ? (
<button onClick={() => { const newPage = page + 1; setPage(newPage); fetchPosts(newPage, categoryFilter, searchKeyword) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>下一页</button>
{posts.length >= 390 ? (
<button onClick={() => { const newPage = page + 1; setPage(newPage); fetchPosts(newPage, categoryFilter, searchKeyword, numberFilter) }} 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>
)}
@ -256,6 +297,9 @@ export default function YichensBoard() {
</div>
</div>
)}
{/* 版本号显示 */}
<div style={{ position: 'fixed', bottom: '70px', right: '12px', color: 'rgba(255,255,255,0.2)', fontSize: '10px' }}>v0.0.3</div>
</div>
)
}

View File

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

136
scripts/ci_version_sync.sh Executable file
View File

@ -0,0 +1,136 @@
#!/bin/bash
# ========================================
# CI/CD 版本自动同步脚本
# 每次代码更新后自动运行
# ========================================
set -e
REPO_DIR="/root/.openclaw/workspace/jiachenlong"
VERSION_FILE="$REPO_DIR/config/VERSION.json"
CHANGELOG_FILE="$REPO_DIR/config/CHANGELOG.md"
cd "$REPO_DIR"
echo "========================================="
echo " CI/CD 版本自动同步"
echo "========================================="
# 检查是否有未提交的更改
if [ -n "$(git status --porcelain)" ]; then
echo "⚠️ 有未提交的更改,请先提交"
exit 1
fi
# 获取当前版本
CURRENT_VERSION=$(python3 -c "import json; print(json.load(open('$VERSION_FILE'))['version'])")
echo "📌 当前版本: $CURRENT_VERSION"
# 解析版本号
MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1)
MINOR=$(echo $CURRENT_VERSION | cut -d. -f2)
PATCH=$(echo $CURRENT_VERSION | cut -d. -f3)
# 询问是否升级版本
echo ""
echo "请选择版本升级类型:"
echo " 1) PATCH (补丁): ${MAJOR}.${MINOR}.$((PATCH+1))"
echo " 2) MINOR (小版本): ${MAJOR}.$((MINOR+1)).0"
echo " 3) MAJOR (大版本): $((MAJOR+1)).0.0"
echo " 4) 自定义版本"
read -p "选择 (1-4): " choice
case $choice in
1) NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH+1))";;
2) NEW_VERSION="${MAJOR}.$((MINOR+1)).0";;
3) NEW_VERSION="$((MAJOR+1)).0.0";;
4) read -p "输入新版本号: " NEW_VERSION;;
*) NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH+1))";;
esac
echo "📌 新版本: $NEW_VERSION"
# 更新 VERSION.json
python3 << EOF
import json
with open('$VERSION_FILE') as f:
v = json.load(f)
old_version = v['version']
v['version'] = '$NEW_VERSION'
v['updated'] = '$(date +%Y-%m-%d)'
# 更新主版本号到所有模块
for section in ['frontend', 'backend']:
if 'pages' in v['modules'][section]:
for k in v['modules'][section]['pages']:
v['modules'][section]['pages'][k] = '$NEW_VERSION'
if 'routers' in v['modules'][section]:
for k in v['modules'][section]['routers']:
v['modules'][section]['routers'][k] = '$NEW_VERSION'
if 'config' in v['modules'][section]:
for k in v['modules'][section]['config']:
v['modules'][section]['config'][k] = '$NEW_VERSION'
if 'app' in v['modules'][section]:
for k in v['modules'][section]['app']:
v['modules'][section]['app'][k] = '$NEW_VERSION'
with open('$VERSION_FILE', 'w') as f:
json.dump(v, f, indent=2, ensure_ascii=False)
print(f"✅ VERSION.json 已更新: {old_version} → $NEW_VERSION")
EOF
# 同步到所有文件头部
python3 << 'EOF'
import json
import re
import os
VERSION = '$NEW_VERSION'
with open('$VERSION_FILE') as f:
v = json.load(f)
frontend_pages = v['modules']['frontend']['pages']
backend_routers = v['modules']['backend']['routers']
# 前端文件
for fname in frontend_pages:
fpath = f"$REPO_DIR/frontend/src/pages/{fname}.jsx"
if os.path.exists(fpath):
with open(fpath, 'r') as f:
content = f.read()
new_content = re.sub(r'Version: [\d.]+', f'Version: {VERSION}', content, count=1)
new_content = re.sub(r'\d{4}-\d{2}-\d{2}', '$(date +%Y-%m-%d)', new_content, count=1)
with open(fpath, 'w') as f:
f.write(new_content)
print(f" ✅ {fname}.jsx")
# 后端文件
for fname in backend_routers:
fpath = f"$REPO_DIR/backend/app/routers/{fname}.py"
if os.path.exists(fpath):
with open(fpath, 'r') as f:
content = f.read()
new_content = re.sub(r'Version: [\d.]+', f'Version: {VERSION}', content, count=1)
new_content = re.sub(r'\d{4}-\d{2}-\d{2}', '$(date +%Y-%m-%d)', new_content, count=1)
with open(fpath, 'w') as f:
f.write(new_content)
print(f" ✅ {fname}.py")
print(f"✅ 所有文件版本已同步到 {VERSION}")
EOF
# 提交更改
git add .
git commit -m "v$NEW_VERSION - 自动版本更新"
git push
echo ""
echo "========================================="
echo " ✅ 版本更新完成!"
echo "========================================="
echo "新版本: $NEW_VERSION"
echo "已推送到远程仓库"

93
scripts/version_sync.sh Executable file
View File

@ -0,0 +1,93 @@
#!/bin/bash
# ========================================
# 版本同步脚本 - 只更新修改的文件
# 用法: ./scripts/version_sync.sh [patch|minor|major|custom]
# ========================================
set -e
REPO_DIR="/root/.openclaw/workspace/jiachenlong"
VERSION_FILE="$REPO_DIR/config/VERSION.json"
cd "$REPO_DIR"
# 获取当前版本
CURRENT=$(python3 -c "import json; print(json.load(open('$VERSION_FILE'))['version'])")
echo "📌 当前版本: $CURRENT"
# 解析版本号
MAJOR=$(echo $CURRENT | cut -d. -f1)
MINOR=$(echo $CURRENT | cut -d. -f2)
PATCH=$(echo $CURRENT | cut -d. -f3)
# 根据参数决定新版本
case "${1:-patch}" in
patch) NEW="$MAJOR.$MINOR.$((PATCH+1))";;
minor) NEW="$MAJOR.$((MINOR+1)).0";;
major) NEW="$((MAJOR+1)).0.0";;
*) NEW="${1:-$CURRENT}";;
esac
echo "📌 新版本: $NEW"
# 1. 更新VERSION.json主版本号
python3 << EOF
import json
with open('$VERSION_FILE') as f:
v = json.load(f)
v['version'] = '$NEW'
v['updated'] = '$(date +%Y-%m-%d)'
with open('$VERSION_FILE', 'w') as f:
json.dump(v, f, indent=2, ensure_ascii=False)
print("✅ VERSION.json 主版本已更新")
EOF
# 2. 同步VERSION.json中的版本到文件头部只同步VERSION.json中已记录的版本
python3 << 'EOF'
import json
import re
import os
VERSION = '$NEW'
REPO = '$REPO_DIR'
with open(f'{REPO}/config/VERSION.json') as f:
v = json.load(f)
pages = v['modules']['frontend']['pages']
routers = v['modules']['backend']['routers']
# 前端
for fname, ver in pages.items():
fpath = f'{REPO}/frontend/src/pages/{fname}.jsx'
if os.path.exists(fpath):
with open(fpath, 'r') as f:
content = f.read()
# 只更新VERSION.json中记录的版本
new_content = re.sub(r'Version: [\d.]+', f'Version: {ver}', content, count=1)
with open(fpath, 'w') as f:
f.write(new_content)
print(f" ✅ {fname}.jsx: {ver}")
# 后端
for fname, ver in routers.items():
fpath = f'{REPO}/backend/app/routers/{fname}.py'
if os.path.exists(fpath):
with open(fpath, 'r') as f:
content = f.read()
new_content = re.sub(r'Version: [\d.]+', f'Version: {ver}', content, count=1)
with open(fpath, 'w') as f:
f.write(new_content)
print(f" ✅ {fname}.py: {ver}")
print("✅ 所有文件版本已同步")
EOF
# 3. 提交
git add .
git commit -m "v$NEW - 版本自动更新" || echo "无新更改"
echo ""
echo "✅ 完成!版本: $CURRENT$NEW"