v1.2.43 一尘看板完善版

This commit is contained in:
龙大 2026-04-06 23:22:57 +08:00
parent 7ff0ae5c9e
commit 9d1b59a421
4 changed files with 212 additions and 78 deletions

View File

@ -32,6 +32,7 @@ class PostItem(BaseModel):
reply_count: int reply_count: int
view_count: int view_count: int
url: Optional[str] url: Optional[str]
content: Optional[str]
class UserStat(BaseModel): class UserStat(BaseModel):
total_users: int total_users: int
@ -42,6 +43,7 @@ class UserItem(BaseModel):
user_id: str user_id: str
username: str username: str
avatar_url: Optional[str] avatar_url: Optional[str]
content: Optional[str]
credit_level: Optional[str] credit_level: Optional[str]
credit_score: Optional[int] credit_score: Optional[int]
post_count: int post_count: int
@ -105,7 +107,7 @@ def get_user_stats(db: Session = Depends(get_coolbot_db)):
@router.get("/posts", response_model=List[PostItem]) @router.get("/posts", response_model=List[PostItem])
def get_posts( def get_posts(
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=500),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
category: Optional[str] = None, category: Optional[str] = None,
post_type: Optional[str] = None, post_type: Optional[str] = None,
@ -113,7 +115,7 @@ def get_posts(
): ):
"""获取帖子列表""" """获取帖子列表"""
query = """ query = """
SELECT post_id, title, category, post_type, price, SELECT post_id, title, content, category, post_type, price,
author_username, post_time, reply_count, view_count, url author_username, post_time, reply_count, view_count, url
FROM yichens_posts FROM yichens_posts
WHERE 1=1 WHERE 1=1
@ -135,19 +137,20 @@ def get_posts(
return [PostItem( return [PostItem(
post_id=r[0], post_id=r[0],
title=r[1] or "", title=r[1] or "",
category=r[2], content=r[2] or "",
post_type=r[3] or "", category=r[3],
price=float(r[4]) if r[4] else None, post_type=r[4] or "",
author_username=r[5] or "", price=float(r[5]) if r[5] else None,
post_time=str(r[6]) if r[6] else "", author_username=r[6] or "",
reply_count=r[7] or 0, post_time=str(r[7]) if r[7] else "",
view_count=r[8] or 0, reply_count=r[8] or 0,
url=r[9] view_count=r[9] or 0,
url=r[10]
) for r in results] ) for r in results]
@router.get("/users", response_model=List[UserItem]) @router.get("/users", response_model=List[UserItem])
def get_users( def get_users(
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=500),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
is_seller: Optional[bool] = None, is_seller: Optional[bool] = None,
db: Session = Depends(get_coolbot_db) db: Session = Depends(get_coolbot_db)
@ -179,3 +182,69 @@ def get_users(
is_seller=r[6] or False, is_seller=r[6] or False,
registration_date=str(r[7]) if r[7] else None registration_date=str(r[7]) if r[7] else None
) for r in results] ) for r in results]
@router.get("/stats/today")
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
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[3] or 0,
"horses": result[4] or 0,
"snakes": result[5] 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.42 VERSION=1.2.43

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.42</title>
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="/static/icons/favicon.png" /> <link rel="icon" type="image/png" href="/static/icons/favicon.png" />

View File

