mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat(data): 无除权因子能力时同步前置二次确认
除权因子不可用时同步静默降级 (日K不复权入库、enriched 直接用 raw 价), 此前无任何提示。现于两个同步入口 (数据页同步按钮、首用欢迎弹窗开始获取) 检测能力矩阵 usable 标志, 不可用先弹确认: 告知不复权口径与除权事件后 需重新校准, 支持不再提示 (localStorage)。矩阵未加载完成时 fail-open 不拦截。
This commit is contained in:
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user