feat(data): 无除权因子能力时同步前置二次确认

除权因子不可用时同步静默降级 (日K不复权入库、enriched 直接用 raw 价),
此前无任何提示。现于两个同步入口 (数据页同步按钮、首用欢迎弹窗开始获取)
检测能力矩阵 usable 标志, 不可用先弹确认: 告知不复权口径与除权事件后
需重新校准, 支持不再提示 (localStorage)。矩阵未加载完成时 fail-open 不拦截。
This commit is contained in:
shy3130
2026-08-31 20:34:27 +08:00
parent bfc2eede3e
commit bfe01e06d9
3 changed files with 120 additions and 3 deletions
@@ -0,0 +1,105 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { AlertTriangle, Info } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
// 无除权因子能力时的同步前置确认。
// 背景: 除权因子不可用时同步静默降级 —— 日K按不复权价入库、enriched 直接用
// raw 价算指标 (pipeline 契约), 后续发生除权事件已有数据需重新校准。
// 该降级无任何运行期提示, 故在同步入口处前置拦截告知一次。
const NO_REMIND_KEY = 'tsp_adj_factor_no_remind'
/**
* 同步入口的除权因子门控。
* guard(run): 除权因子能力可用 (或矩阵未加载完成 fail-open / 用户已选不再提示) 时直接执行,
* 否则弹二次确认; dialog 需渲染在调用方页面根部。
*/
export function useAdjFactorSyncGate() {
const matrix = useQuery({
queryKey: QK.capabilityMatrix,
queryFn: api.capabilityMatrix,
staleTime: 60_000,
})
const [pending, setPending] = useState<{ run: () => void } | null>(null)
const [noRemind, setNoRemind] = useState(() => {
try { return localStorage.getItem(NO_REMIND_KEY) === '1' } catch { return false }
})
const adjRoute = matrix.data?.capabilities.find(c => c.id === 'adj_factor')
// 矩阵未加载完成时不拦截 (fail-open): 首次同步流程不被慢请求卡住
const adjUsable = matrix.isLoading || !matrix.data ? true : (adjRoute?.usable ?? true)
const guard = (run: () => void) => {
if (adjUsable || noRemind) { run(); return }
setPending({ run })
}
const dialog = pending ? (
<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={() => setPending(null)}
/>
<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-warning/12 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-warning" />
</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">
K将以<span className="text-foreground font-medium"></span>,
K 线,
</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>
<label className="mt-3 flex items-center gap-1.5 text-[11px] text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={noRemind}
onChange={e => setNoRemind(e.target.checked)}
className="h-3 w-3 accent-accent cursor-pointer"
/>
</label>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={() => setPending(null)}
className="px-4 h-9 rounded-btn text-sm text-secondary hover:text-foreground hover:bg-elevated transition-colors"
>
</button>
<button
onClick={() => {
try { localStorage.setItem(NO_REMIND_KEY, noRemind ? '1' : '0') } catch { /* 私隐模式等场景静默 */ }
pending.run()
setPending(null)
}}
className="px-4 h-9 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors"
>
</button>
</div>
</motion.div>
</div>
) : null
return { guard, dialog }
}
+9 -2
View File
@@ -12,6 +12,7 @@ import { useDataStatus, useCapabilities, useSettings, usePreferences } from '@/l
import { SealedBadge } from '@/components/SealedBadge' import { SealedBadge } from '@/components/SealedBadge'
import { StockPreviewDialog } from '@/components/StockPreviewDialog' import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { SettingsModal } from '@/components/data/SettingsModal' import { SettingsModal } from '@/components/data/SettingsModal'
import { useAdjFactorSyncGate } from '@/components/AdjFactorSyncGate'
import { STAGE_LABELS } from '@/components/data/ActiveJobCard' import { STAGE_LABELS } from '@/components/data/ActiveJobCard'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { cnSignal } from '@/lib/signals' import { cnSignal } from '@/lib/signals'
@@ -628,6 +629,8 @@ export function Dashboard() {
const fetchSucceeded = fetchStatus.data?.status === 'succeeded' const fetchSucceeded = fetchStatus.data?.status === 'succeeded'
// 首次使用且无数据 → 自动弹一次引导弹窗(同会话只弹一次) // 首次使用且无数据 → 自动弹一次引导弹窗(同会话只弹一次)
// 「开始获取」前若无除权因子能力, 先弹前置确认 (adjGate.guard)
const adjGate = useAdjFactorSyncGate()
useEffect(() => { useEffect(() => {
if (!hasNoData) return if (!hasNoData) return
if (settings.data?.onboarding_completed === false) return // 还在引导流程中,不重复弹 if (settings.data?.onboarding_completed === false) return // 还在引导流程中,不重复弹
@@ -722,12 +725,16 @@ export function Dashboard() {
providerLabel={providerLabel} providerLabel={providerLabel}
onClose={() => setShowWelcomeModal(false)} onClose={() => setShowWelcomeModal(false)}
onStart={() => { onStart={() => {
startFetch.mutate() adjGate.guard(() => {
setShowWelcomeModal(false) startFetch.mutate()
setShowWelcomeModal(false)
})
}} }}
/> />
)} )}
</AnimatePresence> </AnimatePresence>
{/* 无除权因子能力时的同步前置确认 */}
{adjGate.dialog}
<div className="relative mb-1.5 flex flex-wrap items-center justify-between gap-2 overflow-hidden rounded-card border border-border bg-gradient-to-r from-surface/90 to-surface/70 px-3 py-1.5 shadow-[0_1px_3px_hsl(var(--border)/0.4)] backdrop-blur-sm"> <div className="relative mb-1.5 flex flex-wrap items-center justify-between gap-2 overflow-hidden rounded-card border border-border bg-gradient-to-r from-surface/90 to-surface/70 px-3 py-1.5 shadow-[0_1px_3px_hsl(var(--border)/0.4)] backdrop-blur-sm">
<div className="pointer-events-none absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-accent to-accent/20" aria-hidden /> <div className="pointer-events-none absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-accent to-accent/20" aria-hidden />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+6 -1
View File
@@ -33,6 +33,7 @@ import { useToggleRealtimeQuotes, useUpdateQuoteInterval } from '@/lib/useShared
import { MissingCapChip, routeCapUsable, routeProviderDisplay, type RouteCapId } from '@/lib/capability-labels' import { MissingCapChip, routeCapUsable, routeProviderDisplay, type RouteCapId } from '@/lib/capability-labels'
import { QK } from '@/lib/queryKeys' import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { useAdjFactorSyncGate } from '@/components/AdjFactorSyncGate'
import { formatScheduleDatePart, formatScheduleTimePart, isToday } from '@/lib/format' import { formatScheduleDatePart, formatScheduleTimePart, isToday } from '@/lib/format'
// 拆分出的子组件 // 拆分出的子组件
@@ -105,6 +106,9 @@ export function Data() {
}, },
}) })
// 无除权因子能力时同步前置确认 (静默降级告知)
const adjGate = useAdjFactorSyncGate()
const [showClearConfirm, setShowClearConfirm] = useState(false) const [showClearConfirm, setShowClearConfirm] = useState(false)
const clearData = useMutation({ const clearData = useMutation({
mutationFn: api.dataClear, mutationFn: api.dataClear,
@@ -581,7 +585,7 @@ export function Data() {
<span className="text-xs text-accent animate-pulse">使</span> <span className="text-xs text-accent animate-pulse">使</span>
)} )}
<button <button
onClick={() => startSync.mutate()} onClick={() => adjGate.guard(() => startSync.mutate())}
disabled={isStarting} 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" 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"
> >
@@ -1153,6 +1157,7 @@ export function Data() {
{/* 清除数据二次确认弹窗 */} {/* 清除数据二次确认弹窗 */}
<AnimatePresence> <AnimatePresence>
{adjGate.dialog}
{showClearConfirm && ( {showClearConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<motion.div <motion.div