feat(home): 添加市场数据概览

This commit is contained in:
yuxuanhui
2026-08-07 13:29:31 +08:00
parent 9023e00213
commit b0e846d79c
33 changed files with 2229 additions and 18 deletions
@@ -0,0 +1,82 @@
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog"
import { ScrollArea } from "@/shared/ui/scroll-area"
import { Button } from "@/shared/ui/button"
import type { OverviewFailure } from "../api/home.types"
interface FailedStocksDialogProps {
failures: OverviewFailure[]
open: boolean
onOpenChange: (open: boolean) => void
}
export function FailedStocksDialog({
failures,
onOpenChange,
open,
}: FailedStocksDialogProps) {
if (!open) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl" showCloseButton={false}>
<DialogHeader>
<DialogTitle>失败股票详情</DialogTitle>
<DialogDescription>
以下股票尚未在目标交易日同时具备完整的行情和估值数据。
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-80 pr-3">
<div className="space-y-3">
{failures.map((failure) => (
<div
key={`${failure.ts_code}-${failure.failure_type}`}
className="rounded-lg border bg-muted/30 p-3"
>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<p className="font-medium">{failure.name}</p>
<p className="font-mono text-xs text-muted-foreground">
{failure.ts_code}
</p>
</div>
<span className="text-xs text-muted-foreground">
目标交易日:{failure.trade_date}
</span>
</div>
<p className="mt-3 text-sm font-medium">
{formatFailureType(failure.failure_type)}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{failure.reason}
</p>
</div>
))}
</div>
</ScrollArea>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>关闭</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function formatFailureType(value: string) {
const labels: Record<string, string> = {
missing_daily_bar: "缺少日线行情",
missing_daily_bar_and_valuation_snapshot: "缺少日线行情和估值快照",
missing_valuation_snapshot: "缺少估值快照",
recorded_stock_failure: "股票级同步失败",
}
return labels[value] ?? value
}
@@ -0,0 +1,92 @@
import {
BarChart3,
BrainCircuit,
ClipboardCheck,
LayoutDashboard,
Search,
} from "lucide-react"
import { Avatar, AvatarFallback } from "@/shared/ui/avatar"
import { Button } from "@/shared/ui/button"
import { Input } from "@/shared/ui/input"
import { Separator } from "@/shared/ui/separator"
interface HomeShellProps {
children: React.ReactNode
}
const navigation = [
{ label: "首页", icon: LayoutDashboard, active: true },
{ label: "行情数据", icon: BarChart3, active: false },
{ label: "同步任务", icon: ClipboardCheck, active: false },
{ label: "选股策略", icon: BrainCircuit, active: false },
]
export function HomeShell({ children }: HomeShellProps) {
return (
<div className="flex min-h-screen bg-muted/30 text-foreground">
<aside className="flex w-20 shrink-0 flex-col border-r bg-card px-3 py-5 md:w-64 md:px-5">
<div className="flex items-center gap-3 px-1 md:px-2">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
<BarChart3 className="size-5" aria-hidden="true" />
</div>
<div className="hidden min-w-0 md:block">
<p className="truncate text-sm font-semibold tracking-wide">
知行量化
</p>
<p className="text-xs text-muted-foreground">研究工作台</p>
</div>
</div>
<Separator className="my-6" />
<nav aria-label="主导航" className="space-y-1">
{navigation.map((item) => {
const Icon = item.icon
return (
<Button
key={item.label}
className="w-full justify-start gap-3 px-2 md:px-3"
disabled={!item.active}
variant={item.active ? "secondary" : "ghost"}
aria-current={item.active ? "page" : undefined}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="hidden md:inline">{item.label}</span>
</Button>
)
})}
</nav>
<div className="mt-auto hidden rounded-xl bg-muted/60 p-4 md:block">
<p className="text-xs font-medium">数据研究空间</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
从市场数据概览开始,掌握当前研究基础。
</p>
</div>
</aside>
<div className="min-w-0 flex-1">
<header className="flex h-16 items-center justify-between gap-4 border-b bg-card/80 px-4 backdrop-blur md:px-8">
<div className="relative w-full max-w-md">
<Search
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
aria-label="搜索"
className="h-9 bg-muted/40 pl-9"
placeholder="搜索股票、数据或功能"
readOnly
/>
</div>
<Avatar aria-label="用户头像">
<AvatarFallback>研</AvatarFallback>
</Avatar>
</header>
<main className="mx-auto w-full max-w-7xl p-4 md:p-8">{children}</main>
</div>
</div>
)
}
@@ -0,0 +1,158 @@
import { AlertCircle, CheckCircle2, Database, RefreshCw } from "lucide-react"
import { Badge } from "@/shared/ui/badge"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/ui/card"
import { Progress } from "@/shared/ui/progress"
import { Button } from "@/shared/ui/button"
import type { LatestMarketDataUpdate } from "../api/home.types"
interface MarketDataOverviewCardProps {
stockPoolCount: number
update: LatestMarketDataUpdate
onShowFailures: () => void
}
const statusPresentation = {
updating: {
label: "更新中",
description: "同步批次正在处理,数据仍可能变化。",
icon: RefreshCw,
variant: "outline" as const,
},
success: {
label: "已更新",
description: "目标股票池已完整同步到最近交易日。",
icon: CheckCircle2,
variant: "default" as const,
},
partial_success: {
label: "部分更新",
description: "部分股票尚未完成行情或估值数据同步。",
icon: AlertCircle,
variant: "outline" as const,
},
failed: {
label: "未更新",
description: "最近同步未形成可用的完整数据结果。",
icon: AlertCircle,
variant: "destructive" as const,
},
} as const
function formatShanghaiDateTime(value: string | null) {
if (!value) return "同步进行中"
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value))
}
function formatCoverage(value: number) {
return `${(value * 100).toFixed(2)}%`
}
export function MarketDataOverviewCard({
onShowFailures,
stockPoolCount,
update,
}: MarketDataOverviewCardProps) {
const presentation = statusPresentation[update.status]
const StatusIcon = presentation.icon
const progressValue = Math.min(100, Math.max(0, update.coverage_rate * 100))
return (
<Card className="overflow-hidden">
<CardHeader className="gap-4 border-b bg-card md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2 text-xl">
<Database className="size-5 text-primary" aria-hidden="true" />
市场数据概览
</CardTitle>
<CardDescription>{presentation.description}</CardDescription>
</div>
<Badge variant={presentation.variant} className="w-fit gap-1.5">
<StatusIcon className="size-3.5" aria-hidden="true" />
{presentation.label}
</Badge>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<Metric label="当前目标股票池" value={String(stockPoolCount)} />
<Metric label="最近更新交易日" value={update.trade_date} />
<Metric
label="同步完成时间"
value={formatShanghaiDateTime(update.completed_at)}
/>
<Metric label="目标股票数" value={String(update.target_count)} />
<Metric label="成功股票数" value={String(update.successful_count)} />
</div>
<div className="grid gap-4 rounded-xl border bg-muted/20 p-4 sm:grid-cols-3">
<Metric label="失败股票数" value={String(update.failed_count)} />
<Metric label="有效股票数" value={String(update.valid_count)} />
<div className="space-y-2 sm:col-span-1">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">数据覆盖率</span>
<span className="font-semibold tabular-nums">
{formatCoverage(update.coverage_rate)}
</span>
</div>
<Progress
aria-label="数据覆盖率"
value={progressValue}
className="gap-0"
/>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
只有同时具备日线行情和估值快照的股票计入有效股票。
</p>
<Button
disabled={update.failed_count === 0}
onClick={onShowFailures}
variant="outline"
>
<AlertCircle aria-hidden="true" />
查看失败股票
</Button>
</div>
{update.batch_errors.length > 0 ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm">
<p className="font-medium text-destructive">批次级错误</p>
<ul className="mt-1 space-y-1 text-muted-foreground">
{update.batch_errors.map((error) => (
<li key={`${error.item_key}-${error.error_type}`}>
{error.reason}
</li>
))}
</ul>
</div>
) : null}
</CardContent>
</Card>
)
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-lg font-semibold tracking-tight">{value}</p>
</div>
)
}