Files
stock/refer/frontend/src/components/stock-analysis/StockAnalysisBubble.tsx
T
2026-07-04 15:59:20 +08:00

208 lines
7.8 KiB
TypeScript

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<HTMLDivElement>(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 (
<div
ref={containerRef}
className="sa-bubble-root fixed z-[60] select-none cursor-grab active:cursor-grabbing"
style={{
width: `${BUBBLE_W}px`,
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
touchAction: 'none',
transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<AnimatePresence mode="popLayout">
{activeTasks.map((task, i) => (
<BubbleItem
key={task.id}
task={task}
isLast={i === activeTasks.length - 1}
onPointerDown={() => { clickTargetRef.current = () => restoreDialog(task.id) }}
/>
))}
</AnimatePresence>
<style>{`.sa-bubble-root.dragging { transition: none !important; }`}</style>
</div>
)
}
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 (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -8 }}
transition={{ type: 'spring', damping: 22, stiffness: 300 }}
className={isLast ? '' : 'mb-1.5'}
>
<div
onPointerDown={onPointerDown}
role="button"
tabIndex={0}
title={isWorking ? '个股分析中,点击恢复' : isError ? '分析失败,点击重试' : '点击查看个股分析报告'}
className={`group relative flex w-full cursor-pointer items-center gap-1.5 overflow-hidden rounded-lg border bg-gradient-to-br px-2 py-1.5 backdrop-blur-xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.99] ${accent}`}
>
{isWorking && (
<div className="absolute inset-x-0 top-0 h-px overflow-hidden">
<div className="h-full w-1/2 bg-gradient-to-r from-transparent via-sky-200 to-transparent animate-sa-bubble-progress" />
</div>
)}
<span className="flex h-4 w-4 items-center justify-center shrink-0">
{isWorking ? <Loader2 className="h-3 w-3 animate-spin" />
: isError ? <AlertCircle className="h-3 w-3" />
: <Check className="h-3 w-3" />}
</span>
<span className="flex-1 min-w-0 text-[11px] font-medium text-foreground leading-none truncate">
{task.name || task.symbol}
</span>
<span className="shrink-0 text-[9px] leading-none">
{isWorking ? <span className="text-sky-300/80">个股分析</span>
: isError ? <span className="text-red-300/80">失败</span>
: <span className="text-emerald-300/80">点击查看</span>}
</span>
</div>
<style>{`
@keyframes sa-bubble-progress { 0% { transform: translateX(-100%); } 100% { transform: translateX(300%); } }
.animate-sa-bubble-progress { animation: sa-bubble-progress 1.6s ease-in-out infinite; }
`}</style>
</motion.div>
)
}
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 */ }
}