feat(minute-k): 分钟K重做 — 前复权 + 向前扩展 + 清空 + 单独拉取 + 进度

- kline_sync: adjust="forward" 前复权; sync_minute_batch 按月分段(57天/段)
  突破 count=10000 上限; on_chunk_done 回调带日期段标签
- kline.py: 删除 extend_minute_history; 新增 sync_minute_single(单股拉取落库)
  + clear_minute(仅清分钟K二次确认) + sync_minute 可选 days/extend 参数
- preferences/settings: 默认 5 天上限 30 (恢复原始); 超时强制释放 _heavy_run_lock
- MinuteSyncConfig: 自动同步(5天/关闭) + 往前获取(单次拉满) + 获取1年(分段)
  + 清空按钮 + 进度回调 onJobStart
- StockIntradayChart: 单股获取改用 syncMinuteSingle (落库)
- daily_pipeline: _minute_chunk_progress 兼容 seg_label 参数
This commit is contained in:
shy3130
2026-07-12 11:04:34 +08:00
parent a7acc5e49d
commit fc85d53bee
8 changed files with 319 additions and 386 deletions
@@ -32,11 +32,10 @@ export function StockIntradayChart({
})
const fetchMinute = useMutation({
mutationFn: () => api.extendMinuteHistory(5, 'day'),
mutationFn: () => api.syncMinuteSingle(symbol),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['kline-minute', symbol] })
qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
qc.invalidateQueries({ queryKey: QK.klineMinute(symbol, date ?? '') })
setMinuteDismissed(false)
},
})
@@ -61,7 +60,7 @@ export function StockIntradayChart({
{fetchMinute.isPending ? (
<div className="flex items-center justify-center h-full gap-2 text-xs text-accent">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>5K</span>
<span>K数据</span>
</div>
) : sourceIsNone ? (
// 数据源确认无此日分钟数据 (停牌/复牌延迟等): 静态提示 + 保留重试
+95 -141
View File
@@ -1,11 +1,10 @@
import { useState, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { Loader2, Trash2, Download, Calendar } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { isExpertOrAbove } from '@/lib/capability-labels'
export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined; isRunning: boolean; onStart: () => void }) {
export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined; onJobStart?: (jobId: string) => void }) {
const qc = useQueryClient()
const prefs = useQuery({
queryKey: QK.preferences,
@@ -29,8 +28,40 @@ export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label:
update.mutate({ enabled: !enabled, days: localDays })
}
const setDays = (v: number) => {
const clamped = Math.max(1, Math.min(30, v))
setLocalDays(clamped)
update.mutate({ enabled, days: clamped })
}
// 清空分钟K数据 (二次确认)
const [confirmClear, setConfirmClear] = useState(false)
const clearMutation = useMutation({
mutationFn: () => api.clearMinute(),
onSuccess: () => {
setConfirmClear(false)
qc.invalidateQueries({ queryKey: QK.dataStatus })
},
})
// 手动获取 (两个独立按钮, 各自指定天数, 不影响自动同步偏好)
const [fetchingMode, setFetchingMode] = useState<'' | '40d' | '1y'>('')
const handleFetch = (mode: '40d' | '1y') => {
if (!hasMinuteCap) return
// 两个按钮都用向前扩展模式: 从本地最早数据往前补, 叠加避免缺口
const fetchDays = mode === '40d' ? 40 : 365
setFetchingMode(mode)
api.syncMinute(fetchDays, true).then((res) => {
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
qc.invalidateQueries({ queryKey: QK.dataStatus })
// 通知主页面跟踪 job 进度 (ActiveJobCard 会显示实时进度+日志)
if (res.job_id && onJobStart) onJobStart(res.job_id)
}).finally(() => setFetchingMode(''))
}
return (
<div className="px-4 pb-4 pt-3 border-t border-accent/20 space-y-3">
{/* 第 1 行: 自动同步开关 + 天数 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<button
@@ -47,173 +78,96 @@ export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label:
/>
</button>
<span className="text-xs text-foreground font-medium">
{enabled ? '自动同步' : '已关闭'}
{enabled ? '盘后自动同步' : '已关闭'}
</span>
</div>
{!hasMinuteCap && (
<span className="text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium">
Pro+
</span>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-secondary"></span>
<div className="flex items-center gap-2">
<div className="flex items-center">
<button
onClick={() => { const v = Math.max(1, localDays - 1); setLocalDays(v); update.mutate({ enabled, days: v }) }}
onClick={() => setDays(localDays - 1)}
disabled={!hasMinuteCap || !enabled || localDays <= 1}
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 ${
enabled ? 'text-foreground bg-base' : 'text-muted bg-elevated/50'
}`}
>
></button>
<div className={`h-6 w-8 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums ${enabled ? 'text-foreground bg-base' : 'text-muted bg-elevated/50'}`}>
{localDays}
</div>
<button
onClick={() => { const v = Math.min(15, localDays + 1); setLocalDays(v); update.mutate({ enabled, days: v }) }}
disabled={!hasMinuteCap || !enabled || localDays >= 15}
onClick={() => setDays(localDays + 1)}
disabled={!hasMinuteCap || !enabled || localDays >= 30}
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>
>+</button>
</div>
<span className="text-[10px] text-muted"></span>
{!hasMinuteCap && (
<span className="text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium"> Pro+</span>
)}
</div>
</div>
<div className="pt-2 border-t border-border space-y-2.5">
<div className="text-[10px] text-secondary"></div>
<MinuteExtendControls hasMinuteCap={hasMinuteCap} tierLabel={caps?.label ?? ''} isRunning={isRunning} onStart={onStart} />
</div>
<div className="text-[10px] text-muted">
A股标的 · ()
</div>
</div>
)
}
function MinuteExtendControls({ hasMinuteCap, tierLabel, isRunning, onStart }: { hasMinuteCap: boolean; tierLabel: string; isRunning: boolean; onStart: () => void }) {
const qc = useQueryClient()
// 月单位(按月扩展更长的分钟K历史)仅 Expert+ 开放;Pro 仅可用"天"(1~15 天)
const canUseMonth = isExpertOrAbove(tierLabel)
const [unit, setUnit] = useState<'day' | 'month'>('day')
const [value, setValue] = useState(5)
const [confirmOpen, setConfirmOpen] = useState(false)
const dataStatus = useQuery({
queryKey: QK.dataStatus,
queryFn: api.dataStatus,
})
// 判断本地是否已有分钟K数据:后端 _safe_aggregate_minute 为避免全表扫描,
// rows 恒为 0,改用 trading_days(分区目录数,真实统计)判断。
const hasMinuteData = !!(dataStatus.data?.minute?.trading_days)
const extend = useMutation({
mutationFn: () => api.extendMinuteHistory(value, unit),
onSuccess: () => {
onStart()
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
qc.invalidateQueries({ queryKey: QK.dataStatus })
},
})
// 各单位上限:day 15 天,month 6 月(180 天)
const maxValue = unit === 'month' ? 6 : 15
const handleFetch = () => {
if (!hasMinuteData) {
setConfirmOpen(true)
} else {
extend.mutate()
}
}
// 切换单位时把 value clamp 到新单位的上限
const switchUnit = (u: 'day' | 'month') => {
if (u === unit) return
setUnit(u)
const max = u === 'month' ? 6 : 15
setValue(v => Math.min(v, max))
}
return (
<>
<div className="flex items-center gap-2">
<div className="flex items-center">
<button
onClick={() => setValue(Math.max(1, value - 1))}
disabled={!hasMinuteCap || isRunning || extend.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">
{value}
</div>
<button
onClick={() => setValue(Math.min(maxValue, value + 1))}
disabled={!hasMinuteCap || isRunning || extend.isPending || value >= maxValue}
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>
{canUseMonth ? (
<div className="flex rounded-btn border border-border overflow-hidden">
{(['day', 'month'] as const).map(u => (
<button
key={u}
onClick={() => switchUnit(u)}
className={`px-2 py-0.5 text-[10px] font-medium transition-colors ${
unit === u ? 'bg-accent/15 text-accent' : 'text-secondary hover:bg-elevated'
}`}
>{u === 'day' ? '天' : '月'}</button>
))}
</div>
) : (
<span className="text-[10px] text-muted"></span>
)}
{/* 第 2 行: 两个手动获取按钮 (40天快速 / 1年分段) */}
<div className="pt-2 border-t border-border grid grid-cols-2 gap-2">
<button
onClick={() => handleFetch('40d')}
disabled={!hasMinuteCap || fetchingMode !== ''}
className="inline-flex flex-col items-center justify-center gap-0.5 px-2 py-2 rounded-btn bg-accent/90 text-foreground text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors duration-150"
>
{fetchingMode === '40d' ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /><span></span></>
) : (
<><Download className="h-3.5 w-3.5" /><span> ()</span></>
)}
</button>
<button
onClick={() => handleFetch('1y')}
disabled={!hasMinuteCap || fetchingMode !== ''}
className="inline-flex flex-col items-center justify-center gap-0.5 px-2 py-2 rounded-btn border border-amber-400/40 bg-amber-400/10 text-amber-400 text-xs font-medium hover:bg-amber-400/20 disabled:opacity-40 transition-colors duration-150"
>
{fetchingMode === '1y' ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /><span></span></>
) : (
<><Calendar className="h-3.5 w-3.5" /><span> 1 </span><span className="text-[9px] opacity-70"></span></>
)}
</button>
</div>
{/* 第 3 行: 清空 */}
<button
onClick={handleFetch}
disabled={!hasMinuteCap || isRunning || extend.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"
onClick={() => setConfirmClear(true)}
disabled={clearMutation.isPending}
title="清空分钟K数据"
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn border border-danger/30 text-danger/80 text-xs font-medium hover:bg-danger/10 disabled:opacity-40 transition-colors duration-150"
>
{extend.isPending ? (
<><Loader2 className="h-3 w-3 animate-spin" /></>
) : (
<></>
)}
<Trash2 className="h-3 w-3" />
K数据
</button>
{confirmOpen && (
{/* 说明 */}
<div className="text-[10px] text-muted leading-relaxed">
A股标的 · · ·{' '}
<span className="text-accent"></span> 40 ,{' '}
<span className="text-amber-400">1 </span> ()
</div>
{/* 清空确认弹窗 */}
{confirmClear && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setConfirmOpen(false)} />
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => !clearMutation.isPending && setConfirmClear(false)} />
<div className="relative rounded-card border border-border bg-surface shadow-2xl mx-4 px-6 py-5 max-w-sm w-full space-y-4">
<div className="text-sm text-foreground text-center">K数据 {value} {unit === 'month' ? '月' : '天'}K</div>
<div className="text-sm text-foreground text-center font-medium">K数据</div>
<div className="text-[11px] text-muted text-center leading-relaxed">
K (kline_minute) , <span className="text-foreground/80"></span>K
</div>
<div className="flex items-center justify-center gap-3">
<button
onClick={() => { setConfirmOpen(false); extend.mutate() }}
disabled={extend.isPending}
className="px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors duration-150"
>
</button>
<button
onClick={() => setConfirmOpen(false)}
className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs hover:bg-elevated/80 transition-colors duration-150"
>
<button onClick={() => setConfirmClear(false)} disabled={clearMutation.isPending}
className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs hover:bg-elevated/80 transition-colors duration-150"></button>
<button onClick={() => clearMutation.mutate()} disabled={clearMutation.isPending}
className="px-4 py-1.5 rounded-btn bg-danger/90 text-foreground text-xs font-medium hover:bg-danger disabled:opacity-40 transition-colors duration-150">
{clearMutation.isPending ? <span className="inline-flex items-center gap-1.5"><Loader2 className="h-3 w-3 animate-spin" /></span> : '确认清空'}
</button>
</div>
</div>
</div>
)}
</>
</div>
)
}
+15 -7
View File
@@ -1236,8 +1236,21 @@ export const api = {
`/api/kline/sync?symbol=${encodeURIComponent(symbol)}&days=${days}`,
{ method: 'POST' },
),
syncMinute: () =>
request<{ status: string; job_id: string }>('/api/kline/sync_minute', { method: 'POST' }),
syncMinute: (days?: number, extend?: boolean) =>
request<{ status: string; job_id: string }>('/api/kline/sync_minute', {
method: 'POST',
body: JSON.stringify({ ...(days ? { days } : {}), ...(extend ? { extend: true } : {}) }),
}),
syncMinuteSingle: (symbol: string) =>
request<{ status: string; symbol: string; rows: number }>('/api/kline/sync_minute_single', {
method: 'POST',
body: JSON.stringify({ symbol }),
}),
clearMinute: () =>
request<{ status: string; removed: number }>('/api/kline/clear_minute', {
method: 'POST',
body: JSON.stringify({ confirm: true }),
}),
extendHistory: (value: number, unit: 'day' | 'month' | 'year') =>
request<{ status: string; job_id: string }>('/api/kline/extend_history', {
method: 'POST',
@@ -1248,11 +1261,6 @@ export const api = {
method: 'POST',
body: JSON.stringify({ start_date: startDate }),
}),
extendMinuteHistory: (value: number, unit: 'day' | 'month') =>
request<{ status: string; job_id: string }>('/api/kline/extend_minute_history', {
method: 'POST',
body: JSON.stringify({ value, unit }),
}),
rebuildEnriched: () =>
request<{ status: string; job_id: string }>('/api/kline/rebuild_enriched', {
method: 'POST',
+1 -1
View File
@@ -1085,7 +1085,7 @@ export function Data() {
<AnimatePresence>
{openSettings === 'minute' && (
<SettingsModal title="分钟 K · 同步设置" onClose={() => setOpenSettings(null)}>
<MinuteSyncConfig caps={caps.data} isRunning={!!activeJobId} onStart={() => setOpenSettings(null)} />
<MinuteSyncConfig caps={caps.data} onJobStart={(jobId) => { setActiveJobId(jobId); setOpenSettings(null) }} />
</SettingsModal>
)}
</AnimatePresence>