188 lines
6.7 KiB
TypeScript
188 lines
6.7 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
||
import { Trophy, Medal, Award, BarChart3, Users } from 'lucide-react'
|
||
import { useRole } from '../../context'
|
||
import { api, type LeaderboardResponse, type ReporterRank, type StationRank } from '../../api'
|
||
|
||
const PERIODS = ['2026-Q1', '2026-Q2', '2026-Q3', '2026-Q4']
|
||
|
||
function RankBadge({ rank }: { rank: number }) {
|
||
if (rank === 1) return <div className="lb-badge lb-gold"><Trophy size={13} /></div>
|
||
if (rank === 2) return <div className="lb-badge lb-silver"><Medal size={13} /></div>
|
||
if (rank === 3) return <div className="lb-badge lb-bronze"><Award size={13} /></div>
|
||
return <div className="lb-badge">{rank}</div>
|
||
}
|
||
|
||
function ReporterRow({ item, isMe }: { item: ReporterRank; isMe: boolean }) {
|
||
return (
|
||
<tr className={isMe ? 'lb-me' : ''}>
|
||
<td data-label="排名"><RankBadge rank={item.rank} /></td>
|
||
<td data-label="记者">
|
||
<div className="lb-name">
|
||
{isMe && <span className="lb-me-tag">我</span>}
|
||
{item.name}
|
||
</div>
|
||
</td>
|
||
<td data-label="记者站" className="lb-muted">{item.station}</td>
|
||
<td data-label="综合得分" className="lb-score">{(item.totalScore ?? 0).toFixed(1)}</td>
|
||
</tr>
|
||
)
|
||
}
|
||
|
||
function StationRow({ item, isMe }: { item: StationRank; isMe: boolean }) {
|
||
return (
|
||
<tr className={isMe ? 'lb-me' : ''}>
|
||
<td data-label="排名"><RankBadge rank={item.rank} /></td>
|
||
<td data-label="记者站">
|
||
<div className="lb-name">
|
||
{isMe && <span className="lb-me-tag">本站</span>}
|
||
{item.name}
|
||
</div>
|
||
</td>
|
||
<td data-label="人数" className="lb-muted">{item.reporterCount} 人</td>
|
||
<td data-label="质量" className="lb-dim">{(item.avgQuality ?? 0).toFixed(1)}</td>
|
||
<td data-label="数量" className="lb-dim">{(item.avgQuantity ?? 0).toFixed(1)}</td>
|
||
<td data-label="时效" className="lb-dim">{(item.avgEfficiency ?? 0).toFixed(1)}</td>
|
||
<td data-label="综合得分" className="lb-score">{(item.avgScore ?? 0).toFixed(1)}</td>
|
||
</tr>
|
||
)
|
||
}
|
||
|
||
export function LeaderboardPage() {
|
||
const { role } = useRole()
|
||
const [data, setData] = useState<LeaderboardResponse | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState('')
|
||
const [period, setPeriod] = useState('2026-Q3')
|
||
const [groupBy, setGroupBy] = useState<'reporter' | 'station'>('reporter')
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true); setError('')
|
||
try {
|
||
const result = await api.leaderboard(role, { period, group_by: groupBy })
|
||
setData(result)
|
||
} catch (e: any) { setError(e.message) }
|
||
finally { setLoading(false) }
|
||
}, [role, period, groupBy])
|
||
|
||
useEffect(() => { load() }, [load])
|
||
|
||
const myRank = data?.myRank
|
||
const ranks = data?.ranks ?? []
|
||
|
||
return (
|
||
<>
|
||
<div className="page-heading small">
|
||
<div>
|
||
<div className="page-heading-icon"><Trophy size={20} /></div>
|
||
<div>
|
||
<h1>积分排行榜</h1>
|
||
<span>
|
||
{groupBy === 'reporter'
|
||
? '按考核周期统计全国记者积分排名。'
|
||
: '按考核周期统计各记者站平均积分排名。'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 筛选栏 */}
|
||
<div className="panel" style={{ marginBottom: 16 }}>
|
||
<div className="filters">
|
||
<select value={period} onChange={e => setPeriod(e.target.value)}>
|
||
{PERIODS.map(p => <option key={p} value={p}>{p}</option>)}
|
||
</select>
|
||
|
||
<div className="lb-toggle">
|
||
<button
|
||
className={groupBy === 'reporter' ? 'active' : ''}
|
||
onClick={() => setGroupBy('reporter')}
|
||
>
|
||
<BarChart3 size={13} /> 记者排名
|
||
</button>
|
||
<button
|
||
className={groupBy === 'station' ? 'active' : ''}
|
||
onClick={() => setGroupBy('station')}
|
||
>
|
||
<Users size={13} /> 记者站排名
|
||
</button>
|
||
</div>
|
||
|
||
<span className="result-count">
|
||
{loading ? '加载中...' : `${ranks.length} 条记录`}
|
||
{myRank != null && role !== 'headquarters' && ` · 我的排名:第 ${myRank} 名`}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="error-banner">{error}</div>
|
||
)}
|
||
|
||
{!loading && !error && data && ranks.length === 0 && (
|
||
<div className="empty panel" style={{ padding: '40px 0' }}>
|
||
暂无排行榜数据,请先在「评分结果」中触发评分计算。
|
||
</div>
|
||
)}
|
||
|
||
{!loading && !error && ranks.length > 0 && (
|
||
<>
|
||
{/* 我的排名提示 */}
|
||
{data != null && myRank != null && role !== 'headquarters' && (
|
||
<div className="lb-mine">
|
||
<span className="lb-mine-label">我的排名</span>
|
||
<strong className="lb-mine-rank">第 {myRank} 名</strong>
|
||
<span className="lb-mine-total">/ 共 {data.ranks.length} 人</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="panel lb-table-wrap">
|
||
{groupBy === 'reporter' ? (
|
||
<table className="lb-table">
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: 56 }}>排名</th>
|
||
<th>记者</th>
|
||
<th>记者站</th>
|
||
<th style={{ width: 90 }}>综合得分</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(ranks as ReporterRank[]).map(item => (
|
||
<ReporterRow
|
||
key={item.name}
|
||
item={item}
|
||
isMe={item.rank === myRank}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<table className="lb-table">
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: 56 }}>排名</th>
|
||
<th>记者站</th>
|
||
<th style={{ width: 70 }}>人数</th>
|
||
<th style={{ width: 64 }}>质量</th>
|
||
<th style={{ width: 64 }}>数量</th>
|
||
<th style={{ width: 64 }}>时效</th>
|
||
<th style={{ width: 90 }}>综合得分</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(ranks as StationRank[]).map(item => (
|
||
<StationRow
|
||
key={item.name}
|
||
item={item}
|
||
isMe={item.rank === myRank}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
)
|
||
} |