-
-
{page + 1} / {pageCount}
-
+ {filtered.length > 0 && (
+
+
第 {currentPage * PAGE + 1}–{Math.min((currentPage + 1) * PAGE, filtered.length)} 条,共 {filtered.length} 条
+ {pageCount > 1 && (
+
+
+ {currentPage + 1} / {pageCount}
+
+
+ )}
)}
>
)}
-
- 「尝试补齐」会向当前分钟数据源重新拉取该标的最近 10 个交易日的分钟 K。
+ {!hasCap &&
当前分钟数据源不可用,可查看本地缺口;配置可用数据源后可尝试补齐。
}
+
+ 补齐会向当前分钟数据源重新拉取标的最近 10 个交易日窗口的分钟 K,批量操作沿用同一规则。
分钟源通常只提供最近一段时间的分钟数据,超出窗口的历史缺口无法补齐,
此时应视为永久残缺 —— 不影响已有数据的查看。
diff --git a/frontend/src/components/data/MinuteSyncConfig.test.tsx b/frontend/src/components/data/MinuteSyncConfig.test.tsx
new file mode 100644
index 00000000..f4d6d476
--- /dev/null
+++ b/frontend/src/components/data/MinuteSyncConfig.test.tsx
@@ -0,0 +1,217 @@
+// @vitest-environment jsdom
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { MemoryRouter } from 'react-router-dom'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { api, type MinuteGapRow, type Preferences } from '@/lib/api'
+import { QK } from '@/lib/queryKeys'
+import { MinuteSyncConfig } from './MinuteSyncConfig'
+
+vi.mock('@/lib/api', () => ({ api: {
+ preferences: vi.fn(), updateMinuteSync: vi.fn(), syncMinute: vi.fn(),
+ clearMinute: vi.fn(), minuteGaps: vi.fn(), syncMinuteSingle: vi.fn(), repairMinuteGaps: vi.fn(),
+} }))
+vi.mock('@/components/Toast', () => ({ toast: vi.fn() }))
+
+let host: HTMLDivElement
+let root: Root
+let client: QueryClient
+const onJobStart = vi.fn()
+const rows: MinuteGapRow[] = Array.from({ length: 24 }, (_, i) => ({
+ symbol: `${String(i + 1).padStart(6, '0')}.SZ`, date: '2026-10-20',
+ actual_bars: 171, expected_bars: 240, missing_bars: 69,
+ first_time: '09:30', last_time: '13:49',
+}))
+const report = {
+ status: 'ok', scanned_dates: 10, scanned_symbols: 5580, complete_dates: [],
+ inactive_symbol_days: 0,
+ gaps: rows, summary_by_date: [{ date: '2026-10-20', gap_symbols: 24, missing_bars: 1656, expected_bars: 240 }],
+ truncated: false,
+}
+
+beforeEach(() => {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
+ vi.resetAllMocks()
+ host = document.createElement('div')
+ document.body.append(host)
+ root = createRoot(host)
+ client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
+ const prefs = { minute_sync_enabled: false, minute_sync_days: 5, minute_sync_segment_days: 20 } as Preferences
+ vi.mocked(api.preferences).mockResolvedValue(prefs)
+ vi.mocked(api.updateMinuteSync).mockResolvedValue(prefs)
+ vi.mocked(api.minuteGaps).mockResolvedValue(report)
+ vi.mocked(api.syncMinute).mockResolvedValue({ job_id: 'minute-job', status: 'started' })
+ vi.mocked(api.repairMinuteGaps).mockResolvedValue({ job_id: 'repair-job', status: 'started' })
+})
+
+it('零成交残留单独说明, 不提供无效补拉按钮', async () => {
+ vi.mocked(api.minuteGaps).mockResolvedValue({ ...report, gaps: [], summary_by_date: [], inactive_symbol_days: 2 })
+ await render()
+ await click('数据体检')
+ expect(host.textContent).toContain('未发现分钟数据缺口')
+ expect(host.textContent).toContain('另有 2 组整日零成交记录')
+ expect(host.textContent).not.toContain('一键补齐')
+ expect(api.repairMinuteGaps).not.toHaveBeenCalled()
+})
+afterEach(async () => {
+ await act(async () => root.unmount())
+ client.clear()
+ host.remove()
+})
+
+async function render(hasCap = true) {
+ await act(async () => root.render(
+
+ ))
+ await settle()
+}
+async function settle() {
+ await act(async () => { await new Promise(r => setTimeout(r, 20)) })
+}
+async function click(text: string) {
+ const button = [...host.querySelectorAll('button')].find(b => b.textContent?.includes(text))!
+ expect(button).toBeTruthy()
+ await act(async () => button.click())
+ await settle()
+}
+async function filter(value: string) {
+ const input = host.querySelector
('input[placeholder="按代码 / 日期过滤"]')!
+ expect(input).toBeTruthy()
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value)
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+}
+
+it('同步设置和体检分开展示, 切换页签不丢失筛选条件', async () => {
+ await render()
+ const panels = host.querySelectorAll('[role="tabpanel"]')
+ expect(panels).toHaveLength(2)
+ expect(panels[0].hidden).toBe(false)
+ expect(panels[1].hidden).toBe(true)
+ await click('数据体检')
+ expect(panels[0].hidden).toBe(true)
+ expect(panels[1].hidden).toBe(false)
+ expect(panels[1].textContent).toContain('000001.SZ')
+ await filter('000001')
+ await click('同步与获取')
+ await click('数据体检')
+ expect(host.querySelector('input')?.value).toBe('000001')
+})
+
+it('页签支持方向键切换并把键盘焦点移到选中项', async () => {
+ await render()
+ const tabs = host.querySelectorAll('[role="tab"]')
+ await act(async () => {
+ tabs[0].focus()
+ tabs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
+ })
+ expect(tabs[1].getAttribute('aria-selected')).toBe('true')
+ expect(document.activeElement).toBe(tabs[1])
+ await act(async () => tabs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })))
+ expect(tabs[0].getAttribute('aria-selected')).toBe('true')
+ expect(document.activeElement).toBe(tabs[0])
+})
+
+it('无匹配记录时保留搜索框和真实缺口状态, 可清除筛选', async () => {
+ await render()
+ await click('数据体检')
+ await filter('不存在')
+ expect(host.querySelector('input')).not.toBeNull()
+ expect(host.textContent).toContain('没有匹配的缺口记录')
+ expect(host.textContent).toContain('24 条缺口')
+ expect(host.textContent).not.toContain('未发现分钟数据缺口')
+ await filter('')
+ expect(host.textContent).toContain('000001.SZ')
+})
+
+it('分页限制单页记录, 刷新后记录减少仍能显示有效页', async () => {
+ await render()
+ await click('数据体检')
+ expect(host.textContent).toContain('000010.SZ')
+ expect(host.textContent).not.toContain('000011.SZ')
+ await click('下一页')
+ await click('下一页')
+ expect(host.textContent).toContain('000024.SZ')
+ await act(async () => { client.setQueryData(QK.minuteGaps, { ...report, gaps: rows.slice(0, 1) }) })
+ await settle()
+ expect(host.textContent).toContain('000001.SZ')
+})
+
+it('手动获取仍使用分段天数并通知任务, 清空必须经过二次确认', async () => {
+ await render()
+ await click('单次获取 20 天')
+ expect(api.syncMinute).toHaveBeenCalledWith(20, true)
+ expect(onJobStart).toHaveBeenCalledWith('minute-job')
+ await click('清空分钟K数据')
+ expect(api.clearMinute).not.toHaveBeenCalled()
+ expect(host.textContent).toContain('确认清空分钟K数据?')
+ await click('取消')
+ expect(api.clearMinute).not.toHaveBeenCalled()
+})
+
+it('无分钟能力时禁用拉取和补齐, 仍能查看本地体检结果', async () => {
+ await render(false)
+ expect([...host.querySelectorAll('button')].find(b => b.textContent?.includes('单次获取'))?.disabled).toBe(true)
+ await click('数据体检')
+ const repairs = [...host.querySelectorAll('button')].filter(b => b.textContent?.includes('尝试补齐'))
+ expect(repairs.length).toBeGreaterThan(0)
+ expect(repairs.every(b => b.disabled)).toBe(true)
+ expect([...host.querySelectorAll('button')].find(b => b.textContent?.includes('一键补齐'))?.disabled).toBe(true)
+})
+
+it('一键补齐覆盖当前筛选的全部分页, 同一标的只提交一次', async () => {
+ const repeated = rows.map((r, i) => ({ ...r, symbol: rows[i % 12].symbol, date: i < 12 ? '2026-10-20' : '2026-10-21' }))
+ vi.mocked(api.minuteGaps).mockResolvedValue({ ...report, gaps: repeated })
+ await render()
+ await click('数据体检')
+ await click('一键补齐')
+ expect(api.repairMinuteGaps).toHaveBeenCalledTimes(1)
+ expect(api.repairMinuteGaps).toHaveBeenCalledWith(rows.slice(0, 12).map(r => r.symbol))
+ expect(api.syncMinuteSingle).not.toHaveBeenCalled()
+ expect(onJobStart).toHaveBeenCalledWith('repair-job')
+})
+
+it('一键补齐遵守代码筛选, 无匹配项时不能发起任务', async () => {
+ await render()
+ await click('数据体检')
+ await filter('不存在')
+ expect([...host.querySelectorAll('button')].find(b => b.textContent?.includes('一键补齐'))?.disabled).toBe(true)
+ await filter('000001')
+ await click('一键补齐')
+ expect(api.repairMinuteGaps).toHaveBeenCalledWith(['000001.SZ'])
+})
+
+it('批量启动期间禁用重复操作, 失败后显示原因并恢复按钮', async () => {
+ let reject!: (error: Error) => void
+ vi.mocked(api.repairMinuteGaps).mockImplementation(() => new Promise((_, fail) => { reject = fail }))
+ await render()
+ await click('数据体检')
+ await click('一键补齐')
+ const repairs = [...host.querySelectorAll('button')].filter(b => b.textContent?.includes('尝试补齐'))
+ expect(repairs.every(b => b.disabled)).toBe(true)
+ await act(async () => reject(new Error('已有数据任务正在运行')))
+ await settle()
+ expect(host.querySelector('[role="alert"]')?.textContent).toContain('已有数据任务正在运行')
+ expect([...host.querySelectorAll('button')].find(b => b.textContent?.includes('一键补齐'))?.disabled).toBe(false)
+ expect(onJobStart).not.toHaveBeenCalled()
+})
+
+it('体检加载时显示扫描进度', async () => {
+ vi.mocked(api.minuteGaps).mockImplementationOnce(() => new Promise(() => {}))
+ await render()
+ await click('数据体检')
+ expect(host.textContent).toContain('正在扫描分钟分区')
+})
+
+it('体检覆盖失败和空结果状态', async () => {
+ vi.mocked(api.minuteGaps).mockResolvedValueOnce({ ...report, gaps: [], summary_by_date: [] })
+ await render()
+ await click('数据体检')
+ expect(host.textContent).toContain('未发现分钟数据缺口')
+ vi.mocked(api.minuteGaps).mockRejectedValueOnce(new Error('无法读取分钟分区'))
+ await click('重新体检')
+ expect(host.textContent).toContain('体检失败')
+ expect(host.textContent).toContain('无法读取分钟分区')
+})
diff --git a/frontend/src/components/data/MinuteSyncConfig.tsx b/frontend/src/components/data/MinuteSyncConfig.tsx
index d1d11078..5e4fd956 100644
--- a/frontend/src/components/data/MinuteSyncConfig.tsx
+++ b/frontend/src/components/data/MinuteSyncConfig.tsx
@@ -1,13 +1,17 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useId, useRef } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { Loader2, Trash2, Download, Calendar } from 'lucide-react'
+import { Loader2, Trash2, Download, Calendar, SlidersHorizontal, Activity } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { MissingCapChip } from '@/lib/capability-labels'
import { MinuteGapPanel } from '@/components/data/MinuteGapPanel'
// hasCap: 分钟K能力当前是否可用 (路由矩阵判定, 生效源含插件/自定义源)
-export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJobStart?: (jobId: string) => void }) {
+export function MinuteSyncConfig({ hasCap, onJobStart, onClose }: { hasCap: boolean; onJobStart?: (jobId: string) => void; onClose?: () => void }) {
+ const [tab, setTab] = useState<'sync' | 'health'>('sync')
+ const tabId = useId()
+ const syncTabRef = useRef(null)
+ const healthTabRef = useRef(null)
const qc = useQueryClient()
const prefs = useQuery({
queryKey: QK.preferences,
@@ -76,12 +80,54 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
}
return (
-
+
+
{
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return
+ e.preventDefault()
+ const next = e.key === 'Home' ? 'sync' : e.key === 'End' ? 'health' : tab === 'sync' ? 'health' : 'sync'
+ setTab(next)
+ ;(next === 'sync' ? syncTabRef : healthTabRef).current?.focus()
+ }}
+ >
+ {([
+ { key: 'sync', label: '同步与获取', icon: SlidersHorizontal, ref: syncTabRef },
+ { key: 'health', label: '数据体检', icon: Activity, ref: healthTabRef },
+ ] as const).map(({ key, label, icon: Icon, ref }) => (
+
+ ))}
+
+
{/* 区块 A: 自动同步 (盘后定时拉取的偏好设置) */}
-
-
+
+
setDays(localDays + 1)}
+ aria-label="增加自动同步天数"
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"
>+
@@ -123,7 +171,7 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
{/* 分段大小: 控制单次 SDK 请求覆盖的交易日数。每段拉完即落盘,避免全量攒内存 OOM。
「往前获取」与「获取 1 年」共用此设置 (两者都经过 sync_and_persist_minute)。 */}
-
+
分段大小
内存优化
@@ -132,6 +180,7 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
setSegment(localSegment - 5)}
+ aria-label="减小分段大小"
disabled={!hasMinuteCap || localSegment <= 5}
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"
>−
@@ -140,6 +189,7 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
setSegment(localSegment + 5)}
+ aria-label="增大分段大小"
disabled={!hasMinuteCap || localSegment >= 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"
>+
@@ -147,14 +197,14 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
交易日/段
-
- 每段拉完即写盘,避免内存堆积。越小越省内存但越慢,默认 20 平衡。
+
+ 每段拉完即写盘,避免内存堆积。分段越小越省内存,获取耗时越长;默认 20 个交易日。设置自动保存。
{/* 区块 B: 手动获取 (一次性操作, 独立于上方自动同步开关) */}
-
-
+
+
手动获取
不受自动同步开关影响
@@ -163,7 +213,7 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
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"
+ className="inline-flex min-h-20 flex-col items-center justify-center gap-1 px-2 py-3 rounded-btn bg-accent/90 text-white text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
{fetchingMode === '40d' ? (
<>获取中…>
@@ -174,7 +224,7 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
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"
+ className="inline-flex min-h-20 flex-col items-center justify-center gap-1 px-2 py-3 rounded-btn border border-accent/30 bg-accent/5 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-40 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
{fetchingMode === '1y' ? (
<>分段获取中…>
@@ -183,33 +233,35 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo
)}
-
+
A股标的 · 前复权价格 · 从本地最早数据向前叠加 ·{' '}
均按上方「分段大小」分段拉取、每段即落盘
-
- {/* 区块 D: 缺口体检 (发现本地分区中不完整的 标的×交易日) */}
-
-
- 数据体检
-
- 找出残缺的交易日照
-
-
-
-
+
+
+
+
{/* 区块 C: 清空 (危险操作, 独立分隔) */}
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"
+ className="inline-flex items-center justify-center gap-1.5 px-2.5 py-2 rounded-btn text-danger/80 text-[11px] font-medium hover:bg-danger/10 disabled:opacity-40 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-danger/60"
>
清空分钟K数据
+ {onClose && 关闭}
+
{/* 清空确认弹窗 */}
{confirmClear && (
diff --git a/frontend/src/components/data/SettingsModal.tsx b/frontend/src/components/data/SettingsModal.tsx
index d5bb48b8..b8f4a2f1 100644
--- a/frontend/src/components/data/SettingsModal.tsx
+++ b/frontend/src/components/data/SettingsModal.tsx
@@ -2,12 +2,14 @@ import { motion } from 'framer-motion'
import { X } from 'lucide-react'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
-export function SettingsModal({ title, onClose, children, width = 'max-w-md' }: {
+export function SettingsModal({ title, onClose, children, width = 'max-w-md', contentClassName = 'min-h-0 flex-1 overflow-y-auto p-4 sm:p-5' }: {
title: string
onClose: () => void
children: React.ReactNode
/** 弹窗最大宽度 (Tailwind max-w-* 类), 默认 max-w-md */
width?: string
+ /** 有固定页签/操作栏时, 由内容组件管理唯一的滚动区。 */
+ contentClassName?: string
}) {
const backdrop = useDialogBackdrop(onClose)
return (
@@ -18,15 +20,19 @@ export function SettingsModal({ title, onClose, children, width = 'max-w-md' }:
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 rounded-card border border-border bg-surface shadow-2xl mx-4 w-full ${width} max-h-[88vh] flex flex-col overflow-hidden`}
+ role="dialog"
+ aria-modal="true"
+ aria-label={title}
+ // 百分比跟随 #root 的缩放补偿高度, vh 会在界面放大后把底部推出视口。
+ className={`relative rounded-dialog border border-border bg-surface shadow-2xl mx-4 w-full ${width} max-h-[92%] flex flex-col overflow-hidden`}
>
{title}
-
+
-
+
{children}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index d09c8353..27f2e287 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -2832,6 +2832,7 @@ export const api = {
scanned_dates: number
scanned_symbols: number
complete_dates: string[]
+ inactive_symbol_days?: number
gaps: MinuteGapRow[]
summary_by_date: { date: string; gap_symbols: number; missing_bars: number; expected_bars: number }[]
truncated: boolean
@@ -3231,6 +3232,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ symbol, ...(days != null ? { days } : {}) }),
}),
+ repairMinuteGaps: (symbols: string[]) =>
+ request<{ status: string; job_id: string }>('/api/kline/repair_minute_gaps', {
+ method: 'POST',
+ body: JSON.stringify({ symbols }),
+ }),
clearMinute: () =>
request<{ status: string; removed: number }>('/api/kline/clear_minute', {
method: 'POST',
diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts
index a459214f..ff810cea 100644
--- a/frontend/src/lib/queryKeys.ts
+++ b/frontend/src/lib/queryKeys.ts
@@ -14,6 +14,11 @@ export const DAILY_DATA_QUERY_PREFIXES = new Set([
'regime-history', 'regime-latest', 'regime-states', 'regime-coverage', 'regime-phases', 'regime-mainline',
])
+/** 分钟落盘补拉后的体检与直接消费查询, 不影响日K/财务或独立的指数分钟数据。 */
+export const MINUTE_DATA_QUERY_PREFIXES = new Set([
+ 'minute-gaps', 'minute-batch', 'kline-minute', 'kline-minute-range',
+])
+
export const QK = {
// 全局 / 共享 (Layout 预取)
capabilities: ['capabilities'] as const,
diff --git a/frontend/src/pages/Data.tsx b/frontend/src/pages/Data.tsx
index afff37a9..8ae027a7 100644
--- a/frontend/src/pages/Data.tsx
+++ b/frontend/src/pages/Data.tsx
@@ -1217,8 +1217,8 @@ export function Data() {
{openSettings === 'minute' && (
- setOpenSettings(null)}>
- { setActiveJobId(jobId); setOpenSettings(null) }} />
+ setOpenSettings(null)}>
+ setOpenSettings(null)} onJobStart={(jobId) => { setActiveJobId(jobId); setOpenSettings(null) }} />
)}