新增盘后 A 股数据同步与看板市场概览
This commit is contained in:
@@ -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}`
|
||||
}
|
||||
@@ -28,6 +28,40 @@ export interface AuthResponse {
|
||||
user: UserInfo
|
||||
}
|
||||
|
||||
export interface StockRow {
|
||||
symbol: string
|
||||
name: string
|
||||
close: number
|
||||
change_pct: number
|
||||
amount?: number
|
||||
}
|
||||
|
||||
export interface MarketOverview {
|
||||
latest_trade_date: string
|
||||
counts: {
|
||||
up: number
|
||||
down: number
|
||||
flat: number
|
||||
total: number
|
||||
}
|
||||
avg_change_pct: number
|
||||
indices: StockRow[]
|
||||
top_gainers: StockRow[]
|
||||
top_losers: StockRow[]
|
||||
turnover_leaders: StockRow[]
|
||||
}
|
||||
|
||||
export interface SyncJob {
|
||||
id: string
|
||||
job_date: string
|
||||
status: 'running' | 'success' | 'failed'
|
||||
records_count: number
|
||||
started_at: string
|
||||
finished_at?: string
|
||||
error_message: string
|
||||
trigger_by: string
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem('access_token')
|
||||
@@ -107,4 +141,11 @@ export const api = {
|
||||
request<{ message: string }>(`/api/admin/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
listRoles: () => request<Role[]>('/api/admin/roles'),
|
||||
|
||||
stockOverview: () => request<MarketOverview>('/api/stocks/overview'),
|
||||
|
||||
stockSyncStatus: () => request<SyncJob | null>('/api/stocks/sync/status'),
|
||||
|
||||
triggerStockSync: () =>
|
||||
request<{ records_count: number }>('/api/admin/stocks/sync', { method: 'POST' }),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LayoutDashboard, ShieldCheck } from 'lucide-react'
|
||||
import { getStoredUser, roleLabel } from '@/lib/auth'
|
||||
import { StockOverview } from '@/components/StockOverview'
|
||||
|
||||
export function Dashboard() {
|
||||
const user = getStoredUser()
|
||||
@@ -16,6 +17,8 @@ export function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StockOverview role={user?.role || 'user'} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Card
|
||||
icon={<ShieldCheck className="h-5 w-5 text-accent" />}
|
||||
|
||||
Reference in New Issue
Block a user