Compare commits

...

8 Commits

11 changed files with 867 additions and 40 deletions

View File

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

View File

@ -16,6 +16,7 @@ from app.routers import auth, collections, operations
from app.routers import ocr as ocr_router from app.routers import ocr as ocr_router
from app.routers import users as users_router from app.routers import users as users_router
from app.routers import information as information_router from app.routers import information as information_router
from app.routers import yichens as yichens_router
# 版本信息 - 从 config/VERSION 文件读取 # 版本信息 - 从 config/VERSION 文件读取
def get_version(): def get_version():
@ -65,10 +66,10 @@ uploads_dir = "uploads"
os.makedirs(uploads_dir, exist_ok=True) os.makedirs(uploads_dir, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads") app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
# 挂载项目静态资源目录(可选,生产环境建议用 Nginx # 挂载项目静态资源目录
# static_dir = Path(__file__).parent.parent.parent / "static" static_dir = Path(__file__).parent.parent / "static"
# if static_dir.exists(): if static_dir.exists():
# app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# 添加日志中间件 # 添加日志中间件
app.middleware("http")(logging_middleware) app.middleware("http")(logging_middleware)
@ -80,7 +81,8 @@ app.include_router(operations.router)
app.include_router(ocr_router.router) # OCR 识别 app.include_router(ocr_router.router) # OCR 识别
app.include_router(users_router.router) # 当前用户接口 app.include_router(users_router.router) # 当前用户接口
app.include_router(users_router.admin_router) # 管理员用户管理 app.include_router(users_router.admin_router) # 管理员用户管理
app.include_router(information_router.router) # 资讯 app.include_router(information_router.router)
app.include_router(yichens_router.router) # 一尘看板
@app.get("/") @app.get("/")

View File

@ -163,7 +163,7 @@ class Information(Base):
# 内容描述 # 内容描述
content = Column(Text, nullable=True) content = Column(Text, nullable=True)
# 关联藏品ID # 关联藏品ID
collection_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="SET NULL"), nullable=True) collection_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="SET NULL"), nullable=True)

View File