@ -1,66 +1,99 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
export default function YichensBoard() { export default function YichensBoard() {
const [stats, setStats] = useState({ posts: null, categories: [], users: null }) const [todayStats, setTodayStats] = useState({ total: 0, deals: 0, wants: 0, others: 0 })
const [todayCategory, setTodayCategory] = useState([])
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [expandedPosts, setExpandedPosts] = useState({}) const [expandedPosts, setExpandedPosts] = useState({})
const [postTypeFilter, setPostTypeFilter] = useState('all') const [postTypeFilter, setPostTypeFilter] = useState('all')
const [categoryFilter, setCategoryFilter] = useState('')
const [page, setPage] = useState(1)
const [totalPosts, setTotalPosts] = useState(0)
const API_BASE = localStorage.getItem('API_BASE') || '' const API_BASE = localStorage.getItem('API_BASE') || ''
useEffect(() => { fetchStats(); fetchPosts() }, []) const today = new Date()
const dateStr = today.getFullYear() + '/' + (today.getMonth() + 1) + '/' + today.getDate()
const fetchStats = async () => { useEffect(() => { fetchTodayStats(); fetchPosts(1, '') }, [])
const fetchTodayStats = async () => {
try { try {
const [p, c, u] = await Promise.all([ const res = await fetch(API_BASE + '/api/yichens/stats/today')
fetch(`${API_BASE}/api/yichens/stats/posts?days=30`), setTodayStats(await res.json())
fetch(`${API_BASE}/api/yichens/stats/categories?days=30`),
fetch(`${API_BASE}/api/yichens/stats/users`)
])
setStats({ posts: await p.json(), categories: await c.json(), users: await u.json() })
} catch(e) { console.error(e) } } catch(e) { console.error(e) }
} }
const fetchPosts = async () => { 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) setLoading(true)
let url = `${API_BASE}/api/yichens/posts?limit=50` const currentPage = p !== undefined ? p : page
if (postTypeFilter !== 'all') url += `&post_type=${postTypeFilter}` 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 { try {
const res = await fetch(url) const res = await fetch(url)
setPosts((await res.json()) || []) 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('马'))
}
}
setPosts(data)
setTotalPosts(todayStats.total || 0)
} catch { setPosts([]) } } catch { setPosts([]) }
setLoading(false) setLoading(false)
} }
useEffect(() => { if (todayStats.total > 0) fetchTodayCategory() }, [todayStats])
const togglePost = id => setExpandedPosts(p => ({ ...p, [id]: !p[id] })) const togglePost = id => setExpandedPosts(p => ({ ...p, [id]: !p[id] }))
const filtered = postTypeFilter === 'all' ? posts : posts.filter(p => p.post_type === postTypeFilter)
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 ( return (
<div style={{ padding: '0' }}> <div style={{ padding: '0' }}>
{stats.posts && ( <div style={{ marginBottom: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 12, marginBottom: 20 }}> <div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 10 }}>
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 16, border: '1px solid #374151' }}> 📈 连体钞/纪念钞 <span style={{ color: '#9ca3af', fontWeight: 400 }}>{dateStr}</span>
<div style={{ color: '#9ca3af', fontSize: 12, marginBottom: 4 }}>30天帖子</div>
<div style={{ color: '#fff', fontSize: 24, fontWeight: 'bold' }}>{stats.posts.total_posts}</div>
<div style={{ color: '#6b7280', fontSize: 11 }}>出售{stats.posts.total_deals} 求购{stats.posts.total_wants}</div>
</div>
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 16, border: '1px solid #374151' }}>
<div style={{ color: '#9ca3af', fontSize: 12, marginBottom: 4 }}>浏览量</div>
<div style={{ color: '#fff', fontSize: 24, fontWeight: 'bold' }}>{(stats.posts.total_views/10000).toFixed(1)}</div>
<div style={{ color: '#6b7280', fontSize: 11 }}>回复{stats.posts.total_replies}</div>
</div>
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 16, border: '1px solid #374151' }}>
<div style={{ color: '#9ca3af', fontSize: 12, marginBottom: 4 }}>用户数</div>
<div style={{ color: '#fff', fontSize: 24, fontWeight: 'bold' }}>{stats.users?.total_users||0}</div>
<div style={{ color: '#6b7280', fontSize: 11 }}>今日新增{stats.users?.new_users_today||0}</div>
</div>
</div> </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: 16, marginBottom: 20, border: '1px solid #374151' }}> <div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 16, marginBottom: 20, border: '1px solid #374151' }}>
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 12 }}>📊 分类统计</div> <div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 12 }}>📊 今日分类统计</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{stats.categories.map(cat => ( {todayCategory.map(cat => (
<span key={cat.category} style={{ padding: '4px 12px', background: 'rgba(59,130,246,0.2)', borderRadius: 20, color: '#93c5fd', fontSize: 12 }}> <span key={cat.category} style={{ padding: '4px 12px', background: 'rgba(59,130,246,0.2)', borderRadius: 20, color: '#93c5fd', fontSize: 12 }}>
{cat.category} ({cat.count}) {cat.category} ({cat.count})
</span> </span>
@ -68,9 +101,9 @@ export default function YichensBoard() {
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}> <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
{[{key:'all',label:'全部'},{key:'deal',label:'出售'},{key:'want',label:'求购'}].map(k => ( {[{key:'all',label:'全部'},{key:'deal',label:'出售'},{key:'want',label:'求购'},{key:'other',label:'其他'}].map(k => (
<button key={k.key} onClick={() => { setPostTypeFilter(k.key); fetchPosts() }} <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', style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
background: postTypeFilter===k.key ? 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}> background: postTypeFilter===k.key ? 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
{k.label} {k.label}
@ -78,36 +111,68 @@ export default function YichensBoard() {
))} ))}
</div> </div>
{loading ? <div style={{textAlign:'center',color:'#9ca3af',padding:40}}>加载中...</div> : <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> {[{key:'',label:'全部'},{key:'龙',label:'龙钞'},{key:'蛇',label:'蛇钞'},{key:'马',label:'马钞'},{key:'其他',label:'其他'}].map(k => (
{filtered.map(post => ( <button key={k.key} onClick={() => { setCategoryFilter(k.key); setPage(1); fetchPosts(1, k.key) }}
<div key={post.post_id} style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 16, border: '1px solid #374151' }}> style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
<div onClick={() => togglePost(post.post_id)} style={{ cursor: 'pointer' }}> background: categoryFilter===k.key ? 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}> {k.label}
<span style={{ color: '#fff', fontSize: 15, fontWeight: 500 }}>{post.title||'无标题'}</span> </button>
<span style={{ color: post.post_type==='deal'?'#10b981':post.post_type==='want'?'#f59e0b':'#9ca3af', fontSize: 12 }}> ))}
{post.post_type==='deal'?'出售':post.post_type==='want'?'求购':'普通'} </div>
</span>
</div> {loading ? <div style={{textAlign:'center',color:'#9ca3af',padding:40}}>加载中...</div> : (
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#9ca3af', fontSize: 12 }}> <div>
<span>{post.author_username||'未知'}</span> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: '#1e293b', borderRadius: 12, marginTop: 16, marginBottom: 16 }}>
<span>{post.post_time?.substring(0,16)||''}</span> <div style={{ color: '#9ca3af', fontSize: 12 }}>
</div> {totalPosts} 条帖子{Math.ceil(totalPosts / 390)} 当前第 {page}
</div> </div>
{expandedPosts[post.post_id] && ( <div style={{ display: 'flex', gap: 8 }}>
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid #374151' }}> {page > 1 ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 13 }}> <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>
<div><span style={{color:'#6b7280'}}>分类: </span><span style={{color:'#fff'}}>{post.category||'-'}</span></div> ) : (
<div><span style={{color:'#6b7280'}}>价格: </span><span style={{color:'#10b981'}}>{post.price?post.price.toLocaleString()+'元':'待询'}</span></div> <span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>上一页</span>
<div><span style={{color:'#6b7280'}}>浏览: </span><span style={{color:'#fff'}}>{post.view_count||0}</span></div> )}
<div><span style={{color:'#6b7280'}}>回复: </span><span style={{color:'#fff'}}>{post.reply_count||0}</span></div> {posts.length >= 390 ? (
</div> <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>
{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> <span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>下一页</span>
)} )}
</div> </div>
))} </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>
)}
</div> </div>
) )
} }