import { useState, useRef, useEffect, useCallback } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { Loader2, Check, AlertCircle } from 'lucide-react' import { useBubbleTasks, restoreDialog } from '@/lib/stockAnalysisStore' import type { ActiveTask } from '@/lib/stockAnalysisStore' /** * AI 个股分析任务全局气泡 —— 与财务分析胶囊并列,蓝色主题区分。 * 挂在右侧,拖拽丝滑(逻辑同 AiReportBubble,独立状态池)。 * * 与财务胶囊的差异:蓝色系 + "个股分析中"文案,避免与财务分析混淆。 */ const BUBBLE_W = 148 const EDGE_MARGIN = 12 export function StockAnalysisBubble() { const activeTasks = useBubbleTasks() const containerRef = useRef(null) const [pos, setPos] = useState<{ x: number; y: number }>(() => loadPos()) const draggingRef = useRef(false) const dragData = useRef({ mx: 0, my: 0, ox: 0, oy: 0 }) const movedRef = useRef(false) const clickTargetRef = useRef<(() => void) | null>(null) const applyTransform = useCallback((x: number, y: number) => { const el = containerRef.current if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0)` }, []) const clamp = useCallback((x: number, y: number) => { const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN const maxY = window.innerHeight - 80 return { x: Math.max(EDGE_MARGIN, Math.min(maxX, x)), y: Math.max(EDGE_MARGIN, Math.min(maxY, y)), } }, []) const onPointerDown = useCallback((e: React.PointerEvent) => { draggingRef.current = true movedRef.current = false dragData.current = { mx: e.clientX, my: e.clientY, ox: pos.x, oy: pos.y } const el = containerRef.current if (el) el.classList.add('dragging') ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) }, [pos.x, pos.y]) const onPointerMove = useCallback((e: React.PointerEvent) => { if (!draggingRef.current) return const dx = e.clientX - dragData.current.mx const dy = e.clientY - dragData.current.my if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true const c = clamp(dragData.current.ox + dx, dragData.current.oy + dy) applyTransform(c.x, c.y) }, [clamp, applyTransform]) const onPointerUp = useCallback(() => { if (!draggingRef.current) return draggingRef.current = false const el = containerRef.current if (el) el.classList.remove('dragging') if (movedRef.current) { setPos(prev => { const transform = el?.style.transform ?? '' const m = transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px/) const finalPos = m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : prev savePos(finalPos) return finalPos }) } else { const fn = clickTargetRef.current clickTargetRef.current = null fn?.() } }, []) useEffect(() => { const onResize = () => { setPos(prev => { const c = clamp(prev.x, prev.y) if (c.x !== prev.x || c.y !== prev.y) { applyTransform(c.x, c.y) return c } return prev }) } window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, [clamp, applyTransform]) useEffect(() => { applyTransform(pos.x, pos.y) }, [pos.x, pos.y, applyTransform]) if (activeTasks.length === 0) return null // 默认位置偏下,避免与财务胶囊(右下)重叠 return (
{activeTasks.map((task, i) => ( { clickTargetRef.current = () => restoreDialog(task.id) }} /> ))}
) } function BubbleItem({ task, isLast, onPointerDown }: { task: ActiveTask isLast: boolean onPointerDown: () => void }) { const isWorking = task.phase === 'loading' || task.phase === 'streaming' const isError = task.phase === 'error' // 蓝色系(区别于财务分析的紫色) const accent = isWorking ? 'from-sky-500/25 to-blue-500/20 text-sky-300 border-sky-300/40 shadow-[0_6px_24px_-10px_rgba(14,165,233,0.5)]' : isError ? 'from-red-500/20 to-red-500/10 text-red-300 border-red-300/40 shadow-[0_6px_20px_-10px_rgba(239,68,68,0.4)]' : 'from-emerald-500/20 to-emerald-500/10 text-emerald-300 border-emerald-300/40 shadow-[0_6px_20px_-10px_rgba(16,185,129,0.35)]' return (
{isWorking && (
)} {isWorking ? : isError ? : } {task.name || task.symbol} {isWorking ? 个股分析 : isError ? 失败 : 点击查看}
) } const POS_KEY = 'sa_bubble_pos' function loadPos(): { x: number; y: number } { // 默认右下,偏上一点(避开财务胶囊的右下位置) const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN) const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 320) try { const v = localStorage.getItem(POS_KEY) if (v) { const p = JSON.parse(v) if (typeof p.x === 'number' && typeof p.y === 'number') { return { x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)), y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)), } } } } catch { /* ignore */ } return { x: defaultX, y: defaultY } } function savePos(p: { x: number; y: number }) { try { localStorage.setItem(POS_KEY, JSON.stringify(p)) } catch { /* ignore */ } }