242 lines
8.1 KiB
TypeScript
242 lines
8.1 KiB
TypeScript
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}`
|
|
}
|