新增盘后 A 股数据同步与看板市场概览

This commit is contained in:
2026-07-04 10:36:59 +08:00
parent 1fd9e97fe1
commit d18aa79a47
13 changed files with 968 additions and 2 deletions
+241
View File
@@ -0,0 +1,241 @@
import { useEffect, useState } from 'react'
import {
BarChart3,
TrendingUp,
TrendingDown,
Minus,
RefreshCw,
Loader2,
AlertCircle,
Calendar,
Activity,
} from 'lucide-react'
import { api, MarketOverview, SyncJob, StockRow } from '@/lib/api'
import { canManageUsers } from '@/lib/auth'
import { cn } from '@/lib/cn'
interface StockOverviewProps {
role: 'system_admin' | 'admin' | 'user'
}
export function StockOverview({ role }: StockOverviewProps) {
const [overview, setOverview] = useState<MarketOverview | null>(null)
const [job, setJob] = useState<SyncJob | null>(null)
const [loading, setLoading] = useState(true)
const [syncing, setSyncing] = useState(false)
const [error, setError] = useState('')
const canSync = canManageUsers(role)
const fetchAll = async () => {
setLoading(true)
setError('')
try {
const [o, j] = await Promise.all([api.stockOverview(), api.stockSyncStatus()])
setOverview(o)
setJob(j)
} catch (err: any) {
setError(err?.message || '加载市场数据失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchAll()
}, [])
const handleSync = async () => {
setSyncing(true)
setError('')
try {
const res = await api.triggerStockSync()
await fetchAll()
// eslint-disable-next-line no-console
console.log('synced', res.records_count, 'records')
} catch (err: any) {
setError(err?.message || '同步失败')
} finally {
setSyncing(false)
}
}
if (loading) {
return (
<div className="rounded-card border border-border bg-surface p-6 flex items-center justify-center gap-2 text-sm text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)
}
if (!overview?.latest_trade_date) {
return (
<div className="rounded-card border border-border bg-surface p-6 text-center">
<div className="text-sm text-secondary"></div>
{canSync && (
<button
onClick={handleSync}
disabled={syncing}
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-60"
>
{syncing ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</button>
)}
</div>
)
}
const { counts, avg_change_pct, indices, top_gainers, top_losers, turnover_leaders } = overview
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-card border border-border bg-surface/80 px-4 py-3">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-accent" />
<h2 className="text-base font-semibold text-foreground"></h2>
<span className="text-xs text-muted flex items-center gap-1">
<Calendar className="h-3 w-3" />
{overview.latest_trade_date}
</span>
</div>
<div className="flex items-center gap-3">
{job && (
<span className="text-xs text-muted">
{formatSyncStatus(job)}
</span>
)}
{canSync && (
<button
onClick={handleSync}
disabled={syncing}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-60 transition-colors"
>
{syncing ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</button>
)}
</div>
</div>
{error && (
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>{error}</span>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<KpiCard icon={<TrendingUp className="h-4 w-4 text-bull" />} label="上涨" value={counts.up} />
<KpiCard icon={<TrendingDown className="h-4 w-4 text-bear" />} label="下跌" value={counts.down} />
<KpiCard icon={<Minus className="h-4 w-4 text-muted" />} label="平盘" value={counts.flat} />
<KpiCard
icon={<Activity className="h-4 w-4 text-accent" />}
label="平均涨跌"
value={`${avg_change_pct >= 0 ? '+' : ''}${avg_change_pct.toFixed(2)}%`}
valueClass={avg_change_pct >= 0 ? 'text-bull' : 'text-bear'}
/>
</div>
{indices.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{indices.map((item) => (
<IndexCard key={item.symbol} item={item} />
))}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StockList title="涨幅榜" rows={top_gainers} valueKey="change_pct" />
<StockList title="跌幅榜" rows={top_losers} valueKey="change_pct" />
<StockList title="成交额榜" rows={turnover_leaders} valueKey="amount" />
</div>
</div>
)
}
function KpiCard({
icon,
label,
value,
valueClass,
}: {
icon: React.ReactNode
label: string
value: React.ReactNode
valueClass?: string
}) {
return (
<div className="rounded-card border border-border bg-surface p-3 flex items-center gap-3">
<div className="p-1.5 rounded-btn bg-elevated">{icon}</div>
<div>
<div className={cn('text-lg font-bold text-foreground', valueClass)}>{value}</div>
<div className="text-xs text-secondary">{label}</div>
</div>
</div>
)
}
function IndexCard({ item }: { item: StockRow }) {
const positive = item.change_pct >= 0
return (
<div className="rounded-card border border-border bg-surface p-3">
<div className="text-xs text-secondary truncate">{item.name || item.symbol}</div>
<div className={cn('text-base font-semibold mt-1', positive ? 'text-bull' : 'text-bear')}>
{item.close.toFixed(2)}
</div>
<div className={cn('text-xs font-medium', positive ? 'text-bull' : 'text-bear')}>
{positive ? '+' : ''}{item.change_pct.toFixed(2)}%
</div>
</div>
)
}
function StockList({
title,
rows,
valueKey,
}: {
title: string
rows: StockRow[]
valueKey: 'change_pct' | 'amount'
}) {
return (
<div className="rounded-card border border-border bg-surface p-3">
<h3 className="text-xs font-semibold text-secondary uppercase tracking-wider mb-2">{title}</h3>
<div className="space-y-1">
{rows.length === 0 ? (
<div className="text-xs text-muted py-2"></div>
) : (
rows.map((item, idx) => {
const val = valueKey === 'amount' ? (item.amount ?? 0) / 1e8 : item.change_pct
const isPositive = valueKey === 'amount' ? true : item.change_pct >= 0
return (
<div key={item.symbol} className="flex items-center justify-between text-sm py-1">
<div className="flex items-center gap-2 min-w-0">
<span className="text-[10px] text-muted w-4">{idx + 1}</span>
<span className="text-foreground font-medium truncate">{item.name || item.symbol}</span>
</div>
<div className={cn('text-xs font-mono shrink-0', valueKey === 'change_pct' ? (isPositive ? 'text-bull' : 'text-bear') : 'text-foreground')}>
{valueKey === 'amount'
? `${val.toFixed(2)}亿`
: `${isPositive ? '+' : ''}${val.toFixed(2)}%`}
</div>
</div>
)
})
)}
</div>
</div>
)
}
function formatSyncStatus(job: SyncJob): string {
if (job.status === 'running') return '同步中…'
const timeStr = job.finished_at
? new Date(job.finished_at).toLocaleString()
: new Date(job.started_at).toLocaleString()
const statusText = job.status === 'success' ? '成功' : '失败'
return `${statusText} · ${timeStr}`
}