diff --git a/VERSION b/VERSION index 1ed86cb..22911ab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.1.86 +v0.1.87 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index dd4a72e..d2d9864 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -2,7 +2,7 @@ import sys -__version__ = "0.1.86" +__version__ = "0.1.87" # Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的 # 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。 diff --git a/frontend/package.json b/frontend/package.json index 4226999..03b117e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tickflow-stock-panel-frontend", "private": true, - "version": "0.1.86", + "version": "0.1.87", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 6614bba..b7ce57e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -478,12 +478,6 @@ export function Layout() { <> {label} - {/* 个股分析 Beta 标识 */} - {to === '/stock-analysis' && ( - - Beta - - )} {/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */} {to === '/data' && isDataSyncing && ( diff --git a/frontend/src/components/stock-analysis/PriceAlertDialog.tsx b/frontend/src/components/stock-analysis/PriceAlertDialog.tsx new file mode 100644 index 0000000..a315b3c --- /dev/null +++ b/frontend/src/components/stock-analysis/PriceAlertDialog.tsx @@ -0,0 +1,387 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ArrowDown, ArrowUp, Bell, Check, ExternalLink, Loader2, Trash2, X } from 'lucide-react' +import { toast } from '@/components/Toast' +import { LEVEL_GROUPS } from './AnalysisKChart' +import { api, genRuleId, type MonitorRule, type PriceLevel } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { usePreferences } from '@/lib/useSharedQueries' + +interface Props { + symbol: string + name: string + onClose: () => void +} + +type AlertDirection = 'up' | 'down' + +const COOLDOWNS = [ + { value: 600, label: '10 分钟' }, + { value: 1800, label: '30 分钟' }, + { value: 3600, label: '1 小时' }, + { value: 86400, label: '当日一次' }, +] + +function pointCondition(rule: MonitorRule) { + if (rule.type !== 'price' || rule.conditions.length !== 1) return null + const condition = rule.conditions[0] + if (condition.field !== 'close' || !['>=', '<='].includes(condition.op)) return null + if (typeof condition.value !== 'number') return null + return condition +} + +function levelGroupLabel(level: PriceLevel) { + return LEVEL_GROUPS.find(group => group.key === level.type)?.label ?? level.type +} + +export function PriceAlertDialog({ symbol, name, onClose }: Props) { + const qc = useQueryClient() + const { data: prefs } = usePreferences() + const levelsQuery = useQuery({ + queryKey: QK.stockLevels(symbol), + queryFn: () => api.stockAnalysisLevels(symbol, 250), + staleTime: 60_000, + }) + const rulesQuery = useQuery({ queryKey: QK.monitorRules, queryFn: api.monitorRulesList }) + const [tab, setTab] = useState<'create' | 'existing'>('create') + const [direction, setDirection] = useState('up') + const [target, setTarget] = useState('') + const [selectedLabel, setSelectedLabel] = useState('') + const [cooldown, setCooldown] = useState(3600) + const [message, setMessage] = useState('') + const [channels, setChannels] = useState([]) + const [confirmDelete, setConfirmDelete] = useState(null) + const channelsInitialized = useRef(false) + + const currentPrice = levelsQuery.data?.close ?? null + const recommended = useMemo(() => { + if (currentPrice == null) return { above: [] as PriceLevel[], below: [] as PriceLevel[] } + const seen = new Set() + const all = Object.values(levelsQuery.data?.levels ?? {}) + .flat() + .filter(level => Number.isFinite(level.value) && level.value > 0) + .sort((a, b) => Math.abs(a.value - currentPrice) - Math.abs(b.value - currentPrice)) + .filter(level => { + const key = level.value.toFixed(2) + if (seen.has(key)) return false + seen.add(key) + return true + }) + return { + above: all.filter(level => level.value > currentPrice).slice(0, 6), + below: all.filter(level => level.value < currentPrice).slice(0, 6), + } + }, [currentPrice, levelsQuery.data?.levels]) + + useEffect(() => { + if (target || currentPrice == null) return + const initial = recommended.above[0] ?? recommended.below[0] + if (!initial) return + setTarget(initial.value.toFixed(2)) + setDirection(initial.value > currentPrice ? 'up' : 'down') + setSelectedLabel(initial.label) + }, [currentPrice, recommended, target]) + + useEffect(() => { + if (channelsInitialized.current || !prefs) return + channelsInitialized.current = true + const configured = new Set() + if (prefs.feishu_webhook_url) configured.add('feishu') + if (prefs.wecom_webhook_url) configured.add('wecom') + setChannels((prefs.webhook_default_channels ?? []).filter(channel => configured.has(channel))) + }, [prefs]) + + useEffect(() => { + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + window.addEventListener('keydown', closeOnEscape) + return () => window.removeEventListener('keydown', closeOnEscape) + }, [onClose]) + + const pointRules = useMemo( + () => (rulesQuery.data?.rules ?? []).filter(rule => + rule.scope === 'symbols' + && rule.symbols.length === 1 + && rule.symbols[0] === symbol + && pointCondition(rule), + ), + [rulesQuery.data?.rules, symbol], + ) + + const targetValue = Number(target) + const targetValid = Number.isFinite(targetValue) && targetValue > 0 + const alreadyReached = targetValid && currentPrice != null && ( + direction === 'up' ? currentPrice >= targetValue : currentPrice <= targetValue + ) + const duplicate = targetValid && pointRules.some(rule => { + const condition = pointCondition(rule)! + return condition.op === (direction === 'up' ? '>=' : '<=') + && Math.abs((condition.value ?? 0) - targetValue) < 0.005 + }) + + const save = useMutation({ + mutationFn: () => api.monitorRuleSave({ + id: genRuleId(), + name: `点位提醒 · ${name || symbol} · ${direction === 'up' ? '涨至' : '跌至'}${selectedLabel || targetValue.toFixed(2)}`, + enabled: true, + type: 'price', + asset_type: 'stock', + scope: 'symbols', + symbols: [symbol], + sector: null, + strategy_id: null, + direction: 'entry', + conditions: [{ field: 'close', op: direction === 'up' ? '>=' : '<=', value: targetValue }], + logic: 'and', + cooldown_seconds: cooldown, + severity: 'warn', + message: message.trim(), + webhook_channels: channels, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: QK.monitorRules }) + toast('点位提醒已创建', 'success') + onClose() + }, + onError: error => toast(String((error as Error)?.message || '创建失败'), 'error'), + }) + + const toggle = useMutation({ + mutationFn: (rule: MonitorRule) => { + const { runtime_warning: _runtimeWarning, ...persisted } = rule + return api.monitorRuleSave({ ...persisted, enabled: !rule.enabled }) + }, + onSuccess: () => qc.invalidateQueries({ queryKey: QK.monitorRules }), + onError: error => toast(String((error as Error)?.message || '更新失败'), 'error'), + }) + + const remove = useMutation({ + mutationFn: api.monitorRuleDelete, + onSuccess: () => { + setConfirmDelete(null) + qc.invalidateQueries({ queryKey: QK.monitorRules }) + toast('点位提醒已删除', 'success') + }, + onError: error => toast(String((error as Error)?.message || '删除失败'), 'error'), + }) + + const selectLevel = (level: PriceLevel) => { + setTarget(level.value.toFixed(2)) + setDirection(currentPrice != null && level.value < currentPrice ? 'down' : 'up') + setSelectedLabel(level.label) + } + + const updateTarget = (value: string) => { + setTarget(value) + setSelectedLabel('') + const parsed = Number(value) + if (currentPrice != null && Number.isFinite(parsed)) { + setDirection(parsed < currentPrice ? 'down' : 'up') + } + } + + const toggleChannel = (channel: string) => { + setChannels(current => current.includes(channel) + ? current.filter(item => item !== channel) + : [...current, channel]) + } + + return ( +
+
event.stopPropagation()}> +
+ + + +
+
+

