| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248 |
- 'use client';
- import { useState, useEffect, useRef, useCallback } from 'react';
- import { createPortal } from 'react-dom';
- import { motion } from 'motion/react';
- import { Play, Pause, X } from 'lucide-react';
- import { useI18n } from '@/lib/hooks/use-i18n';
- import type { DiscussionAction } from '@/lib/types/action';
- interface ProactiveCardProps {
- action: DiscussionAction;
- mode: 'playback' | 'paused' | 'autonomous';
- /** Ref to the anchor element the card points to (avatar, etc.) */
- anchorRef: React.RefObject<HTMLElement | null>;
- /** Where the card prefers to align relative to the anchor */
- align?: 'left' | 'right';
- /** Portal target — defaults to document.body. Pass the fullscreen container
- * when in presentation mode so the card stays visible inside the top-layer. */
- portalContainer?: HTMLElement | null;
- agentName?: string;
- agentAvatar?: string;
- agentColor?: string;
- onSkip: () => void;
- onListen: () => void;
- onTogglePause: () => void;
- }
- const CARD_WIDTH = 256; // w-64
- const VIEWPORT_PAD = 12;
- /**
- * 主动讨论卡片组件
- *
- * 通过 React Portal 渲染到 document.body,使用 fixed 定位,
- * 不受父级 overflow/z-index stacking context 影响。
- */
- export const ProactiveCard = ({
- action,
- mode,
- anchorRef,
- align = 'right',
- portalContainer,
- agentName,
- agentAvatar,
- agentColor,
- onSkip,
- onListen,
- onTogglePause,
- }: ProactiveCardProps) => {
- const { t } = useI18n();
- const [progress, setProgress] = useState(100);
- const skippedRef = useRef(false);
- const isPaused = mode === 'paused';
- // Computed position state
- const [pos, setPos] = useState<{
- left: number;
- bottom: number;
- tailOffset: number;
- } | null>(null);
- const updatePosition = useCallback(() => {
- const el = anchorRef.current;
- if (!el) return;
- const rect = el.getBoundingClientRect();
- const anchorCenterX = rect.left + rect.width / 2;
- const anchorTop = rect.top;
- // Center card on anchor, clamped to viewport
- let cardLeft = anchorCenterX - CARD_WIDTH / 2;
- cardLeft = Math.max(
- VIEWPORT_PAD,
- Math.min(window.innerWidth - CARD_WIDTH - VIEWPORT_PAD, cardLeft),
- );
- const tailOffset = Math.max(16, Math.min(CARD_WIDTH - 16, anchorCenterX - cardLeft));
- const bottom = window.innerHeight - anchorTop + 12; // 12px gap above anchor
- setPos({ left: cardLeft, bottom, tailOffset });
- }, [anchorRef]);
- // Continuously track anchor position via rAF to handle CSS transitions, sidebar collapse, etc.
- useEffect(() => {
- let rafId: number;
- const tick = () => {
- updatePosition();
- rafId = requestAnimationFrame(tick);
- };
- rafId = requestAnimationFrame(tick);
- return () => cancelAnimationFrame(rafId);
- }, [updatePosition]);
- useEffect(() => {
- if (mode !== 'playback') return;
- const duration = 5000;
- const interval = 50;
- const step = (interval / duration) * 100;
- const timer = setInterval(() => {
- setProgress((prev) => {
- const newProgress = prev - step;
- if (newProgress <= 0) {
- clearInterval(timer);
- return 0;
- }
- return newProgress;
- });
- }, interval);
- return () => clearInterval(timer);
- }, [mode]);
- useEffect(() => {
- if (progress <= 0 && !skippedRef.current && mode === 'playback') {
- skippedRef.current = true;
- onSkip();
- }
- }, [progress, onSkip, mode]);
- if (!pos) return null;
- const card = (
- <motion.div
- initial={{ opacity: 0, y: 10, scale: 0.95 }}
- animate={{ opacity: 1, y: 0, scale: 1 }}
- exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
- className="fixed w-64 z-[9999] pointer-events-auto"
- style={{
- left: pos.left,
- bottom: pos.bottom,
- ...(align === 'left'
- ? { transformOrigin: 'bottom left' }
- : { transformOrigin: 'bottom right' }),
- }}
- >
- <div className="relative">
- {/* Close button */}
- <button
- onClick={(e) => {
- e.stopPropagation();
- onSkip();
- }}
- className="absolute -top-2 -right-2 w-6 h-6 bg-white dark:bg-gray-800 shadow-md border border-gray-100 dark:border-gray-700 rounded-full flex items-center justify-center text-gray-400 hover:text-red-500 hover:scale-110 transition-all z-20 group/close"
- title={t('proactiveCard.skip')}
- >
- <X className="w-3 h-3 stroke-[2.5]" />
- </button>
- {/* Triangle Tail */}
- <div
- className="absolute -bottom-[6px] w-3 h-3 bg-white dark:bg-gray-800 border-b border-r border-gray-100 dark:border-gray-700 z-10"
- style={{
- left: `${pos.tailOffset}px`,
- transform: 'translateX(-50%) rotate(45deg)',
- }}
- />
- {/* Card body */}
- <div className="bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-3.5 rounded-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] border border-gray-100 dark:border-gray-700 flex flex-col gap-2.5 relative overflow-hidden">
- {/* Progress Bar */}
- <div className="absolute top-0 left-0 right-0 h-1 bg-gray-50/50 dark:bg-gray-700/50">
- <div
- className={`h-full transition-all duration-[50ms] ease-linear ${
- isPaused
- ? 'bg-gray-300 dark:bg-gray-600'
- : 'bg-gradient-to-r from-amber-400 to-amber-500 dark:from-amber-500 dark:to-amber-600'
- }`}
- style={{ width: `${progress}%` }}
- />
- </div>
- {/* Header */}
- <div className="flex items-center gap-2 px-0.5 pt-1">
- {agentAvatar && (
- <div className="w-6 h-6 rounded-full overflow-hidden shrink-0 border border-gray-100 dark:border-gray-700">
- <img
- src={agentAvatar}
- alt={agentName || ''}
- className="w-full h-full object-cover"
- />
- </div>
- )}
- <div className="flex items-center gap-1.5 flex-1 min-w-0">
- {agentName && (
- <span className="text-[11px] font-semibold text-gray-700 dark:text-gray-300 truncate">
- {agentName}
- </span>
- )}
- <span
- className="text-[9px] font-black uppercase tracking-widest px-1.5 py-0.5 rounded-full shrink-0"
- style={{
- color: agentColor || '#d97706',
- backgroundColor: agentColor ? `${agentColor}18` : 'rgba(217, 119, 6, 0.08)',
- }}
- >
- {t('proactiveCard.discussion')}
- </span>
- </div>
- <span
- className={`text-[10px] font-bold tabular-nums shrink-0 ${
- isPaused ? 'text-gray-300 dark:text-gray-600' : 'text-gray-400 dark:text-gray-500'
- }`}
- >
- {Math.max(0, Math.ceil((progress / 100) * 5))}s
- </span>
- </div>
- <p className="text-[13px] font-bold text-gray-800 dark:text-gray-200 leading-snug px-0.5">
- {action.topic}
- </p>
- <div className="flex items-center gap-1.5 mt-0.5">
- <button
- onClick={(e) => {
- e.stopPropagation();
- onListen();
- }}
- className="flex-1 py-2 bg-gradient-to-r from-amber-400 to-amber-500 hover:from-amber-500 hover:to-amber-600 dark:from-amber-500 dark:to-amber-600 dark:hover:from-amber-600 dark:hover:to-amber-700 text-white text-[11px] font-black rounded-lg flex items-center justify-center gap-1.5 transition-all active:scale-[0.97] shadow-sm shadow-amber-200/50 dark:shadow-amber-800/50"
- >
- <Play className="w-3 h-3 fill-current" /> {t('proactiveCard.join')}
- </button>
- <button
- onClick={(e) => {
- e.stopPropagation();
- onTogglePause();
- }}
- className={`p-2 aspect-square rounded-lg border transition-colors active:scale-90 ${
- isPaused
- ? 'bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-700 hover:bg-amber-100 dark:hover:bg-amber-900/50'
- : 'bg-gray-50 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 border-gray-100 dark:border-gray-600'
- }`}
- title={isPaused ? t('proactiveCard.resume') : t('proactiveCard.pause')}
- >
- {isPaused ? (
- <Play className="w-3 h-3 fill-current" />
- ) : (
- <Pause className="w-3 h-3 fill-current" />
- )}
- </button>
- </div>
- </div>
- </div>
- </motion.div>
- );
- return createPortal(card, portalContainer || document.body);
- };
|