mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
点位监控(双击图表): - 日K/单日分时/多日分时价格主图支持双击, 自动预填目标价并按最新价判断上穿/下穿 - 成交量区与指标子图不触发; 自动生成通知文案, 用户手改后不再覆盖 - 已启用点位监控以横虚线显示 (上穿红/下穿绿), 并纳入三图 Y 轴范围 - 复用现有 PriceAlertDialog, 未改变后端 close>=/<= 规则语义与冷却机制 分钟K数据: - 个股补齐强制回溯请求天数 (force_full_days), 修复切换日期范围只显示3天 - 单股并发补齐复用仓库写锁, 修复 Windows Parquet 临时文件替换失败 - DuckDB 读路径改用短生命周期 cursor, 释放对 Parquet 文件的占用 设置页: - 数据任务超时从 DataSources 抽出为独立"超时设置"页签 - 设置侧栏垂直居中 验证: pytest 38 passed, pnpm build ok, git diff --check ok, 浏览器端到端 GUI 验证通过
469 lines
19 KiB
TypeScript
469 lines
19 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2 } from 'lucide-react'
|
|
import { api } from '@/lib/api'
|
|
import { QK } from '@/lib/queryKeys'
|
|
import { cn } from '@/lib/cn'
|
|
import { cnSignal } from '@/lib/signals'
|
|
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
|
|
import { WatchlistAddMenu } from '@/components/WatchlistAddMenu'
|
|
import { StockMultiDayIntradayChart } from '@/components/StockMultiDayIntradayChart'
|
|
import { DatePicker } from '@/components/DatePicker'
|
|
import { RuleEditor } from '@/components/monitor/RuleEditor'
|
|
import { PriceAlertDialog } from '@/components/stock-analysis/PriceAlertDialog'
|
|
import { buildMonitorPriceLines } from '@/lib/price-alerts'
|
|
import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries'
|
|
import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream'
|
|
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
|
import { storage } from '@/lib/storage'
|
|
|
|
interface Props {
|
|
symbol: string | null
|
|
name?: string
|
|
onClose: () => void
|
|
/** 触发信息 (来自监控触发记录, 有值时在顶栏下方显示) */
|
|
triggerInfo?: {
|
|
price?: number | null
|
|
changePct?: number | null
|
|
ts?: number
|
|
signals?: string[]
|
|
message?: string
|
|
} | null
|
|
}
|
|
|
|
// ===== 板块标识(与 Screener 列表一致)=====
|
|
|
|
// 预设快捷范围(只保留半年和1年)
|
|
const PRESETS: { label: string; months: number }[] = [
|
|
{ label: '半年', months: 6 },
|
|
{ label: '1年', months: 12 },
|
|
]
|
|
|
|
type PreviewView = 'daily' | 'intraday'
|
|
interface PriceAlertDraft {
|
|
id: number
|
|
targetPrice: number
|
|
currentPrice: number
|
|
}
|
|
const INTRADAY_DAY_OPTIONS = [1, 5, 10, 20] as const
|
|
|
|
function loadIntradayDays(): number {
|
|
const saved = storage.stockPreviewIntradayDays.get(10)
|
|
return INTRADAY_DAY_OPTIONS.includes(saved as typeof INTRADAY_DAY_OPTIONS[number])
|
|
? saved
|
|
: 10
|
|
}
|
|
|
|
function boardTag(symbol: string): { label: string; color: string } | null {
|
|
if (/^(300|301)/.test(symbol)) return { label: '创', color: 'text-[#f97316] bg-[#f97316]/12 border-[#f97316]/25' }
|
|
if (/^688/.test(symbol)) return { label: '科', color: 'text-purple-400 bg-purple-400/12 border-purple-400/25' }
|
|
if (/^[48]/.test(symbol)) return { label: '北', color: 'text-cyan-400 bg-cyan-400/12 border-cyan-400/25' }
|
|
return null
|
|
}
|
|
|
|
export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props) {
|
|
const [view, setView] = useState<PreviewView>('daily')
|
|
const [intradayDays, setIntradayDays] = useState(loadIntradayDays)
|
|
const [dateRange, setDateRange] = useState(getDefaultRange)
|
|
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
|
|
const [priceAlertDraft, setPriceAlertDraft] = useState<PriceAlertDraft | null>(null)
|
|
const [maximized, setMaximized] = useState(false)
|
|
const qc = useQueryClient()
|
|
const backdrop = useDialogBackdrop(onClose)
|
|
|
|
const watchlist = useQuery({
|
|
queryKey: QK.watchlist,
|
|
queryFn: api.watchlistList,
|
|
enabled: !!symbol,
|
|
})
|
|
const monitorRules = useQuery({
|
|
queryKey: QK.monitorRules,
|
|
queryFn: api.monitorRulesList,
|
|
enabled: !!symbol,
|
|
})
|
|
const monitorPriceLines = useMemo(
|
|
() => symbol ? buildMonitorPriceLines(monitorRules.data?.rules ?? [], symbol) : [],
|
|
[monitorRules.data?.rules, symbol],
|
|
)
|
|
const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol)
|
|
|
|
const toggleWatchlist = useMutation({
|
|
mutationFn: ({
|
|
action,
|
|
groupId,
|
|
}: {
|
|
action: 'add' | 'remove'
|
|
groupId?: string | null
|
|
}) => action === 'remove'
|
|
? api.watchlistRemove(symbol!)
|
|
: api.watchlistAdd(symbol!, '', groupId),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: QK.watchlist })
|
|
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
|
|
},
|
|
})
|
|
|
|
// ESC 关闭
|
|
useEffect(() => {
|
|
if (!symbol) return
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && !priceAlertDraft) onClose()
|
|
}
|
|
document.addEventListener('keydown', handler)
|
|
return () => document.removeEventListener('keydown', handler)
|
|
}, [symbol, onClose, priceAlertDraft])
|
|
|
|
useEffect(() => {
|
|
if (symbol) setView('daily')
|
|
setPriceAlertDraft(null)
|
|
}, [symbol])
|
|
|
|
// 焦点股票注册: SSE quotes_updated 推送时精准 invalidate 当前股票日K,
|
|
// 让对话框日K最后一根蜡烛随实时价变化 (后端只读内存, 不调 TickFlow)。
|
|
// 关闭/切股时清除, 避免无谓刷新。
|
|
useEffect(() => {
|
|
if (!symbol) return
|
|
setFocusSymbol(symbol)
|
|
return () => clearFocusSymbol()
|
|
}, [symbol])
|
|
|
|
// 分时图实时轮询: 复用自选列表的「分时刷新开关 + 间隔」偏好。
|
|
// 仅实时行情运行 且 用户开启分时刷新时才轮询; 否则 undefined (定格)。
|
|
const { data: prefs } = usePreferences()
|
|
const { data: quoteStatus } = useQuoteStatus()
|
|
const realtimeRunning = quoteStatus?.running ?? false
|
|
const intradayRefreshOn = prefs?.minute_intraday_refresh ?? false
|
|
const intradayRefetchMs = (intradayRefreshOn && realtimeRunning)
|
|
? (prefs?.minute_intraday_refresh_interval ?? 6) * 1000
|
|
: undefined
|
|
|
|
const handleRefresh = () => {
|
|
if (!symbol) return
|
|
if (view === 'daily') {
|
|
qc.invalidateQueries({ queryKey: ['kline', symbol] })
|
|
} else {
|
|
qc.invalidateQueries({ queryKey: ['kline-minute-range', symbol] })
|
|
qc.invalidateQueries({ queryKey: ['kline-minute', symbol!] })
|
|
}
|
|
}
|
|
|
|
const selectIntradayDays = (days: number) => {
|
|
setIntradayDays(days)
|
|
storage.stockPreviewIntradayDays.set(days)
|
|
}
|
|
|
|
const openPriceAlert = (targetPrice: number, currentPrice: number) => {
|
|
setPriceAlertDraft({ id: Date.now(), targetPrice, currentPrice })
|
|
}
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{symbol && (
|
|
<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"
|
|
{...backdrop}
|
|
/>
|
|
|
|
{/* 弹窗主体 */}
|
|
<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={cn(
|
|
'relative rounded-card border border-border bg-base shadow-2xl overflow-hidden flex flex-col transition-all duration-200 ease-smooth',
|
|
maximized ? 'w-screen h-screen max-w-none max-h-none' : 'w-[92vw] max-w-[1100px] max-h-[95vh]',
|
|
)}
|
|
>
|
|
{/* 顶栏 */}
|
|
<div className="flex items-center justify-between gap-3 px-4 py-3 sm:px-5 shrink-0">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
{(() => {
|
|
const board = symbol ? boardTag(symbol) : null
|
|
return board ? (
|
|
<span className={`inline-flex items-center justify-center w-[18px] h-[18px] rounded text-[9px] font-bold leading-none border ${board.color}`}>
|
|
{board.label}
|
|
</span>
|
|
) : null
|
|
})()}
|
|
<span className="shrink-0 font-mono text-sm font-medium text-foreground">{symbol}</span>
|
|
{name && <span className="truncate text-xs text-muted">{name}</span>}
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{/* 区间选择 — 随视图切换 */}
|
|
{view === 'daily' ? (
|
|
<div className="flex items-center gap-1">
|
|
{PRESETS.map(p => {
|
|
const now = new Date()
|
|
const s = new Date(now)
|
|
s.setMonth(s.getMonth() - p.months)
|
|
const expected = s.toISOString().slice(0, 10)
|
|
const isActive = dateRange.start === expected
|
|
return (
|
|
<button
|
|
key={p.label}
|
|
onClick={() => {
|
|
const end = new Date().toISOString().slice(0, 10)
|
|
const ns = new Date()
|
|
ns.setMonth(ns.getMonth() - p.months)
|
|
setDateRange({ start: ns.toISOString().slice(0, 10), end })
|
|
}}
|
|
className={`h-6 px-1.5 rounded text-[11px] transition-colors cursor-pointer
|
|
${isActive
|
|
? 'bg-accent/20 text-accent font-medium border border-accent/30'
|
|
: 'text-muted hover:text-foreground hover:bg-elevated border border-transparent'
|
|
}`}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
)
|
|
})}
|
|
<DatePicker
|
|
value={dateRange.start}
|
|
onChange={(v) => setDateRange(prev => ({ ...prev, start: v }))}
|
|
max={dateRange.end}
|
|
/>
|
|
<span className="text-muted/40 text-[10px]">~</span>
|
|
<DatePicker
|
|
value={dateRange.end}
|
|
onChange={(v) => setDateRange(prev => ({ ...prev, end: v }))}
|
|
min={dateRange.start}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1">
|
|
<div className="inline-flex shrink-0 items-center rounded border border-border bg-elevated p-0.5" aria-label="分时周期">
|
|
{INTRADAY_DAY_OPTIONS.map(days => (
|
|
<button
|
|
key={days}
|
|
type="button"
|
|
aria-pressed={intradayDays === days}
|
|
onClick={() => selectIntradayDays(days)}
|
|
className={`h-5 rounded px-1.5 font-mono text-[10px] transition-colors ${
|
|
intradayDays === days
|
|
? 'bg-accent/20 text-accent'
|
|
: 'text-muted hover:text-secondary'
|
|
}`}
|
|
>
|
|
{days}日
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<span className="mx-0.5 h-4 w-px shrink-0 bg-border" />
|
|
|
|
{/* 日K / 分时 切换 */}
|
|
<div role="tablist" aria-label="图表视图" className="inline-flex shrink-0 items-center rounded border border-border bg-elevated p-0.5">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={view === 'daily'}
|
|
onClick={() => setView('daily')}
|
|
className={`inline-flex h-6 items-center gap-1 rounded px-2 text-[11px] transition-colors ${
|
|
view === 'daily' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'
|
|
}`}
|
|
>
|
|
<LineChart className="h-3 w-3" />
|
|
日 K
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={view === 'intraday'}
|
|
onClick={() => setView('intraday')}
|
|
className={`inline-flex h-6 items-center gap-1 rounded px-2 text-[11px] transition-colors ${
|
|
view === 'intraday' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'
|
|
}`}
|
|
>
|
|
<Clock className="h-3 w-3" />
|
|
分时
|
|
</button>
|
|
</div>
|
|
|
|
<span className="mx-0.5 h-4 w-px shrink-0 bg-border" />
|
|
|
|
{/* 自选 */}
|
|
{inWatchlist ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleWatchlist.mutate({ action: 'remove' })}
|
|
disabled={toggleWatchlist.isPending}
|
|
className="rounded-btn p-1.5 text-[#FACC15] transition-colors cursor-pointer hover:bg-elevated disabled:opacity-50"
|
|
title="移出自选"
|
|
aria-label={`将 ${symbol} 移出自选`}
|
|
>
|
|
<Star className="h-4 w-4" />
|
|
</button>
|
|
) : (
|
|
<WatchlistAddMenu
|
|
onSelect={groupId => toggleWatchlist.mutate({ action: 'add', groupId })}
|
|
disabled={toggleWatchlist.isPending}
|
|
triggerClassName="rounded-btn p-1.5 text-muted transition-colors cursor-pointer hover:bg-elevated hover:text-foreground disabled:opacity-50"
|
|
ariaLabel={`将 ${symbol} 加入自选`}
|
|
>
|
|
<Star className="h-4 w-4" />
|
|
</WatchlistAddMenu>
|
|
)}
|
|
{/* 加监控 */}
|
|
<button
|
|
onClick={() => setShowMonitorEditor(true)}
|
|
className="p-1.5 rounded-btn text-amber-400 hover:bg-amber-400/10 transition-colors cursor-pointer"
|
|
title="加监控"
|
|
>
|
|
<RadioTower className="h-4 w-4" />
|
|
</button>
|
|
|
|
{/* 刷新 */}
|
|
<button
|
|
onClick={handleRefresh}
|
|
className="p-1.5 rounded-btn text-secondary hover:text-foreground hover:bg-elevated transition-colors"
|
|
title="刷新"
|
|
>
|
|
<RefreshCw className="h-4 w-4" />
|
|
</button>
|
|
|
|
{/* 放大 / 缩小 */}
|
|
<button
|
|
onClick={() => setMaximized(v => !v)}
|
|
className="p-1.5 rounded-btn text-secondary hover:text-foreground hover:bg-elevated transition-colors"
|
|
title={maximized ? '缩小' : '放大'}
|
|
>
|
|
{maximized ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
|
</button>
|
|
|
|
<button
|
|
onClick={onClose}
|
|
className="shrink-0 rounded-btn p-1.5 text-secondary transition-colors hover:bg-elevated hover:text-foreground"
|
|
aria-label="关闭个股详情"
|
|
title="关闭"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 触发信息条 (来自监控触发记录) */}
|
|
{triggerInfo && (
|
|
<div className="flex items-center gap-4 border-b border-amber-400/20 bg-amber-400/[0.06] px-5 py-2 shrink-0">
|
|
{/* 左: 触发标记 + 时间 */}
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className="text-[10px] font-semibold text-amber-400">⚡ 触发</span>
|
|
{triggerInfo.ts && (
|
|
<span className="text-[11px] text-secondary font-mono">
|
|
{new Date(triggerInfo.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 中: 价格 + 涨跌幅 */}
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{triggerInfo.price != null && (
|
|
<span className="text-[11px] font-mono text-foreground/80">{triggerInfo.price.toFixed(2)}</span>
|
|
)}
|
|
{triggerInfo.changePct != null && (
|
|
<span className={`text-[11px] font-mono font-medium ${triggerInfo.changePct >= 0 ? 'text-danger' : 'text-bear'}`}>
|
|
{triggerInfo.changePct >= 0 ? '+' : ''}{(triggerInfo.changePct * 100).toFixed(2)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 右: 消息 + 信号标签 */}
|
|
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
|
{triggerInfo.message && (
|
|
<span className="text-[11px] text-foreground/70 truncate">{triggerInfo.message}</span>
|
|
)}
|
|
{triggerInfo.signals && triggerInfo.signals.length > 0 && (
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
{triggerInfo.signals.map((s, j) => (
|
|
<span key={j} className="rounded bg-accent/10 px-1.5 py-0.5 text-[9px] text-accent/80">{cnSignal(s)}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 图表内容 */}
|
|
<div className="flex-1 overflow-auto p-4">
|
|
{view === 'daily' ? (
|
|
<StockPanel
|
|
symbol={symbol}
|
|
height={420}
|
|
showIntraday
|
|
dateRange={dateRange}
|
|
priceLines={monitorPriceLines}
|
|
onPriceDoubleClick={openPriceAlert}
|
|
/>
|
|
) : (
|
|
<>
|
|
<StockPanel
|
|
symbol={symbol}
|
|
dateRange={dateRange}
|
|
infoBarOnly
|
|
/>
|
|
<StockMultiDayIntradayChart
|
|
symbol={symbol}
|
|
days={intradayDays}
|
|
height={480}
|
|
refetchIntervalMs={intradayRefetchMs}
|
|
priceLines={monitorPriceLines}
|
|
onPriceDoubleClick={openPriceAlert}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* 加监控编辑器弹层 */}
|
|
<AnimatePresence>
|
|
{showMonitorEditor && symbol && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="absolute inset-0 z-20 flex items-start justify-center overflow-auto bg-black/40 p-4"
|
|
onClick={() => setShowMonitorEditor(false)}
|
|
>
|
|
<div className="mt-8 w-full max-w-2xl" onClick={e => e.stopPropagation()}>
|
|
<RuleEditor
|
|
rule={null}
|
|
simple
|
|
preset={{
|
|
scope: 'symbols',
|
|
symbols: [symbol],
|
|
type: 'signal',
|
|
logic: 'or',
|
|
}}
|
|
onClose={() => setShowMonitorEditor(false)}
|
|
onSaved={() => setShowMonitorEditor(false)}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
{symbol && priceAlertDraft && (
|
|
<PriceAlertDialog
|
|
key={`${symbol}-${priceAlertDraft.id}`}
|
|
symbol={symbol}
|
|
name={name ?? ''}
|
|
initialTarget={priceAlertDraft.targetPrice}
|
|
initialCurrentPrice={priceAlertDraft.currentPrice}
|
|
onClose={() => setPriceAlertDraft(null)}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
}
|