点位提醒

+ {name || symbol} + {symbol} +
+
+ 当前价 {currentPrice?.toFixed(2) ?? '—'} +
+
+ +
+ +
+ {([ + { key: 'create', label: '新建提醒' }, + { key: 'existing', label: `已有提醒 ${pointRules.length}` }, + ] as const).map(item => ( + + ))} +
+ + {tab === 'create' ? ( +
+
+
+ 触发方向 +
+ + +
+
+ +
+ +
+
+ 关键价位 + {levelsQuery.isLoading && } +
+
+ {([ + { key: 'above', label: '上方', icon: ArrowUp, levels: recommended.above, color: 'text-bull' }, + { key: 'below', label: '下方', icon: ArrowDown, levels: recommended.below, color: 'text-bear' }, + ] as const).map(group => ( +
+
+ {group.label} +
+
+ {group.levels.length === 0 ? ( +
暂无价位
+ ) : group.levels.map(level => { + const selected = Math.abs(Number(target) - level.value) < 0.005 + return ( + + ) + })} +
+
+ ))} +
+
+ +
+ + +
+ +
+ 通知渠道 +
+ + {([ + { key: 'feishu', label: '飞书', configured: !!prefs?.feishu_webhook_url }, + { key: 'wecom', label: '企业微信', configured: !!prefs?.wecom_webhook_url }, + ]).map(channel => ( + + ))} +
+
+ + {(alreadyReached || duplicate) && ( +
+ {duplicate ? '相同方向和价格的提醒已存在。' : '当前价格已处于触发区间,请调整目标价格或触发方向。'} +
+ )} +
+ ) : ( +
+ {rulesQuery.isLoading ? ( +
+ ) : pointRules.length === 0 ? ( +
+ + 暂无点位提醒 + +
+ ) : ( +
+ {pointRules.map(rule => { + const condition = pointCondition(rule)! + const isUp = condition.op === '>=' + return ( +
+ + {isUp ? : } + + + {rule.name} + {isUp ? '涨至' : '跌至'} {condition.value!.toFixed(2)} · {COOLDOWNS.find(item => item.value === rule.cooldown_seconds)?.label ?? `${rule.cooldown_seconds} 秒`} + + + {confirmDelete === rule.id ? ( + + ) : ( + + )} +
+ ) + })} +
+ )} +
+ )} + +
+ + 监控中心 + +
+ + {tab === 'create' && ( + + )} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/StockAnalysis.tsx b/frontend/src/pages/StockAnalysis.tsx index 0228aa4..36eb7f3 100644 --- a/frontend/src/pages/StockAnalysis.tsx +++ b/frontend/src/pages/StockAnalysis.tsx @@ -7,6 +7,7 @@ import { StockFinancialSearch } from '@/components/financials/StockFinancialSear import { StockPreviewDialog } from '@/components/StockPreviewDialog' import { LastStockChip } from '@/components/LastStockChip' import { AnalysisKChart, type PriceLevel, type LevelType } from '@/components/stock-analysis/AnalysisKChart' +import { PriceAlertDialog } from '@/components/stock-analysis/PriceAlertDialog' import { api } from '@/lib/api' import { useLastStock } from '@/lib/useLastStock' import { QK } from '@/lib/queryKeys' @@ -30,6 +31,7 @@ export function StockAnalysis() { const [checking, setChecking] = useState(false) const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null) const [previewSymbol, setPreviewSymbol] = useState(null) + const [showPriceAlerts, setShowPriceAlerts] = useState(false) const { last: lastStock, remember: rememberStock } = useLastStock('stock-analysis') // 进入页面立即加载历史报告(供右侧常驻列表)。store 内部有 historyLoaded 去重, 重复调用安全。 @@ -48,6 +50,7 @@ export function StockAnalysis() { setSymbol(sym) setName(nm) setConfirmReport(null) + setShowPriceAlerts(false) rememberStock(sym, nm) } @@ -78,11 +81,6 @@ export function StockAnalysis() { <> - Beta - - } subtitle="日 K · 关键价位 · AI 四维分析(技术 / 基本面 / 财务 / 消息面)" right={
@@ -117,15 +115,12 @@ export function StockAnalysis() { AI 个股分析 )} @@ -165,6 +160,15 @@ export function StockAnalysis() { triggerInfo={null} onClose={() => setPreviewSymbol(null)} /> + + {showPriceAlerts && symbol && ( + setShowPriceAlerts(false)} + /> + )} ) }