Files
stock/serve/frontend/src/components/data/StatCard.tsx
T
2026-07-04 16:59:25 +08:00

319 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { motion } from 'framer-motion'
import { Loader2, CheckCircle2, Settings, Table2 } from 'lucide-react'
import { formatNumber } from '@/lib/format'
import { fmtDate } from '@/lib/format'
import { Skeleton } from './Skeleton'
// 卡片能力定义:capKey → 查 capability limitstierReq → 无权限时显示的档位要求
// capKey 为空串表示该数据在 free-api 服务器(None 档/Free 档)即可获取,无需付费能力门控。
export const CARD_META: Record<string, {
capKey: string // 对应的 capability key,空串表示本地计算 / free 服务器可用
tierReq: string // 最低档位要求(无权限时显示)
}> = {
// 标的维表走 exchanges 端点,free-api 服务器即可获取,无需付费能力
instruments: { capKey: '', tierReq: '' },
daily: { capKey: 'kline.daily.batch', tierReq: 'Starter+' },
adj_factor: { capKey: 'adj_factor', tierReq: 'Starter+' },
enriched: { capKey: '', tierReq: '' },
// ETF 复用日K批量能力(免费档 kline.daily.batch 即可),不显示档位徽章
etf: { capKey: 'kline.daily.batch', tierReq: '' },
minute: { capKey: 'kline.minute.batch', tierReq: 'Pro+' },
financials: { capKey: 'financial', tierReq: 'Expert' },
}
export function Pill({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-btn bg-base/40 border border-border px-3 py-1.5">
<div className="text-[10px] uppercase tracking-wider text-muted">{label}</div>
<div className="font-mono text-sm font-medium tabular-nums mt-0.5">{value}</div>
</div>
)
}
function CapBadge({ hasCap, isLocal, tierLabel, tierReq, capInfo, localSuffix }: {
hasCap: boolean
isLocal: boolean
tierLabel?: string
tierReq?: string
capInfo?: { rpm: number | null; batch: number | null; subscribe: number | null } | undefined
localSuffix?: string
}) {
if (isLocal) {
return (
<span className="text-[10px] text-secondary bg-elevated rounded px-1.5 py-px font-medium">
本地计算{localSuffix ? ` · ${localSuffix}` : ''}
</span>
)
}
if (hasCap && capInfo && tierLabel) {
const parts = [tierLabel, `${capInfo.rpm}/min`]
if (capInfo.batch != null && capInfo.batch > 1) parts.push(`${capInfo.batch}股/批`)
return (
<span className="text-[10px] text-accent/80 bg-accent/8 rounded px-1.5 py-px font-mono font-medium">
{parts.join(' · ')}
</span>
)
}
if (!hasCap && tierReq && tierReq !== 'Free') {
// 缺权限且非 Free 档(付费档位才提示升级);Free 档人人可用,
// 若显示"需 Free"会造成 Expert 等用户困惑(通常是探测瞬时失败丢能力)
return (
<span className="text-[10px] text-warning/90 bg-warning/8 rounded px-1.5 py-px font-medium">
{tierReq}
</span>
)
}
if (hasCap) {
return (
<span className="text-[10px] text-accent/80 bg-accent/8 rounded px-1.5 py-px font-medium">
{tierLabel ?? '已授权'}
</span>
)
}
return null
}
export type FieldTab = { label: string; table: string }
export function StatCard({
title, hint, stats, isInstrument = false, loading = false,
active = false, done = false, skipped = false, stagePct = 0,
tierKey, capLimits, tierLabel,
auto, onSettings, onShowFields, settingsOpen, subLabel, localBadgeSuffix, fieldTabs,
}: {
title: string
hint: string
stats: any | null | undefined
isInstrument?: boolean
loading?: boolean
active?: boolean
done?: boolean
skipped?: boolean
stagePct?: number
tierKey?: string
capLimits?: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }>
tierLabel?: string
onSettings?: () => void
onShowFields?: (table?: string) => void
settingsOpen?: boolean
auto?: boolean
subLabel?: string
localBadgeSuffix?: string
// 多表字段入口: [{label: '维表', table: 'index_instruments'}, ...]
// 提供时渲染多个图标按钮(每个对应一张表的字段说明); 否则回退到单个 onShowFields
fieldTabs?: FieldTab[]
}) {
const empty = loading || !stats || (stats.rows === 0 && !stats.trading_days && !stats.fields)
const borderCls = active
? 'border-accent/50'
: done
? 'border-bear/30'
: 'border-border'
const bgCls = active ? 'bg-accent/[0.03]' : 'bg-surface'
const meta = tierKey ? CARD_META[tierKey] : undefined
const isLocal = meta?.capKey === ''
const capInfo = meta?.capKey ? capLimits?.[meta.capKey] : undefined
const hasCap = isLocal || !!capInfo
// 渲染字段说明入口图标
// - fieldTabs 提供时: 返回 null (图标由 renderSubLabelInline 内联到文字后)
// - 否则: 单个图标按钮 (onShowFields)
const renderFieldButtons = () => {
if (fieldTabs && fieldTabs.length > 0) return null
if (onShowFields) {
return (
<button
onClick={(e) => { e.stopPropagation(); onShowFields() }}
className="inline-flex align-middle ml-1 p-0.5 rounded hover:bg-elevated transition-colors text-secondary hover:text-accent"
title="查看字段说明"
>
<Table2 className="h-3 w-3" />
</button>
)
}
return null
}
// 单个图标按钮 (复用样式)
const fieldIconButton = (tab: FieldTab) => (
<button
key={tab.table}
onClick={(e) => { e.stopPropagation(); onShowFields?.(tab.table) }}
className="inline-flex align-middle -mt-px p-0.5 rounded hover:bg-elevated transition-colors text-secondary hover:text-accent"
title={`查看${tab.label}字段说明`}
>
<Table2 className="h-3 w-3" />
</button>
)
// subLabel 文本内容 (不含图标)
const subLabelText = subLabel
?? (isInstrument
? `标的 · ${((stats?.named ?? stats?.rows) ?? 0).toLocaleString()} 个含名称`
: stats?.fields
? '字段 · 复权 · 技术指标'
: title === '日 K' && stats?.trading_days
? '日 · A股标的 · 日线'
: stats?.trading_days && !stats?.rows
? '日 · A股标的 · 分钟级'
: (() => {
const parts = [`行 · ${(stats?.symbols_covered ?? 0)} 只标的`]
if (stats?.trading_days) parts.push(${stats.trading_days} 日`)
return parts.join(' ')
})())
// 有 fieldTabs 时: 把 subLabel 按分隔符拆开, 每个匹配词后面内联图标
// 例如 "日 · 维表 · 日K · 指标" → 日 · 维表[icon] · 日K[icon] · 指标[icon]
const renderSubLabelInline = () => {
if (!fieldTabs || fieldTabs.length === 0) {
return <>{subLabelText}{renderFieldButtons()}</>
}
const labels = fieldTabs.map(t => t.label)
// 按非字母数字汉字的分隔符拆分, 保留分隔符
const tokens = subLabelText.split(/(\s*·\s*|\s+)/).filter(t => t !== '')
const used = new Set<string>()
return (
<>
{tokens.map((tok, i) => {
const trimmed = tok.trim()
// 跳过纯分隔符
if (trimmed === '' || trimmed === '·') return <span key={i}>{tok}</span>
// 匹配某个 tab label (整体匹配, 避免部分子串误命中)
const idx = labels.indexOf(trimmed)
if (idx >= 0 && !used.has(trimmed)) {
used.add(trimmed)
return <span key={i}>{tok}{fieldIconButton(fieldTabs[idx])}</span>
}
return <span key={i}>{tok}</span>
})}
</>
)
}
return (
<div className={`rounded-card border ${borderCls} ${bgCls} flex flex-col transition-all duration-300 ${active ? 'shadow-[0_0_16px_rgba(61,214,140,0.08)]' : ''}`}>
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<div className="flex items-center gap-1.5">
{auto !== undefined && !loading && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${auto ? 'bg-accent shadow-[0_0_4px_rgba(61,214,140,0.5)]' : 'bg-muted'}`} />
<span className={auto ? 'text-accent/70' : 'text-muted'}>{auto ? '自动' : '关闭'}</span>
</span>
)}
{active && <Loader2 className="h-3.5 w-3.5 text-accent animate-spin" />}
{done && !active && !skipped && <CheckCircle2 className="h-3.5 w-3.5 text-bear" />}
{skipped && !active && (
<span className="text-[10px] text-muted bg-elevated rounded px-1.5 py-px font-medium">
本次跳过
</span>
)}
{onSettings && (
<button
onClick={(e) => { e.stopPropagation(); onSettings() }}
className={`p-0.5 rounded hover:bg-elevated transition-colors ${
settingsOpen ? 'text-accent' : 'text-secondary'
}`}
>
<Settings className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="px-4 pb-1 text-[10px] text-muted">{hint}</div>
<div className="px-4 pb-2">
{loading ? (
<Skeleton w="w-16" h="h-4" />
) : (
<CapBadge
hasCap={hasCap}
isLocal={isLocal}
tierLabel={tierLabel}
tierReq={meta?.tierReq}
capInfo={capInfo}
localSuffix={localBadgeSuffix}
/>
)}
</div>
<div className="px-4 pb-1">
{loading ? (
<>
<Skeleton w="w-20" h="h-8" />
<Skeleton w="w-24" h="h-3" className="mt-1" />
</>
) : empty ? (
<>
<div className="font-mono text-2xl font-bold tracking-tight tabular-nums text-foreground"></div>
<div className="text-[11px] text-muted mt-0.5">
暂无数据{renderFieldButtons()}
</div>
</>
) : (
<>
<div className="font-mono text-2xl font-bold tracking-tight tabular-nums text-foreground">
{stats.fields
? stats.fields
: stats.trading_days && !stats.rows
? stats.trading_days.toLocaleString()
: formatNumber(stats.rows)}
</div>
<div className="text-[11px] text-muted mt-0.5">
{renderSubLabelInline()}
</div>
</>
)}
</div>
<div className="mt-auto px-4 pb-4 pt-2 border-t border-border space-y-0.5">
{loading ? (
<>
<div className="flex justify-between"><Skeleton w="w-6" h="h-3" /><Skeleton w="w-16" h="h-3" /></div>
<div className="flex justify-between"><Skeleton w="w-4" h="h-3" /><Skeleton w="w-16" h="h-3" /></div>
</>
) : empty ? (
<>
<div className="flex justify-between text-[11px]">
<span className="text-muted">{isInstrument ? '快照日' : '起'}</span>
<span className="font-mono text-secondary"></span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-muted">{isInstrument ? '标的数' : '止'}</span>
<span className="font-mono text-secondary"></span>
</div>
</>
) : (
<>
<div className="flex justify-between text-[11px]">
<span className="text-muted">{isInstrument ? '快照日' : '起'}</span>
<span className="font-mono text-secondary">{fmtDate(isInstrument ? stats.latest_as_of : stats.earliest_date)}</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-muted">{isInstrument ? '标的数' : '止'}</span>
<span className="font-mono text-secondary">{isInstrument ? String(stats.rows) : fmtDate(stats.latest_date)}</span>
</div>
</>
)}
</div>
{active && stagePct > 0 && (
<div className="h-1 bg-elevated overflow-hidden rounded-b-card">
<motion.div
className="h-full bg-accent"
initial={{ width: 0 }}
animate={{ width: `${stagePct}%` }}
transition={{ duration: 0.3, ease: 'easeOut' }}
/>
</div>
)}
</div>
)
}