317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
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 limits;tierReq → 无权限时显示的档位要求
|
||
// capKey 为空串表示该数据在 free-api 服务器(无档/免费档)即可获取,无需付费能力门控。
|
||
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: '' },
|
||
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>
|
||
)
|
||
}
|