@ -1,12 +1,14 @@
# 资讯API路由 # 资讯API路由
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from sqlalchemy import text
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, date from datetime import datetime, date
from app.core.database import get_db from app.core.database import get_db
from app.core.auth import get_current_user from app.core.auth import get_current_user
from app.core.coolbot_db import coolbot_engine
from app.models.models import User, Information, Collection from app.models.models import User, Information, Collection
router = APIRouter(prefix="/api/information", tags=["资讯"]) router = APIRouter(prefix="/api/information", tags=["资讯"])
@ -16,7 +18,7 @@ router = APIRouter(prefix="/api/information", tags=["资讯"])
class InformationCreate(BaseModel): class InformationCreate(BaseModel):
info_type: str # seek-寻配号, deal-成交数据, publish-发布 info_type: str # seek-寻配号, deal-成交数据, publish-发布
title: str title: str
content: Optional[str] = None content: Optional[str]
collection_id: Optional[str] = None collection_id: Optional[str] = None
expect_category: Optional[str] = None expect_category: Optional[str] = None
expect_version: Optional[str] = None expect_version: Optional[str] = None
@ -30,7 +32,7 @@ class InformationCreate(BaseModel):
class InformationUpdate(BaseModel): class InformationUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
content: Optional[str] = None content: Optional[str]
status: Optional[str] = None status: Optional[str] = None
expect_category: Optional[str] = None expect_category: Optional[str] = None
expect_version: Optional[str] = None expect_version: Optional[str] = None
@ -74,6 +76,8 @@ class InformationResponse(BaseModel):
collection_number: Optional[str] = None collection_number: Optional[str] = None
# 匹配数量(我的藏品中满足条件的数量) # 匹配数量(我的藏品中满足条件的数量)
matched_count: Optional[int] = 0 matched_count: Optional[int] = 0
# 网络数据匹配数量coolbot_data数据库中满足条件的数量
network_matched_count: Optional[int] = 0
class Config: class Config:
from_attributes = True from_attributes = True
@ -87,7 +91,8 @@ def get_information_list(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
current_user: Optional[User] = Depends(get_current_user), current_user: Optional[User] = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db),
response: Response = None
): ):
"""获取资讯列表(公开,无需登录)""" """获取资讯列表(公开,无需登录)"""
query = db.query(Information).options( query = db.query(Information).options(
@ -142,8 +147,21 @@ def get_information_list(
collection_version=item.collection.f02_11_version if item.collection else None, collection_version=item.collection.f02_11_version if item.collection else None,
collection_number=item.collection.f02_10_prefix_serial if item.collection else None, collection_number=item.collection.f02_10_prefix_serial if item.collection else None,
matched_count=matched_count, matched_count=matched_count,
network_matched_count=match_collections_count_from_coolbot(item.expect_number) if item.info_type == 'seek' and item.expect_number else 0,
)) ))
# 获取总数并设置响应头
from fastapi import Response
total_query = db.query(Information).filter(Information.status == status)
if info_type:
total_query = total_query.filter(Information.info_type == info_type)
total_count = total_query.count()
total_pages = (total_count + page_size - 1) // page_size
# 设置响应头
response.headers['X-Total-Pages'] = str(total_pages)
response.headers['X-Total-Count'] = str(total_count)
return result return result
@ -182,6 +200,110 @@ def match_collections_count(db: Session, user_id: str, expect_number: str) -> in
return count return count
def match_collections_count_from_coolbot(expect_number: str) -> int:
"""根据号码特征计算匹配藏品数量从coolbot_data数据库"""
if not expect_number or len(expect_number) != 10:
return 0
# 固定前缀
if not expect_number.startswith('J0'):
return 0
pattern = expect_number[2:] # 后8位
if not pattern:
return 0
# 直接查询coolbot_data数据库
query = text("""
SELECT COUNT(*) FROM collections
WHERE crown_code IS NOT NULL
AND crown_code != ''
AND LENGTH(crown_code) >= 10
AND crown_code LIKE 'J0%'
""")
try:
with coolbot_engine.connect() as conn:
result = conn.execute(query)
total_count = result.scalar() or 0
# 遍历匹配
query_all = text("""
SELECT id, crown_code FROM collections
WHERE crown_code IS NOT NULL
AND crown_code != ''
AND LENGTH(crown_code) >= 10
AND crown_code LIKE 'J0%'
""")
result = conn.execute(query_all)
match_count = 0
for row in result:
crown_code = row[1]
if crown_code and len(crown_code) >= 10:
col_pattern = crown_code[2:10]
if match_pattern(col_pattern, pattern):
match_count += 1
return match_count
except Exception as e:
print(f"Error querying coolbot_data: {e}")
return 0
def match_collections_list_from_coolbot(expect_number: str, limit: int = 20) -> List[dict]:
"""获取匹配的藏品列表从coolbot_data数据库"""
if not expect_number or len(expect_number) != 10:
return []
# 固定前缀
if not expect_number.startswith('J0'):
return []
pattern = expect_number[2:] # 后8位
if not pattern:
return []
# 直接查询coolbot_data数据库
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 LENGTH(crown_code) >= 10
AND crown_code LIKE 'J0%'
""")
try:
with coolbot_engine.connect() as conn:
result = conn.execute(query)
matched = []
for row in result:
crown_code = row[3]
if crown_code and len(crown_code) >= 10:
col_pattern = crown_code[2:10]
if match_pattern(col_pattern, pattern):
matched.append({
"id": row[0],
"name": row[1],
"category": row[2],
"crown_code": crown_code,
"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 querying coolbot_data: {e}")
return []
def match_pattern(col_number: str, pattern: str) -> bool: def match_pattern(col_number: str, pattern: str) -> bool:
"""匹配号码特征模式""" """匹配号码特征模式"""
# X = 任意数字 # X = 任意数字
@ -502,7 +624,36 @@ def get_seek_match(
"cost_price": c.f05_40_cost_price, "cost_price": c.f05_40_cost_price,
} }
for c in matched for c in matched
] ],
"network_matched_count": match_collections_count_from_coolbot(info.expect_number) if info.expect_number else 0,
"network_collections": match_collections_list_from_coolbot(info.expect_number, limit=20) if info.expect_number else []
}
# 获取网络数据匹配列表
@router.get("/seek/network-match/{info_id}")
def get_network_match(
info_id: str,
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)
):
"""获取一尘数据库中匹配的藏品列表"""
info = db.query(Information).filter(
Information.id == info_id,
Information.info_type == "seek"
).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)
return {
"matched_count": len(matched),
"collections": matched
} }
@ -558,8 +709,21 @@ def get_my_seeks(
collection_version=item.collection.f02_11_version if item.collection else None, collection_version=item.collection.f02_11_version if item.collection else None,
collection_number=item.collection.f02_10_prefix_serial if item.collection else None, collection_number=item.collection.f02_10_prefix_serial if item.collection else None,
matched_count=matched_count, matched_count=matched_count,
network_matched_count=match_collections_count_from_coolbot(item.expect_number) if item.info_type == 'seek' and item.expect_number else 0,
)) ))
# 获取总数并设置响应头
from fastapi import Response
total_query = db.query(Information).filter(Information.status == status)
if info_type:
total_query = total_query.filter(Information.info_type == info_type)
total_count = total_query.count()
total_pages = (total_count + page_size - 1) // page_size
# 设置响应头
response.headers['X-Total-Pages'] = str(total_pages)
response.headers['X-Total-Count'] = str(total_count)
return result return result

View File

@ -0,0 +1,252 @@
from fastapi import APIRouter, Depends, Query
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", response_model=List[PostItem])
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,
db: Session = Depends(get_coolbot_db)
):
"""获取帖子列表"""
query = """
SELECT post_id, title, content, category, post_type, price,
author_username, post_time, reply_count, view_count, url
FROM yichens_posts
WHERE 1=1
"""
params = {"limit": limit, "offset": offset}
if category:
query += " AND category = :category"
params["category"] = category
if post_type:
query += " AND post_type = :post_type"
params["post_type"] = post_type
query += " ORDER BY post_time DESC LIMIT :limit OFFSET :offset"
results = db.execute(text(query), params).fetchall()
return [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]
@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]

View File

@ -1 +1 @@
VERSION=1.2.41 VERSION=1.2.50

View File

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

View File

@ -5,6 +5,8 @@ export default function Home() {
const [user, setUser] = useState(null) const [user, setUser] = useState(null)
const [stats, setStats] = useState({ totalCount: 0, totalCost: 0, totalRevenue: 0, expectedProfit: 0, totalProfit: 0, gradedCount: 0 }) const [stats, setStats] = useState({ totalCount: 0, totalCost: 0, totalRevenue: 0, expectedProfit: 0, totalProfit: 0, gradedCount: 0 })
const [recentCollections, setRecentCollections] = useState([]) const [recentCollections, setRecentCollections] = useState([])
const [yichensStats, setYichensStats] = useState({ total: 0, deals: 0, wants: 0, dragons: 0, horses: 0, tianma: 0 })
const [recentPosts, setRecentPosts] = useState([])
const currentPath = window.location.hash.slice(1) || '/' const currentPath = window.location.hash.slice(1) || '/'
useEffect(() => { useEffect(() => {
@ -54,6 +56,18 @@ export default function Home() {
} }
}, []) }, [])
//
useEffect(() => {
fetch('/api/yichens/stats/today').then(res => res.json()).then(data => {
setYichensStats(data || {})
}).catch(() => {})
//
fetch('/api/yichens/posts?limit=10&offset=0').then(res => res.json()).then(data => {
setRecentPosts(Array.isArray(data) ? data : [])
}).catch(() => {})
}, [])
// //
const isAdmin = user && user.role === 'admin' const isAdmin = user && user.role === 'admin'
@ -96,7 +110,7 @@ export default function Home() {
backdropFilter: 'blur(10px)' backdropFilter: 'blur(10px)'
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<img src="/static/images/jiachenlong-logo.png?v=1.2.4" alt="甲辰收藏" style={{ width: '48px', height: '48px', borderRadius: '50%', objectFit: 'cover' }} /> <img src="/static/images/jiachenlong-logo.png" alt="甲辰收藏" style={{ width: '48px', height: '48px', borderRadius: '50%', objectFit: 'cover' }} />
<div> <div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '600' }}>{user?.username || '用户'}</div> <div style={{ color: '#fff', fontSize: '18px', fontWeight: '600' }}>{user?.username || '用户'}</div>
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: '12px' }}>欢迎回来 👋</div> <div style={{ color: 'rgba(255,255,255,0.6)', fontSize: '12px' }}>欢迎回来 👋</div>
@ -175,11 +189,42 @@ export default function Home() {
</div> </div>
</div> </div>
{/* 最近藏品 */} {/* 一尘今日数据 */}
<div style={{ marginBottom: '20px' }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', marginBottom: '12px', paddingLeft: '4px' }}>📊 一尘今日数据</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '8px' }}>
<div style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.15) 0%, rgba(59,130,246,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(59,130,246,0.2)', textAlign: 'center' }}>
<div style={{ color: '#60a5fa', fontSize: '20px', fontWeight: '700' }}>{yichensStats.total || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>总帖子</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(16,185,129,0.15) 0%, rgba(16,185,129,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(16,185,129,0.2)', textAlign: 'center' }}>
<div style={{ color: '#34d399', fontSize: '20px', fontWeight: '700' }}>{yichensStats.deals || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>出售</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(245,158,11,0.15) 0%, rgba(245,158,11,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(245,158,11,0.2)', textAlign: 'center' }}>
<div style={{ color: '#fbbf24', fontSize: '20px', fontWeight: '700' }}>{yichensStats.wants || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>求购</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(139,92,246,0.15) 0%, rgba(139,92,246,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(139,92,246,0.2)', textAlign: 'center' }}>
<div style={{ color: '#a78bfa', fontSize: '20px', fontWeight: '700' }}>{yichensStats.dragons || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>龙钞</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(236,72,153,0.15) 0%, rgba(236,72,153,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(236,72,153,0.2)', textAlign: 'center' }}>
<div style={{ color: '#f472b6', fontSize: '20px', fontWeight: '700' }}>{yichensStats.horses || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>马钞</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(20,184,166,0.15) 0%, rgba(20,184,166,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(20,184,166,0.2)', textAlign: 'center' }}>
<div style={{ color: '#2dd4bf', fontSize: '20px', fontWeight: '700' }}>{yichensStats.tianma || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>天马</div>
</div>
</div>
</div>
{/* 最新一尘发帖 */}
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', paddingLeft: '4px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', paddingLeft: '4px' }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px' }}>最近藏品</div> <div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px' }}>📝 最新一尘发帖</div>
<div onClick={() => window.location.hash = '#/list'} style={{ color: '#3b82f6', fontSize: '12px', cursor: 'pointer' }}>查看全部 </div> <div onClick={() => window.location.hash = '#/news'} style={{ color: '#3b82f6', fontSize: '12px', cursor: 'pointer' }}>查看全部 </div>
</div> </div>
<div style={{ <div style={{
background: 'rgba(255,255,255,0.03)', background: 'rgba(255,255,255,0.03)',
@ -187,24 +232,30 @@ export default function Home() {
border: '1px solid rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.05)',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
{recentCollections.length === 0 ? ( {recentPosts.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>暂无藏品</div> <div style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>暂无帖子</div>
) : ( ) : (
recentCollections.map((item, idx) => ( recentPosts.map((item, idx) => (
<div key={item.id || idx} onClick={() => window.location.hash = '#/detail?id=' + item.id} style={{ <div key={item.post_id || idx} onClick={() => window.location.hash = '#/news'} style={{
padding: '12px 16px', padding: '12px 16px',
borderBottom: idx < recentCollections.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none', borderBottom: idx < recentPosts.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none',
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center' alignItems: 'center'
}}> }}>
<div> <div style={{ flex: 1 }}>
<div style={{ color: '#fff', fontSize: '14px' }}>{item.code || '-'} <span style={{ color: 'rgba(255,255,255,0.4)', fontSize: '14px', fontWeight: 'bold' }}>{item.prefixSerial || ''}</span></div> <div style={{ color: '#fff', fontSize: '14px' }}>{item.title || '无标题'}</div>
<div style={{ color: 'rgba(255,255,255,0.4)', fontSize: '12px', marginTop: '2px' }}>{item.version || '-'} · {item.status === 'sold' ? '已售' : item.status === 'in_collection' ? '收藏中' : item.status}</div> <div style={{ color: 'rgba(255,255,255,0.4)', fontSize: '12px', marginTop: '2px' }}>{item.category || '-'} · {item.author_username || '未知'} · {item.post_time?.substring(0, 16) || ''}</div>
</div> </div>
<div style={{ color: item.costPrice ? '#22c55e' : 'rgba(255,255,255,0.3)', fontSize: '13px' }}> <div style={{
{item.costPrice ? '¥' + item.costPrice : '-'} color: item.post_type === 'deal' ? '#10b981' : item.post_type === 'want' ? '#f59e0b' : '#8b5cf6',
fontSize: '12px',
padding: '2px 8px',
borderRadius: '4px',
background: item.post_type === 'deal' ? 'rgba(16,185,129,0.2)' : item.post_type === 'want' ? 'rgba(245,158,11,0.2)' : 'rgba(139,92,246,0.2)'
}}>
{item.post_type === 'deal' ? '出售' : item.post_type === 'want' ? '求购' : '其他'}
</div> </div>
</div> </div>
)) ))

View File

@ -672,6 +672,9 @@ export default function Info() {
</div> </div>
</div> </div>
)} )}
</div> </div>
) )
} }

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import YichensBoard from './YichensBoard'
// - // -
const getUserPhone = () => { const getUserPhone = () => {
@ -22,7 +23,7 @@ const getStoredUserId = () => {
// - // -
export default function News() { export default function News() {
const [activeTab, setActiveTab] = useState(localStorage.getItem('news_activeTab') || 'seek') const [activeTab, setActiveTab] = useState(localStorage.getItem('news_activeTab') || 'yichen')
const [showSeekPublish, setShowSeekPublish] = useState(false) const [showSeekPublish, setShowSeekPublish] = useState(false)
const [showMySeeks, setShowMySeeks] = useState(false) // const [showMySeeks, setShowMySeeks] = useState(false) //
const [showContactId, setShowContactId] = useState(null) // ID const [showContactId, setShowContactId] = useState(null) // ID
@ -32,6 +33,8 @@ export default function News() {
const [matchedStatus, setMatchedStatus] = useState({}) // const [matchedStatus, setMatchedStatus] = useState({}) //
const [showMatchList, setShowMatchList] = useState(false) const [showMatchList, setShowMatchList] = useState(false)
const [matchCollections, setMatchCollections] = useState([]) const [matchCollections, setMatchCollections] = useState([])
const [networkMatchCollections, setNetworkMatchCollections] = useState([])
const [showNetworkMatchList, setShowNetworkMatchList] = useState(false)
const [customModal, setCustomModal] = useState({show: false, title: '', content: ''}) const [customModal, setCustomModal] = useState({show: false, title: '', content: ''})
const [seekForm, setSeekForm] = useState({ const [seekForm, setSeekForm] = useState({
edition: '龙钞', type: '', category: '寻号', price: '', matchType: '模糊', features: '', content: '', title: '', contact: getUserPhone() || '' edition: '龙钞', type: '', category: '寻号', price: '', matchType: '模糊', features: '', content: '', title: '', contact: getUserPhone() || ''
@ -40,6 +43,8 @@ export default function News() {
const [viewMode, setViewMode] = useState('all') const [viewMode, setViewMode] = useState('all')
const [expandedItems, setExpandedItems] = useState({}) // const [expandedItems, setExpandedItems] = useState({}) //
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const API_BASE = localStorage.getItem('API_BASE') || '' const API_BASE = localStorage.getItem('API_BASE') || ''
const currentUser = localStorage.getItem('token') ? { id: 'logged_in' } : null const currentUser = localStorage.getItem('token') ? { id: 'logged_in' } : null
@ -72,7 +77,10 @@ export default function News() {
const type = activeTab === 'seek' ? 'seek' : activeTab === 'deal' ? 'deal' : 'yichen' const type = activeTab === 'seek' ? 'seek' : activeTab === 'deal' ? 'deal' : 'yichen'
// token便matched_count // token便matched_count
const headers = token ? { Authorization: `Bearer ${token}` } : {} const headers = token ? { Authorization: `Bearer ${token}` } : {}
const res = await fetch(`${API_BASE}/api/information/list?info_type=${type}`, { headers }) const res = await fetch(`${API_BASE}/api/information/list?info_type=${type}&page=${currentPage}&page_size=50`, { headers })
//
const total = res.headers.get('X-Total-Pages') || res.headers.get('x-total-pages')
if (total) setTotalPages(parseInt(total))
// //
if (!res.ok) { if (!res.ok) {
console.error('获取资讯列表失败:', res.status) console.error('获取资讯列表失败:', res.status)
@ -146,10 +154,11 @@ export default function News() {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (!token) { alert('请先登录'); return } if (!token) { alert('请先登录'); return }
try { try {
const phone = getUserPhone()
const res = await fetch(`${API_BASE}/api/information/seek/match-confirm`, { const res = await fetch(`${API_BASE}/api/information/seek/match-confirm`, {
method: 'POST', method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ info_id: infoId }) body: JSON.stringify({ info_id: infoId, contact: phone || '用户已同意' })
}) })
const data = await res.json() const data = await res.json()
if (data.message === '匹配成功,已通知发布者') { if (data.message === '匹配成功,已通知发布者') {
@ -187,7 +196,7 @@ export default function News() {
console.log('Matched user response:', res.status) console.log('Matched user response:', res.status)
const data = await res.json() const data = await res.json()
console.log('Matched user data:', data) console.log('Matched user data:', data)
if (data.user_name) setCustomModal({show: true, title: '匹配者信息', content: `用户名: ${data.user_name}\n联系方式: ${data.matched_contact || '未提供'}`}) if (data.user_name) setCustomModal({show: true, title: '匹配者信息', content: `用户名: ${data.user_name}\n手机号: ${data.phone || '未提供'}\n\n免责声明及风险提示所有用户信息仅作参考交易请走正规平台如产生经济损失与本站无关后果自负。`})
else if (data.detail) alert(data.detail) else if (data.detail) alert(data.detail)
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
} }
@ -200,7 +209,7 @@ export default function News() {
console.log('Publisher response:', res.status) console.log('Publisher response:', res.status)
const data = await res.json() const data = await res.json()
console.log('Publisher data:', data) console.log('Publisher data:', data)
if (data.user_name) setCustomModal({show: true, title: '发布者信息', content: `用户名: ${data.user_name}\n联系方式: ${data.contact || '未提供'}`}) if (data.user_name) setCustomModal({show: true, title: '发布者信息', content: `用户名: ${data.user_name}\n手机号: ${data.phone || '未提供'}\n\n免责声明及风险提示所有用户信息仅作参考交易请走正规平台如产生经济损失与本站无关后果自负。`})
else if (data.detail) alert(data.detail) else if (data.detail) alert(data.detail)
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
} }
@ -223,6 +232,20 @@ export default function News() {
} }
} }
//
const fetchNetworkMatchCollections = async (infoId) => {
try {
const res = await fetch(`${API_BASE}/api/information/seek/network-match/${infoId}`)
const data = await res.json()
console.log('网络数据匹配结果:', data)
setNetworkMatchCollections(data.collections || [])
setShowNetworkMatchList(true)
} catch (e) {
console.error('获取网络匹配藏品失败:', e)
alert('获取网络匹配藏品失败: ' + e.message)
}
}
// ID // ID
const currentUserId = getStoredUserId() const currentUserId = getStoredUserId()
@ -388,7 +411,7 @@ export default function News() {
readOnly={isFixed} readOnly={isFixed}
onChange={(e) => { onChange={(e) => {
if (i < 2) return if (i < 2) return
const val = e.target.value.toUpperCase().replace(/[^0-9XABCFG]/g, '') const val = e.target.value.toUpperCase().replace(/[^0-9XABCDEFG]/g, '')
const newFeatures = (seekForm.features || '').split('') const newFeatures = (seekForm.features || '').split('')
while (newFeatures.length < 8) newFeatures.push('') while (newFeatures.length < 8) newFeatures.push('')
newFeatures[i - 2] = val newFeatures[i - 2] = val
@ -455,7 +478,7 @@ export default function News() {
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{infoList.map(item => { {activeTab === 'yichen' ? <YichensBoard /> : infoList.map(item => {
// //
const content = item.content || '' const content = item.content || ''
const featuresMatch = content.match(/号码特征[:\s]*(.+?)(?:\n|$)/) const featuresMatch = content.match(/号码特征[:\s]*(.+?)(?:\n|$)/)
@ -522,12 +545,20 @@ export default function News() {
{/* 配号结果 - 仅寻配号显示 */} {/* 配号结果 - 仅寻配号显示 */}
{activeTab === 'seek' && ( {activeTab === 'seek' && (
<div style={{ marginBottom: '8px' }}> <div style={{ marginBottom: '8px' }}>
<span <span
style={{ color: '#10b981', fontSize: '13px', fontWeight: 'bold', cursor: 'pointer' }} style={{ color: '#10b981', fontSize: '13px', fontWeight: 'bold', cursor: 'pointer' }}
onClick={() => fetchMatchCollections(item.id)} onClick={() => fetchMatchCollections(item.id)}
> >
配号结果: {item.matched_count || 0}条藏品匹配成功 自有{item.matched_count || 0}条藏品匹配成功
</span> </span>
{item.network_matched_count !== undefined && (
<span
style={{ color: '#3b82f6', fontSize: '13px', fontWeight: 'bold', cursor: 'pointer', marginLeft: '12px' }}
onClick={() => fetchNetworkMatchCollections(item.id)}
>
网络数据{item.network_matched_count}条匹配成功
</span>
)}
</div> </div>
)} )}
{/* 正文 - 默认收起,点击展开 */} {/* 正文 - 默认收起,点击展开 */}
@ -614,23 +645,24 @@ export default function News() {
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}> <div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<button <button
onClick={() => { onClick={() => {
if (item.is_matched === 'matched') return
if (item.matched_count > 0) { if (item.matched_count > 0) {
matchAndContact(item.id) matchAndContact(item.id)
} else { } else {
setCustomModal({show: true, title: '提示', content: '暂无匹配藏品,无法匹配'}) setCustomModal({show: true, title: '提示', content: '暂无匹配藏品,无法匹配'})
} }
}} }}
disabled={item.matched_count === 0} disabled={item.matched_count === 0 || item.is_matched === 'matched'}
style={{ style={{
flex: 1, flex: 1,
padding: '8px 12px', padding: '8px 12px',
borderRadius: '6px', borderRadius: '6px',
border: 'none', border: 'none',
fontSize: '12px', fontSize: '12px',
background: item.matched_count > 0 ? '#3b82f6' : '#4b5563', background: (item.matched_count > 0 && item.is_matched !== 'matched') ? '#3b82f6' : '#4b5563',
color: '#fff', color: '#fff',
cursor: item.matched_count > 0 ? 'pointer' : 'not-allowed', cursor: (item.matched_count > 0 && item.is_matched !== 'matched') ? 'pointer' : 'not-allowed',
opacity: item.matched_count > 0 ? 1 : 0.5 opacity: (item.matched_count > 0 && item.is_matched !== 'matched') ? 1 : 0.5
}} }}
> >
匹配并联系藏友 匹配并联系藏友
@ -752,6 +784,74 @@ export default function News() {
</div> </div>
)} )}
{/* 网络数据匹配藏品列表弹窗 */}
{showNetworkMatchList && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '20px', width: '90%', maxHeight: '80vh', overflow: 'auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3 style={{ color: '#fff', margin: 0 }}>匹配藏品清单网络数据</h3>
<button onClick={() => setShowNetworkMatchList(false)} style={{ background: 'transparent', border: 'none', color: '#94a3b8', fontSize: '20px', cursor: 'pointer' }}>×</button>
</div>
{networkMatchCollections.length === 0 ? (
<div style={{ color: '#94a3b8', textAlign: 'center', padding: '20px' }}>暂无匹配藏品</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{networkMatchCollections.map(c => (
<div
key={c.id}
style={{ background: '#0f172a', borderRadius: '8px', padding: '12px', cursor: 'pointer' }}
onClick={() => {
if (c.post_url) {
window.open(c.post_url, '_blank')
}
setShowNetworkMatchList(false)
}}
>
<div style={{ color: '#e2e8f0', fontSize: '13px' }}>
<span style={{ color: '#94a3b8' }}>名称: </span>{c.name || '-'}
</div>
<div style={{ color: '#e2e8f0', fontSize: '13px', marginTop: '4px' }}>
<span style={{ color: '#94a3b8' }}>冠字号: </span>{c.crown_code}
</div>
{c.price && (
<div style={{ color: '#e2e8f0', fontSize: '13px', marginTop: '4px' }}>
<span style={{ color: '#94a3b8' }}>价格: </span>¥{c.price}
</div>
)}
<div style={{ color: '#3b82f6', fontSize: '12px', marginTop: '6px' }}>
点击查看原帖
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* 分页组件 */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '8px', padding: '16px', marginTop: '8px' }}>
<button
onClick={() => { if (currentPage > 1) { setCurrentPage(currentPage - 1); fetchInfoList() } }}
disabled={currentPage === 1}
style={{ padding: '8px 16px', borderRadius: '6px', border: 'none', background: currentPage === 1 ? '#374151' : '#3b82f6', color: currentPage === 1 ? '#6b7280' : '#fff', cursor: currentPage === 1 ? 'not-allowed' : 'pointer', fontSize: '13px' }}
>
上一页
</button>
<span style={{ color: '#94a3b8', fontSize: '13px' }}>
{currentPage} / {totalPages}
</span>
<button
onClick={() => { if (currentPage < totalPages) { setCurrentPage(currentPage + 1); fetchInfoList() } }}
disabled={currentPage >= totalPages}
style={{ padding: '8px 16px', borderRadius: '6px', border: 'none', background: currentPage >= totalPages ? '#374151' : '#3b82f6', color: currentPage >= totalPages ? '#6b7280' : '#fff', cursor: currentPage >= totalPages ? 'not-allowed' : 'pointer', fontSize: '13px' }}
>
下一页
</button>
</div>
)}
{/* 我的寻号弹窗 */} {/* 我的寻号弹窗 */}
{showMySeeks && ( {showMySeeks && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>

View File

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