Files
tick-stock-panel/frontend/src/pages/Data.tsx
T
shy3130 00a30597bd feat(ui): 看板无数据提示优化 + 数据页无 Key 引导 + 设置页文案
Dashboard:
- hasNoData 改用 trading_days 判断 (后端 status rows 恒为 0 导致提示无法消除)
- 无数据提示改 warning 黄色风格 + AlertTriangle 图标
- 无数据/无 Key 两提示互斥: 无 Key 优先, 有 Key 无数据才显示同步提示
- API Key 横幅: 邀请码加复制按钮, 文案调整

Data:
- 无 Key 时立即同步按钮置灰禁用 + 显示未配置提示
- 指数数据卡片设置按钮与其它卡片一致 (有数据才显示)

System:
- '清理浏览器缓存' 改名 '刷新前端缓存', 避免误导为清数据
2026-06-24 23:24:25 +08:00

975 lines
44 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 { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import {
Database,
Play,
Loader2,
HardDrive,
Clock,
Calendar,
Trash2,
Plus,
Wifi,
AlertTriangle,
Info,
} from 'lucide-react'
import { EndpointTestDialog } from '@/components/EndpointTestDialog'
import { api, type ExtDataConfig } from '@/lib/api'
import {
useCapabilities,
useSettings,
usePreferences,
useQuoteStatus,
useQuoteInterval,
useDataStatus,
} from '@/lib/useSharedQueries'
import { useToggleRealtimeQuotes, useUpdateQuoteInterval } from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader'
import { formatScheduleDatePart, formatScheduleTimePart, isToday } from '@/lib/format'
// 拆分出的子组件
import { StatCard, type FieldTab } from '@/components/data/StatCard'
import { ActiveJobCard } from '@/components/data/ActiveJobCard'
import { SectionTitle, HistoryRow } from '@/components/data/SectionTitle'
import { SettingsModal } from '@/components/data/SettingsModal'
import { ScheduleEditor } from '@/components/data/ScheduleEditor'
import { ExtendHistoryPanel } from '@/components/data/ExtendHistoryPanel'
import { EnrichedRebuildPanel } from '@/components/data/EnrichedRebuildPanel'
import { MinuteSyncConfig } from '@/components/data/MinuteSyncConfig'
import { QuoteConfigCard } from '@/components/data/QuoteConfigCard'
import { EnrichedSchemaModal } from '@/components/data/SchemaModal'
import { Skeleton } from '@/components/data/Skeleton'
import { ExtDataStatCard } from '@/components/ext-data/ExtDataStatCard'
import { CreateExtDialog } from '@/components/ext-data/CreateExtDialog'
import { EditExtDialog } from '@/components/ext-data/EditExtDialog'
export function Data() {
const qc = useQueryClient()
const [activeJobId, setActiveJobId] = useState<string | null>(null)
const startTime = useRef<number | null>(null)
const topRef = useRef<HTMLDivElement>(null)
const caps = useCapabilities()
const settings = useSettings()
const status = useDataStatus({
refetchInterval: activeJobId ? 2_000 : 30_000,
})
const history = useQuery({
queryKey: QK.pipelineJobs,
queryFn: () => api.pipelineJobs(15),
refetchInterval: activeJobId ? false : 60_000,
})
const job = useQuery({
queryKey: QK.pipelineJob(activeJobId ?? ''),
queryFn: () => api.pipelineJob(activeJobId!),
enabled: !!activeJobId,
refetchInterval: (q: any) => {
const j = q.state.data
return j && (j.status === 'succeeded' || j.status === 'failed') ? false : 1_000
},
})
const startSync = useMutation({
mutationFn: api.pipelineRun,
onSuccess: ({ job_id }) => {
setActiveJobId(job_id)
startTime.current = Date.now()
},
})
const [showClearConfirm, setShowClearConfirm] = useState(false)
const clearData = useMutation({
mutationFn: api.dataClear,
onSuccess: () => {
qc.invalidateQueries()
setShowClearConfirm(false)
},
})
const updateSchedule = useMutation({
mutationFn: ({ hour, minute }: { hour: number; minute: number }) =>
api.updatePipelineSchedule(hour, minute),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.dataStatus })
setShowScheduleEdit(false)
},
})
const updateInstSchedule = useMutation({
mutationFn: ({ hour, minute }: { hour: number; minute: number }) =>
api.updateInstrumentsSchedule(hour, minute),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.dataStatus })
setShowInstScheduleEdit(false)
},
})
const [openSettings, setOpenSettings] = useState<string | null>(null)
const [showScheduleEdit, setShowScheduleEdit] = useState(false)
const [showInstScheduleEdit, setShowInstScheduleEdit] = useState(false)
const [indexExtendValue, setIndexExtendValue] = useState(6)
const [indexExtendUnit, setIndexExtendUnit] = useState<'month' | 'year'>('month')
const [schemaTable, setSchemaTable] = useState<string | null>(null)
const [showEndpointTest, setShowEndpointTest] = useState(false)
const [showCreateExt, setShowCreateExt] = useState(false)
const [editingExt, setEditingExt] = useState<ExtDataConfig | null>(null)
const [indexBatchInput, setIndexBatchInput] = useState('100')
const extConfigs = useQuery({
queryKey: QK.extData,
queryFn: api.extDataList,
})
const deleteExt = useMutation({
mutationFn: (id: string) => api.extDataDelete(id),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.extData }),
})
const syncIndexDaily = useMutation({
mutationFn: () => api.syncIndexDaily(indexSyncDays),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
qc.invalidateQueries({ queryKey: ['index-daily'] })
},
})
const prefs = usePreferences()
const minuteAuto = prefs.data?.minute_sync_enabled ?? false
const pipelineSched = prefs.data?.pipeline_schedule ?? { hour: 15, minute: 30 }
const instrumentsSched = prefs.data?.instruments_schedule ?? { hour: 9, minute: 10 }
const indexDailyBatchSize = prefs.data?.index_daily_batch_size ?? 100
useEffect(() => {
setIndexBatchInput(String(indexDailyBatchSize))
}, [indexDailyBatchSize])
const updateIndexBatchSize = useMutation({
mutationFn: (size: number) => api.updateIndexDailyBatchSize(size),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const [showIntervalEdit, setShowIntervalEdit] = useState(false)
const handleToggleIntervalEdit = useCallback((fromEvent?: boolean) => {
setShowIntervalEdit(v => {
const next = !v
if (!fromEvent) {
window.dispatchEvent(new CustomEvent('quote-interval-editor-toggle', { detail: { source: 'data' } }))
}
return next
})
}, [])
useEffect(() => {
const handler = (e: Event) => {
const ce = e as CustomEvent
if (ce.detail?.source !== 'data') {
setShowIntervalEdit(v => !v)
}
}
window.addEventListener('quote-interval-editor-toggle', handler)
return () => window.removeEventListener('quote-interval-editor-toggle', handler)
}, [])
const quoteInterval = useQuoteInterval()
const updateInterval = useUpdateQuoteInterval()
const realtimeEnabled = prefs.data?.realtime_quotes_enabled ?? false
const quoteStatus = useQuoteStatus()
const toggleQuote = useToggleRealtimeQuotes()
const hasAdjCap = !!caps.data?.capabilities?.['adj_factor']
const hasDailyBatchCap = !!caps.data?.capabilities?.['kline.daily.batch']
const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch']
const pipelineSteps = ['日K', ...(hasAdjCap ? ['复权'] : []), '指标', '指数', ...((hasMinuteCap && minuteAuto) ? ['分钟K'] : [])]
useEffect(() => {
if (job.data && (job.data.status === 'succeeded' || job.data.status === 'failed')) {
qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
const t = setTimeout(() => setActiveJobId(null), 5_000)
return () => clearTimeout(t)
}
}, [job.data?.status])
useEffect(() => {
if (job.isError && /404/.test(String((job.error as any)?.message ?? ''))) {
setActiveJobId(null)
}
}, [job.isError, job.error])
useEffect(() => {
if (!activeJobId && history.data?.active_id) {
setActiveJobId(history.data.active_id)
}
}, [history.data?.active_id])
const s = status.data
const isLoading = status.isLoading
const isRunning = job.data?.status === 'running' || job.data?.status === 'pending'
const isStarting = startSync.isPending
const hasData = !!(s?.instruments?.rows || s?.daily?.rows)
// none 档(无 key / 无效 key) → 禁用立即同步 (同步依赖付费档的批量端点)
const isNoKey = settings.data?.mode === 'none'
const indexOverviewStats = s ? {
rows: 0,
earliest_date: s.index_daily?.earliest_date ?? s.index_enriched?.earliest_date ?? null,
latest_date: s.index_daily?.latest_date ?? s.index_enriched?.latest_date ?? null,
symbols_covered: s.index_daily?.symbols_covered ?? s.index_instruments?.rows ?? 0,
trading_days: s.index_daily?.trading_days ?? s.index_enriched?.trading_days ?? 0,
} : null
const indexOverviewLabel = s ? '日 · 维表 · 日K · 指标' : undefined
const indexEarliestDate = s?.index_daily?.earliest_date ?? s?.index_enriched?.earliest_date ?? null
const indexOffsetDays = indexExtendUnit === 'month' ? indexExtendValue * 30 : indexExtendValue * 365
const indexTargetDate = (() => {
const d = indexEarliestDate ? new Date(indexEarliestDate) : new Date()
d.setDate(d.getDate() - indexOffsetDays)
return d
})()
const indexTargetDateText = indexTargetDate.toISOString().slice(0, 10)
const indexSyncDays = Math.min(
5000,
Math.max(30, Math.ceil((Date.now() - indexTargetDate.getTime()) / 86_400_000) + 1),
)
const STAGE_CARD: Record<string, string> = {
sync_instruments: 'instruments',
sync_daily: 'daily',
extend_history: 'daily',
sync_adj: 'adj_factor',
compute_enriched: 'enriched',
rebuild_enriched: 'enriched',
sync_index: 'index_daily',
sync_minute: 'minute',
extend_minute: 'minute',
}
const activeCard = isRunning && job.data ? STAGE_CARD[job.data.stage] ?? null : null
const skippedCards = new Set(
(job.data?.result?.skipped_stages ?? [])
.map(s => STAGE_CARD[s])
.filter(Boolean) as string[]
)
const prevStageRef = useRef<string | null>(null)
const [doneStages, setDoneStages] = useState<Set<string>>(new Set())
useEffect(() => {
if (!job.data?.stage) return
const stage = job.data.stage
if (stage === prevStageRef.current) return
const prev = prevStageRef.current
if (prev && STAGE_CARD[prev]) {
setDoneStages((s) => new Set(s).add(STAGE_CARD[prev]))
}
prevStageRef.current = stage
qc.invalidateQueries({ queryKey: QK.dataStatus })
}, [job.data?.stage])
useEffect(() => {
if (!activeJobId) {
setDoneStages(new Set())
prevStageRef.current = null
}
}, [activeJobId])
const handleJobClick = useCallback((id: string) => {
setActiveJobId(id)
requestAnimationFrame(() => {
topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}, [])
return (
<>
<div ref={topRef} />
<PageHeader
title="数据"
subtitle="本地数据画像 · 同步状态 · 历史记录"
right={
<div className="flex items-center gap-3">
{!hasData && !isLoading && !isNoKey && (
<span className="text-xs text-accent animate-pulse">首次使用请点击右侧按钮同步数据</span>
)}
{isNoKey ? (
<button
disabled
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium opacity-40 cursor-not-allowed transition-all duration-150"
>
<Play className="h-3.5 w-3.5" />
立即同步
</button>
) : (
<button
onClick={() => startSync.mutate()}
disabled={isStarting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium hover:from-accent/35 hover:to-accent/20 disabled:opacity-40 transition-all duration-150"
>
{(isRunning || isStarting) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="h-3.5 w-3.5" />
)}
{isStarting ? '启动中…' : isRunning ? '同步中…' : '立即同步'}
</button>
)}
<div className="w-px h-4 bg-border" />
<div className="flex items-center gap-1.5">
<button
onClick={() => setShowCreateExt(true)}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-secondary hover:text-accent hover:bg-accent/8 text-xs transition-colors duration-150"
>
<Plus className="h-3.5 w-3.5" />
扩展数据
</button>
<button
onClick={() => setShowEndpointTest(true)}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-secondary hover:text-accent hover:bg-accent/8 text-xs transition-colors duration-150"
>
<Wifi className="h-3.5 w-3.5" />
测试端点
</button>
<button
onClick={() => setShowClearConfirm(true)}
disabled={isRunning}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-muted hover:text-danger hover:bg-danger/8 text-xs transition-colors duration-150 disabled:opacity-40 disabled:pointer-events-none"
>
<Trash2 className="h-3.5 w-3.5" />
清除数据
</button>
</div>
</div>
}
/>
<div className="px-8 py-6 space-y-6 max-w-6xl">
{/* 未配置 API Key 告警条 —— 引导用户去配置 Key 后才能同步 */}
{isNoKey && (
<div className="flex items-center gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2 text-xs">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
<span className="text-secondary leading-relaxed">
未配置 API Key,无法同步数据,请前往
<Link to="/settings?tab=account" className="mx-0.5 font-medium text-warning hover:underline">
配置
</Link>
</span>
</div>
)}
{/* 实时进度 */}
<AnimatePresence>
{job.data && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<ActiveJobCard job={job.data} />
</motion.div>
)}
</AnimatePresence>
{/* 实时行情 + 存储 + 调度 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<QuoteConfigCard
enabled={realtimeEnabled}
running={quoteStatus.data?.running ?? false}
isTrading={quoteStatus.data?.is_trading_hours ?? false}
lastFetchMs={quoteStatus.data?.last_fetch_ms ?? null}
intervalS={quoteInterval.data?.interval ?? quoteStatus.data?.interval_s ?? 10}
intervalMin={quoteInterval.data?.min_interval ?? 5}
intervalMax={quoteInterval.data?.max_interval ?? 60}
loading={quoteStatus.isLoading}
onToggle={(v) => toggleQuote.mutate(v)}
toggling={toggleQuote.isPending}
showIntervalEdit={showIntervalEdit}
onShowIntervalEdit={handleToggleIntervalEdit}
onIntervalChange={(v) => updateInterval.mutate(v)}
/>
{/* 自动调度 */}
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center gap-2 mb-3">
<Calendar className="h-4 w-4 text-secondary" />
<h3 className="text-sm font-medium text-foreground">自动调度</h3>
</div>
{isLoading ? (
<div className="space-y-2">
<div className="flex items-center justify-between"><Skeleton w="w-16" /><Skeleton w="w-28" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-16" /><Skeleton w="w-28" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-6" /><Skeleton w="w-20" /></div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-[10px] text-muted pb-2 border-b border-border/50">
<span className="text-accent/60 font-medium">盘前</span>
<span>标的维表</span>
<span className="text-border"></span>
<span className="text-accent/60 font-medium">盘后</span>
{pipelineSteps.map((step, i) => (
<span key={step} className="contents">
{i > 0 && <span className="text-border"></span>}
<span>{step}</span>
</span>
))}
</div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted">时区</span>
<span className="font-mono text-secondary">Asia/Shanghai</span>
</div>
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1">
<span className="text-muted">盘前 · 标的维表</span>
<span className="text-muted/50">·</span>
<span className="font-mono text-secondary">
{`${String(instrumentsSched.hour).padStart(2, '0')}:${String(instrumentsSched.minute).padStart(2, '0')}`}
</span>
<button
onClick={() => setShowInstScheduleEdit(v => !v)}
className={`p-0.5 rounded hover:bg-elevated transition-colors ${showInstScheduleEdit ? 'text-accent' : 'text-secondary'}`}
>
<Clock className="h-3 w-3" />
</button>
</div>
<div className="flex items-center gap-2 font-mono text-secondary">
{s?.last_instruments_run && (
<span className={`inline-flex flex-col items-center leading-tight ${isToday(s.last_instruments_run) ? 'text-bear' : 'text-secondary/70'}`}>
<span> {formatScheduleDatePart(s.last_instruments_run)}</span>
<span>{formatScheduleTimePart(s.last_instruments_run)}</span>
</span>
)}
{s?.next_instruments_run && (
<span className="inline-flex flex-col items-center leading-tight text-foreground">
<span> {formatScheduleDatePart(s.next_instruments_run)}</span>
<span>{formatScheduleTimePart(s.next_instruments_run)}</span>
</span>
)}
</div>
</div>
<AnimatePresence>
{showInstScheduleEdit && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<ScheduleEditor
value={instrumentsSched}
onSave={(h, m) => updateInstSchedule.mutate({ hour: h, minute: m })}
loading={updateInstSchedule.isPending}
hint="不晚于 09:15"
/>
</motion.div>
)}
</AnimatePresence>
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1">
<span className="text-muted">盘后 · 全量管道</span>
<span className="text-muted/50">·</span>
<span className="font-mono text-secondary">
{`${String(pipelineSched.hour).padStart(2, '0')}:${String(pipelineSched.minute).padStart(2, '0')}`}
</span>
<button
onClick={() => setShowScheduleEdit(v => !v)}
className={`p-0.5 rounded hover:bg-elevated transition-colors ${showScheduleEdit ? 'text-accent' : 'text-secondary'}`}
>
<Clock className="h-3 w-3" />
</button>
</div>
<div className="flex items-center gap-2 font-mono text-secondary">
{s?.last_pipeline_run && (
<span className={`inline-flex flex-col items-center leading-tight ${isToday(s.last_pipeline_run) ? 'text-bear' : 'text-secondary/70'}`}>
<span> {formatScheduleDatePart(s.last_pipeline_run)}</span>
<span>{formatScheduleTimePart(s.last_pipeline_run)}</span>
</span>
)}
{s?.next_pipeline_run && (
<span className="inline-flex flex-col items-center leading-tight text-foreground">
<span> {formatScheduleDatePart(s.next_pipeline_run)}</span>
<span>{formatScheduleTimePart(s.next_pipeline_run)}</span>
</span>
)}
</div>
</div>
<AnimatePresence>
{showScheduleEdit && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<ScheduleEditor
value={pipelineSched}
onSave={(h, m) => updateSchedule.mutate({ hour: h, minute: m })}
loading={updateSchedule.isPending}
hint="不早于 15:00"
/>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
{/* 存储 */}
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-secondary" />
<h3 className="text-sm font-medium text-foreground">存储</h3>
</div>
{isLoading ? (
<Skeleton w="w-12" />
) : (
<span className="font-mono text-xs text-muted">{s ? `${s.storage.total_size_mb} MB` : '—'}</span>
)}
</div>
<div className="space-y-2">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton w="w-10" />
<div className="flex items-center gap-3">
<Skeleton w="w-14" />
<Skeleton w="w-16" />
</div>
</div>
))
) : [
{ label: '标的维表', files: s?.storage.instruments_files, size: s?.storage.instruments_size_mb },
{ label: '日 K', files: s?.storage.daily_files, size: s?.storage.daily_size_mb },
{ label: '除权因子', files: s?.storage.adj_factor_files, size: s?.storage.adj_factor_size_mb },
{ label: 'Enriched', files: s?.storage.enriched_files, size: s?.storage.enriched_size_mb },
{ label: '分钟 K', files: s?.storage.minute_files, size: s?.storage.minute_size_mb },
{ label: '财务数据', files: s?.storage.financials_files, size: s?.storage.financials_size_mb },
].map((item) => (
<div key={item.label} className="flex items-center justify-between text-[11px]">
<span className="text-muted">{item.label}</span>
<div className="flex items-center gap-3">
<span className="font-mono text-secondary">{item.files ?? 0} 文件</span>
<span className="font-mono text-muted w-16 text-right">{(item.size ?? 0).toFixed(1)} MB</span>
</div>
</div>
))}
{/* 扩展数据 */}
{(extConfigs.data && (extConfigs.data.items?.length ?? 0) > 0) && (
<div className="flex items-center justify-between text-[11px] border-t border-border/50 pt-2 mt-1">
<span className="text-muted">扩展数据</span>
<div className="flex items-center gap-3">
<span className="font-mono text-secondary">{s?.storage.ext_data_files ?? extConfigs.data.items.length} 文件</span>
<span className="font-mono text-muted w-16 text-right">
{s?.storage.ext_data_size_mb != null ? `${s.storage.ext_data_size_mb.toFixed(1)} MB` : '—'}
</span>
</div>
</div>
)}
</div>
</div>
</div>
{/* 数据画像 */}
<div>
<SectionTitle icon={Database}>数据画像</SectionTitle>
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 items-stretch">
<StatCard
title="标的维表"
hint="盘前同步 · 元数据快照"
stats={s?.instruments}
isInstrument
loading={isLoading}
active={activeCard === 'instruments'}
done={doneStages.has('instruments')}
skipped={skippedCards.has('instruments')}
stagePct={activeCard === 'instruments' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="instruments"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('instruments')}
/>
<StatCard
title="日 K"
hint="增量同步 · 全市场"
stats={s?.daily}
loading={isLoading}
active={activeCard === 'daily'}
done={doneStages.has('daily')}
skipped={skippedCards.has('daily')}
stagePct={activeCard === 'daily' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('daily')}
onSettings={hasData ? () => setOpenSettings(v => v === 'daily' ? null : 'daily') : undefined}
settingsOpen={openSettings === 'daily'}
/>
<StatCard
title="除权因子"
hint="增量同步 · 全市场"
stats={s?.adj_factor}
loading={isLoading}
active={activeCard === 'adj_factor'}
done={doneStages.has('adj_factor')}
skipped={skippedCards.has('adj_factor')}
stagePct={activeCard === 'adj_factor' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="adj_factor"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('adj_factor')}
/>
<StatCard
title="Enriched"
hint="复权 OHLCV + 技术指标"
stats={s?.enriched}
loading={isLoading}
active={activeCard === 'enriched'}
done={doneStages.has('enriched')}
skipped={skippedCards.has('enriched')}
stagePct={activeCard === 'enriched' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="enriched"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
subLabel="字段 · 指标 · 信号"
localBadgeSuffix={`${prefs.data?.enriched_batch_size ?? 1000}只/批`}
onShowFields={() => setSchemaTable('enriched')}
onSettings={hasData ? () => setOpenSettings(v => v === 'enriched' ? null : 'enriched') : undefined}
settingsOpen={openSettings === 'enriched'}
/>
<StatCard
title="指数数据"
hint="CN_Index · 独立存储"
stats={indexOverviewStats}
loading={isLoading}
active={activeCard === 'index_daily'}
done={doneStages.has('index_daily')}
skipped={skippedCards.has('index_daily')}
stagePct={activeCard === 'index_daily' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
subLabel={indexOverviewLabel}
fieldTabs={[
{ label: '维表', table: 'index_instruments' },
{ label: '日K', table: 'index_daily' },
{ label: '指标', table: 'index_enriched' },
] as FieldTab[]}
onShowFields={(t) => setSchemaTable(t ?? 'index_daily')}
onSettings={hasData ? () => setOpenSettings(v => v === 'index' ? null : 'index') : undefined}
settingsOpen={openSettings === 'index'}
/>
<StatCard
title="分钟 K"
hint="全市场同步"
stats={s?.minute}
loading={isLoading}
active={activeCard === 'minute'}
done={doneStages.has('minute')}
skipped={skippedCards.has('minute')}
stagePct={activeCard === 'minute' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="minute"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={minuteAuto}
onShowFields={() => setSchemaTable('minute')}
onSettings={hasData ? () => setOpenSettings(v => v === 'minute' ? null : 'minute') : undefined}
settingsOpen={openSettings === 'minute'}
/>
<StatCard
title="财务数据"
hint="利润表 / 资负表 / 现金流 / 指标"
stats={s?.financials ? { rows: s.financials.rows } : null}
loading={isLoading}
tierKey="financials"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
/>
{(extConfigs.data?.items ?? []).map((ext) => (
<ExtDataStatCard
key={ext.id}
config={ext}
onDelete={() => deleteExt.mutate(ext.id)}
deleting={deleteExt.isPending}
onEdit={() => setEditingExt(ext)}
/>
))}
</div>
</div>
{/* 同步历史 */}
<div>
<SectionTitle icon={Clock}>同步历史</SectionTitle>
<div className="mt-3 rounded-card border border-border overflow-hidden">
{history.isLoading ? (
<div className="px-5 py-6 space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Skeleton w="w-4" h="h-4" rounded="rounded-full" />
<div className="space-y-1.5">
<Skeleton w="w-20" />
<Skeleton w="w-28" h="h-3" />
</div>
</div>
<Skeleton w="w-32" />
</div>
))}
</div>
) : history.data && history.data.jobs.length > 0 ? (
<div className="divide-y divide-border">
{history.data.jobs.map((j) => (
<HistoryRow key={j.id} job={j} onClick={() => handleJobClick(j.id)} />
))}
</div>
) : (
<div className="px-5 py-8 text-center text-sm text-muted">
暂无同步记录 点右上角"立即同步"开始。
</div>
)}
</div>
</div>
{startSync.isError && (
<div className="rounded-btn border border-danger/40 bg-danger/5 px-3 py-2 text-sm text-danger">
启动失败:{String((startSync.error as any).message)}
</div>
)}
</div>
{/* 弹窗 */}
<EnrichedSchemaModal
table={schemaTable}
onClose={() => setSchemaTable(null)}
/>
{showEndpointTest && (
<EndpointTestDialog
hasKey={settings.data?.mode === 'api_key'}
tierLabel={settings.data?.tier_label ?? ''}
currentEndpoint={settings.data?.current_endpoint ?? ''}
onClose={() => setShowEndpointTest(false)}
/>
)}
<AnimatePresence>
{showCreateExt && (
<CreateExtDialog onClose={() => setShowCreateExt(false)} />
)}
{editingExt && (
<EditExtDialog config={editingExt} onClose={() => setEditingExt(null)} />
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'daily' && (
<SettingsModal title="日 K · 向前扩展历史" onClose={() => setOpenSettings(null)}>
<ExtendHistoryPanel
caps={caps.data}
isRunning={!!activeJobId}
earliestDate={s?.daily?.earliest_date ?? null}
onStart={() => setOpenSettings(null)}
/>
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'enriched' && (
<SettingsModal title="Enriched · 计算设置" onClose={() => setOpenSettings(null)}>
<EnrichedRebuildPanel isRunning={!!activeJobId} onStart={() => setOpenSettings(null)} />
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'index' && (
<SettingsModal title="指数数据 · 手动获取" onClose={() => setOpenSettings(null)}>
<div className="space-y-4">
<div className="rounded-card border border-border bg-base/30 p-4 space-y-3">
<div>
<div className="text-sm font-medium text-foreground">指数日 K</div>
<div className="text-[11px] text-muted mt-1">获取数据时会先刷新 CN_Index 维表,再向前扩展指数历史;指数不需要复权。</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center">
<button
onClick={() => setIndexExtendValue(v => Math.max(1, v - 1))}
disabled={!hasDailyBatchCap || !!activeJobId || syncIndexDaily.isPending}
className="h-6 w-6 flex items-center justify-center rounded-l-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
></button>
<div className="h-6 w-8 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums text-foreground bg-base">
{indexExtendValue}
</div>
<button
onClick={() => setIndexExtendValue(v => Math.min(indexExtendUnit === 'year' ? 10 : 36, v + 1))}
disabled={!hasDailyBatchCap || !!activeJobId || syncIndexDaily.isPending}
className="h-6 w-6 flex items-center justify-center rounded-r-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>+</button>
</div>
<div className="flex rounded-btn border border-border overflow-hidden">
{(['month', 'year'] as const).map(u => (
<button
key={u}
onClick={() => { setIndexExtendUnit(u); if (u === 'year' && indexExtendValue > 10) setIndexExtendValue(1); if (u === 'month' && indexExtendValue > 36) setIndexExtendValue(6) }}
disabled={!hasDailyBatchCap || !!activeJobId || syncIndexDaily.isPending}
className={`px-2 py-0.5 text-[10px] font-medium transition-colors disabled:opacity-40 ${
indexExtendUnit === u ? 'bg-accent/15 text-accent' : 'text-secondary hover:bg-elevated'
}`}
>{u === 'month' ? '月' : '年'}</button>
))}
</div>
</div>
<div className="text-[10px] text-muted">
预计扩展至 <span className="font-mono text-secondary">{indexTargetDateText}</span>
{indexEarliestDate && <span> (当前最早: <span className="font-mono text-secondary">{indexEarliestDate}</span>)</span>}
</div>
<div className="rounded-btn border border-border bg-base/40 p-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-medium text-foreground">批次大小</div>
<div className="text-[10px] text-muted mt-0.5">每批同步并计算的指数数量,默认 100</div>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={1}
max={10000}
value={indexBatchInput}
onChange={e => setIndexBatchInput(e.target.value)}
disabled={updateIndexBatchSize.isPending || !!activeJobId || syncIndexDaily.isPending}
className="w-20 px-2 py-1 rounded-btn bg-elevated border border-border text-xs font-mono text-foreground outline-none focus:border-accent disabled:opacity-40"
/>
<button
onClick={() => {
const size = Math.max(1, Math.min(10000, Number(indexBatchInput) || 100))
setIndexBatchInput(String(size))
updateIndexBatchSize.mutate(size)
}}
disabled={updateIndexBatchSize.isPending || !!activeJobId || syncIndexDaily.isPending}
className="px-2.5 py-1 rounded-btn bg-elevated border border-border text-xs text-secondary hover:text-foreground disabled:opacity-40 transition-colors"
>
{updateIndexBatchSize.isPending ? '保存中…' : '保存'}
</button>
</div>
</div>
<div className="text-[10px] text-muted">
当前生效: <span className="font-mono text-secondary">{indexDailyBatchSize}</span>
</div>
</div>
<button
onClick={() => syncIndexDaily.mutate()}
disabled={!hasDailyBatchCap || !!activeJobId || syncIndexDaily.isPending}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 disabled:pointer-events-none transition-colors duration-150"
>
{syncIndexDaily.isPending ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
获取中…
</>
) : (
<>获取数据</>
)}
</button>
{!hasDailyBatchCap && (
<span className="text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium">
Starter+ / Pro 批量日 K 权限
</span>
)}
</div>
</div>
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'minute' && (
<SettingsModal title="分钟 K · 同步设置" onClose={() => setOpenSettings(null)}>
<MinuteSyncConfig caps={caps.data} isRunning={!!activeJobId} onStart={() => setOpenSettings(null)} />
</SettingsModal>
)}
</AnimatePresence>
{/* 清除数据二次确认弹窗 */}
<AnimatePresence>
{showClearConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => !clearData.isPending && setShowClearConfirm(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="relative w-[90vw] max-w-[420px] rounded-card border border-border bg-base shadow-2xl p-6"
>
<div className="flex items-start gap-3">
<div className="shrink-0 h-10 w-10 rounded-full bg-danger/12 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground mb-1.5">确认清除本地数据?</h3>
<p className="text-xs text-secondary leading-relaxed">
此操作将<span className="text-danger font-medium">永久删除</span>所有已同步的本地数据,包括:
</p>
<ul className="mt-2 text-[11px] text-muted leading-relaxed space-y-0.5">
<li>· 标的维表、日 K、除权因子</li>
<li>· Enriched 指标数据、分钟 K</li>
<li>· 财务数据、指数数据</li>
</ul>
<p className="mt-2 text-[11px] text-danger/90">
操作不可恢复,需重新执行同步才能恢复数据。
</p>
<div className="mt-2 flex items-start gap-1.5 text-[11px] text-warning">
<Info className="h-3.5 w-3.5 shrink-0 mt-px text-warning" />
<span>此操作不会清除扩展数据,如需删除请在扩展数据设置中单独操作。</span>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button
onClick={() => setShowClearConfirm(false)}
disabled={clearData.isPending}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors disabled:opacity-50"
>
取消
</button>
<button
onClick={() => clearData.mutate()}
disabled={clearData.isPending}
className="px-3 py-1.5 rounded-btn bg-danger/90 text-base text-sm font-medium hover:bg-danger disabled:opacity-50 transition-colors"
>
{clearData.isPending ? '清除中…' : '清除数据'}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
)
}