proactive-card.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. 'use client';
  2. import { useState, useEffect, useRef, useCallback } from 'react';
  3. import { createPortal } from 'react-dom';
  4. import { motion } from 'motion/react';
  5. import { Play, Pause, X } from 'lucide-react';
  6. import { useI18n } from '@/lib/hooks/use-i18n';
  7. import type { DiscussionAction } from '@/lib/types/action';
  8. interface ProactiveCardProps {
  9. action: DiscussionAction;
  10. mode: 'playback' | 'paused' | 'autonomous';
  11. /** Ref to the anchor element the card points to (avatar, etc.) */
  12. anchorRef: React.RefObject<HTMLElement | null>;
  13. /** Where the card prefers to align relative to the anchor */
  14. align?: 'left' | 'right';
  15. /** Portal target — defaults to document.body. Pass the fullscreen container
  16. * when in presentation mode so the card stays visible inside the top-layer. */
  17. portalContainer?: HTMLElement | null;
  18. agentName?: string;
  19. agentAvatar?: string;
  20. agentColor?: string;
  21. onSkip: () => void;
  22. onListen: () => void;
  23. onTogglePause: () => void;
  24. }
  25. const CARD_WIDTH = 256; // w-64
  26. const VIEWPORT_PAD = 12;
  27. /**
  28. * 主动讨论卡片组件
  29. *
  30. * 通过 React Portal 渲染到 document.body,使用 fixed 定位,
  31. * 不受父级 overflow/z-index stacking context 影响。
  32. */
  33. export const ProactiveCard = ({
  34. action,
  35. mode,
  36. anchorRef,
  37. align = 'right',
  38. portalContainer,
  39. agentName,
  40. agentAvatar,
  41. agentColor,
  42. onSkip,
  43. onListen,
  44. onTogglePause,
  45. }: ProactiveCardProps) => {
  46. const { t } = useI18n();
  47. const [progress, setProgress] = useState(100);
  48. const skippedRef = useRef(false);
  49. const isPaused = mode === 'paused';
  50. // Computed position state
  51. const [pos, setPos] = useState<{
  52. left: number;
  53. bottom: number;
  54. tailOffset: number;
  55. } | null>(null);
  56. const updatePosition = useCallback(() => {
  57. const el = anchorRef.current;
  58. if (!el) return;
  59. const rect = el.getBoundingClientRect();
  60. const anchorCenterX = rect.left + rect.width / 2;
  61. const anchorTop = rect.top;
  62. // Center card on anchor, clamped to viewport
  63. let cardLeft = anchorCenterX - CARD_WIDTH / 2;
  64. cardLeft = Math.max(
  65. VIEWPORT_PAD,
  66. Math.min(window.innerWidth - CARD_WIDTH - VIEWPORT_PAD, cardLeft),
  67. );
  68. const tailOffset = Math.max(16, Math.min(CARD_WIDTH - 16, anchorCenterX - cardLeft));
  69. const bottom = window.innerHeight - anchorTop + 12; // 12px gap above anchor
  70. setPos({ left: cardLeft, bottom, tailOffset });
  71. }, [anchorRef]);
  72. // Continuously track anchor position via rAF to handle CSS transitions, sidebar collapse, etc.
  73. useEffect(() => {
  74. let rafId: number;
  75. const tick = () => {
  76. updatePosition();
  77. rafId = requestAnimationFrame(tick);
  78. };
  79. rafId = requestAnimationFrame(tick);
  80. return () => cancelAnimationFrame(rafId);
  81. }, [updatePosition]);
  82. useEffect(() => {
  83. if (mode !== 'playback') return;
  84. const duration = 5000;
  85. const interval = 50;
  86. const step = (interval / duration) * 100;
  87. const timer = setInterval(() => {
  88. setProgress((prev) => {
  89. const newProgress = prev - step;
  90. if (newProgress <= 0) {
  91. clearInterval(timer);
  92. return 0;
  93. }
  94. return newProgress;
  95. });
  96. }, interval);
  97. return () => clearInterval(timer);
  98. }, [mode]);
  99. useEffect(() => {
  100. if (progress <= 0 && !skippedRef.current && mode === 'playback') {
  101. skippedRef.current = true;
  102. onSkip();
  103. }
  104. }, [progress, onSkip, mode]);
  105. if (!pos) return null;
  106. const card = (
  107. <motion.div
  108. initial={{ opacity: 0, y: 10, scale: 0.95 }}
  109. animate={{ opacity: 1, y: 0, scale: 1 }}
  110. exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
  111. className="fixed w-64 z-[9999] pointer-events-auto"
  112. style={{
  113. left: pos.left,
  114. bottom: pos.bottom,
  115. ...(align === 'left'
  116. ? { transformOrigin: 'bottom left' }
  117. : { transformOrigin: 'bottom right' }),
  118. }}
  119. >
  120. <div className="relative">
  121. {/* Close button */}
  122. <button
  123. onClick={(e) => {
  124. e.stopPropagation();
  125. onSkip();
  126. }}
  127. 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"
  128. title={t('proactiveCard.skip')}
  129. >
  130. <X className="w-3 h-3 stroke-[2.5]" />
  131. </button>
  132. {/* Triangle Tail */}
  133. <div
  134. 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"
  135. style={{
  136. left: `${pos.tailOffset}px`,
  137. transform: 'translateX(-50%) rotate(45deg)',
  138. }}
  139. />
  140. {/* Card body */}
  141. <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">
  142. {/* Progress Bar */}
  143. <div className="absolute top-0 left-0 right-0 h-1 bg-gray-50/50 dark:bg-gray-700/50">
  144. <div
  145. className={`h-full transition-all duration-[50ms] ease-linear ${
  146. isPaused
  147. ? 'bg-gray-300 dark:bg-gray-600'
  148. : 'bg-gradient-to-r from-amber-400 to-amber-500 dark:from-amber-500 dark:to-amber-600'
  149. }`}
  150. style={{ width: `${progress}%` }}
  151. />
  152. </div>
  153. {/* Header */}
  154. <div className="flex items-center gap-2 px-0.5 pt-1">
  155. {agentAvatar && (
  156. <div className="w-6 h-6 rounded-full overflow-hidden shrink-0 border border-gray-100 dark:border-gray-700">
  157. <img
  158. src={agentAvatar}
  159. alt={agentName || ''}
  160. className="w-full h-full object-cover"
  161. />
  162. </div>
  163. )}
  164. <div className="flex items-center gap-1.5 flex-1 min-w-0">
  165. {agentName && (
  166. <span className="text-[11px] font-semibold text-gray-700 dark:text-gray-300 truncate">
  167. {agentName}
  168. </span>
  169. )}
  170. <span
  171. className="text-[9px] font-black uppercase tracking-widest px-1.5 py-0.5 rounded-full shrink-0"
  172. style={{
  173. color: agentColor || '#d97706',
  174. backgroundColor: agentColor ? `${agentColor}18` : 'rgba(217, 119, 6, 0.08)',
  175. }}
  176. >
  177. {t('proactiveCard.discussion')}
  178. </span>
  179. </div>
  180. <span
  181. className={`text-[10px] font-bold tabular-nums shrink-0 ${
  182. isPaused ? 'text-gray-300 dark:text-gray-600' : 'text-gray-400 dark:text-gray-500'
  183. }`}
  184. >
  185. {Math.max(0, Math.ceil((progress / 100) * 5))}s
  186. </span>
  187. </div>
  188. <p className="text-[13px] font-bold text-gray-800 dark:text-gray-200 leading-snug px-0.5">
  189. {action.topic}
  190. </p>
  191. <div className="flex items-center gap-1.5 mt-0.5">
  192. <button
  193. onClick={(e) => {
  194. e.stopPropagation();
  195. onListen();
  196. }}
  197. 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"
  198. >
  199. <Play className="w-3 h-3 fill-current" /> {t('proactiveCard.join')}
  200. </button>
  201. <button
  202. onClick={(e) => {
  203. e.stopPropagation();
  204. onTogglePause();
  205. }}
  206. className={`p-2 aspect-square rounded-lg border transition-colors active:scale-90 ${
  207. isPaused
  208. ? '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'
  209. : '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'
  210. }`}
  211. title={isPaused ? t('proactiveCard.resume') : t('proactiveCard.pause')}
  212. >
  213. {isPaused ? (
  214. <Play className="w-3 h-3 fill-current" />
  215. ) : (
  216. <Pause className="w-3 h-3 fill-current" />
  217. )}
  218. </button>
  219. </div>
  220. </div>
  221. </div>
  222. </motion.div>
  223. );
  224. return createPortal(card, portalContainer || document.body);
  225. };