From 8e787fe3c097ad0f535f0880a4e1f337c4055b70 Mon Sep 17 00:00:00 2001 From: Zack <43022758+bahamutww@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:06:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-assistant):=20=E9=87=8D=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E5=AA=92=E4=BD=93=E4=BB=BB=E5=8A=A1=E5=8D=A1=E5=8F=8A?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 媒体任务卡单色主题重设计,统一使用中性主题变量,失败态唯一用强调色 - 新增共享极简播放器MediaAudioPlayer,替代原生audio控件 - 抽取统一拖拽把手组件TaskCardDragHandle,明确拖拽区域与视觉提示 - 新增任务卡演示页,支持mockStatus跳过轮询,展示全状态矩阵 - 刷新后思考面板及媒体任务卡持久化恢复,reasoning内容用包裹保存 - 保存消息数据结构增添视频、音乐、语音任务数组,保持增量向后兼容 - 画布拖拽事件类型统一为原生MouseEvent | TouchEvent,改进触屏兼容 - 失败态错误信息完整展示且支持一键复制,取消重试入口 - 移除耗时文案,加载态改为骨架动画与顶部spinner - 完善i18n音频卡文本,覆盖中英文双语言 - 修复任务卡边框不显示问题,采用border-width及border-border类控制 - 改善触屏拖拽效果,新增getDragEventPoint支持触摸点坐标与修正ctrlKey/metaKey属性 - 代码静态检查无错误,后端逻辑调整需重启服务生效,前端支持热更 --- CHANGELOG.md | 93 ++++++ backend/admin/package-lock.json | 4 +- backend/admin/package.json | 2 +- backend/config.py | 2 +- backend/routers/chats.py | 3 + backend/services/chat_generation.py | 34 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../src/app/demo/task-cards-demo/page.tsx | 294 +++++++++++++++++ .../ai-assistant/MediaAudioPlayer.tsx | 164 ++++++++++ .../components/ai-assistant/MusicTaskCard.tsx | 290 ++++++++--------- .../ai-assistant/TaskCardDragHandle.tsx | 49 +++ .../components/ai-assistant/TtsTaskCard.tsx | 225 ++++++------- .../components/ai-assistant/VideoTaskCard.tsx | 295 ++++++++---------- .../ai-assistant/hooks/useSessionManager.ts | 9 +- frontend/src/i18n/locales/en-US.json | 29 ++ frontend/src/i18n/locales/zh-CN.json | 29 ++ 17 files changed, 1069 insertions(+), 459 deletions(-) create mode 100644 frontend/src/app/demo/task-cards-demo/page.tsx create mode 100644 frontend/src/components/ai-assistant/MediaAudioPlayer.tsx create mode 100644 frontend/src/components/ai-assistant/TaskCardDragHandle.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b640f9..0748b5d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,99 @@ --- +## [v0.1.6] - 2026-09-30 + +**主题:AI 助手媒体任务卡单色重设计(共享极简播放器 + 拖拽把手)、刷新后思考面板与媒体任务卡持久化恢复、画布拖拽事件类型规范化与触屏兼容** + +上一版本:`v0.1.5`(含热修复 `v0.1.5-fix` / PR #329:`packaging` 依赖区间修正)。本版本无数据库迁移;无 npm 依赖清单变更(三张任务卡内部不再 `import framer-motion`,但该库仍被其它组件使用)。 + +### ✨ 新增 + +#### 共享极简媒体播放器 MediaAudioPlayer + +- 音乐 / 语音任务卡完成态统一改用自绘极简播放器:`primary` 圆形播放键 + 细进度条 + 时间 + `extraActions` 插槽(音乐卡放歌词开关)+ 下载按钮,取代浏览器原生 `audio controls`。 +- 各控件 `onMouseDown` 阻断冒泡,播放器空白处仍可拖拽卡片到画布;不复用画布侧重型 `AudioDisplay`(圆形频谱,与「更精简、更单色」相悖)。 + +#### 任务卡拖拽把手 TaskCardDragHandle + +- 抽出独立拖拽把手组件,明确可拖拽区域与视觉 affordance,三卡统一复用。 + +#### task-cards-demo 演示页 + +- 新增 `/demo/task-cards-demo`(与 `orbs-demo` 同约定):单色主题 token 色板 + 图像四态 + 视频 / 音乐 / 语音三卡的 `pending/processing/completed/failed` 全状态矩阵,直接复用生产组件保证视觉 1:1。 +- 三卡新增可选 `mockStatus` prop:提供后跳过 `/status` 轮询并一次性注入静态状态,专供演示页使用,生产调用不传则行为完全不变。 + +### 🔧 改进 + +- **媒体任务卡单色重设计**:`VideoTaskCard` / `MusicTaskCard` / `TtsTaskCard` 三卡改走中性主题 token(`bg-card` / `border-border` / `muted` / `muted-foreground` / `foreground`),深浅主题自适应,仅失败态用 `destructive` 作唯一强调色;不再消费 `globals.css` 的 `--color-status-*` 多彩变量族,类型图标恒为中性色,状态语义交给文字色调 + 终态状态图标(`CheckCircle2` / `XCircle`)。 +- **加载态与耗时文案**:移除「通常需要 1-5 分钟 / 30-120 秒 / 10-60 秒」等耗时提示,加载态改为骨架块(`h-12`,音视频 `h-40`,`bg-muted animate-pulse`)+ 头部 `Loader2` spinner;三卡内部移除 `framer-motion` 依赖。 +- **失败态**:错误信息不再截断,改为完整展示 +「复制错误信息」按钮(`navigator.clipboard` + toast);因卡片仅持有 `taskId`、拿不到原始 prompt / 参数,故不提供重试而以复制错误替代。 +- **全面 i18n**:三卡所有裸中文迁入 `ai.mediaTask.*` 键族(zh-CN / en-US 双语各 +29 键),含 `mode.text_to_video` / `image_to_video` / `edit` 动态键。 +- **画布拖拽事件类型规范化**:拖拽回调事件类型由 `React.MouseEvent` 统一为原生 `MouseEvent | TouchEvent`,组合回调显式标注 `OnNodeDrag` 泛型消除 `any`,防止类型签名漂移。 + +### 🐛 修复 + +- **刷新后思考面板与媒体任务卡丢失**:根因是 `chat_messages.content` 单一 JSON blob 保存时只写 `{text, skill_calls, tool_calls}`,reasoning 与三类媒体任务从不落库(工具条 / 技能条 / 内联图片因已接线而保留)。修复沿用 `skill_calls/tool_calls` 的 content-JSON 持久化模式,补接「保存 → GET → 前端恢复」三处链路: + - **reasoning** 以 `` 包裹持久化进 `text`(`chat_generation.py`)——同时修复刷新显示与「多轮对话上一轮 reasoning 从不回传 DeepSeek」的隐藏 bug,前端思考面板零改动。 + - **视频 / 音乐 / 语音任务**存为独立数组 `video_tasks` / `music_tasks` / `tts_tasks`:保存前在 `ctx.*_tasks.clear()` 之前收集落库,`GET /chats/{id}/messages`(`chats.py`)补返回该三数组,前端 `loadSessionData`(`useSessionManager.ts`)映射回消息。仅写入非空数组,增量、向后兼容、无需 DB 迁移;恢复后任务卡走 `/status` 轮询,已完成立即显示。 +- **任务卡边框静默失效**:原卡片只写 `border-[var(--x-border)]` 颜色而缺 `border` 宽度类,`border-width` 恒为 0 致边框从不显示(tsc / eslint 均无法发现);重设计后改 `border border-border` 得到真实 1px 边框。 +- **触屏拖拽兼容**:新增 `getDragEventPoint` 从 `changedTouches` 提取 `TouchEvent` 的坐标与 `ctrlKey` / `metaKey`(原生 TouchEvent 无这些字段),修复触屏下拖拽到 AI 面板的悬停检测;同时消除拖拽回调类型不兼容导致的生产构建类型报错。 + +### ✅ 测试与验证 + +- 后端 `py_compile`:`chat_generation.py`、`chats.py` 通过。 +- 前端 `tsc --noEmit`:零类型错误(覆盖任务卡重写、`MediaAudioPlayer` / `TaskCardDragHandle` 新组件、demo 页、拖拽重构、`useSessionManager`)。 +- ESLint:任务卡改动文件 `error=0`(优于基线,顺带修复既有 `react-hooks/immutability` error);demo 页 `mockStatus` 一次性注入 effect 加 `react-hooks/set-state-in-effect` 禁用注释。 +- 两个 i18n locale(zh-CN / en-US)JSON 合法,`ai.mediaTask` 键族双语齐备。 +- 落库任务对象结构 `{task_id, video_mode, model}`(视频)/ `{task_id, model}`(音乐、语音)与前端 `VideoTaskData` / `MusicTaskData` / `TtsTaskData` 逐字段吻合,`deserialize_content` 原样透传 dict。 + +### 📌 升级说明 + +1. **无需执行数据库迁移**;**无 npm 依赖清单变更**(无需 `npm install`)。 +2. 后端 Python 改动(`chats.py` / `chat_generation.py`)需**重启服务**生效;持久化为增量兼容,历史消息不受影响(旧消息无 `think` 块 / 任务数组时按原样渲染)。 +3. 纯前端改动热更即可;演示页 `/demo/task-cards-demo` 仅用于开发预览,不影响生产逻辑。 + +### 📁 主要变更文件 + +**后端** + +``` +backend/config.py 版本号 0.1.6(单一来源) +backend/services/chat_generation.py reasoning 以 落库 + 三类媒体任务数组持久化 +backend/routers/chats.py GET messages 返回 video/music/tts_tasks +``` + +**前端** + +``` +frontend/package.json / package-lock.json 版本号 0.1.6 +frontend/src/components/ai-assistant/MediaAudioPlayer.tsx 新增:共享极简播放器 +frontend/src/components/ai-assistant/TaskCardDragHandle.tsx 新增:任务卡拖拽把手 +frontend/src/components/ai-assistant/VideoTaskCard.tsx 单色重设计 + mockStatus + i18n +frontend/src/components/ai-assistant/MusicTaskCard.tsx 单色重设计 + 接入 MediaAudioPlayer +frontend/src/components/ai-assistant/TtsTaskCard.tsx 单色重设计 + 接入 MediaAudioPlayer +frontend/src/components/ai-assistant/hooks/useSessionManager.ts 恢复历史消息映射三类任务数组 +frontend/src/app/theater/[id]/hooks/useNodeDragToAI.ts 拖拽事件归一化 + getDragEventPoint +frontend/src/app/theater/[id]/hooks/useCanvasSnapping.ts 拖拽事件类型统一 +frontend/src/app/theater/[id]/page.tsx 组合回调显式 OnNodeDrag 泛型 +frontend/src/app/demo/task-cards-demo/page.tsx 新增:任务卡全状态演示页 +frontend/src/i18n/locales/{zh-CN,en-US}.json ai.mediaTask 键族 +``` + +**管理端** + +``` +backend/admin/package.json / package-lock.json 版本号 0.1.6 +``` + +**文档** + +``` +CHANGELOG.md 本文件 +``` + +--- + ## [v0.1.5] - 2026-09-29 **主题:AI 助手面板与画布节点视觉升级(BorderBeam 流光描边 + ThinkingOrb 加载球体)、TTS 拖拽与音色选择修复、agentscope 2.0.9 升级与依赖安全下界抬升** diff --git a/backend/admin/package-lock.json b/backend/admin/package-lock.json index 51e228a8..4cc3338b 100644 --- a/backend/admin/package-lock.json +++ b/backend/admin/package-lock.json @@ -1,12 +1,12 @@ { "name": "admin-dashboard", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "admin-dashboard", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { "@hookform/resolvers": "^5.4.0", "@monaco-editor/react": "^4.7.0", diff --git a/backend/admin/package.json b/backend/admin/package.json index cfb8cf20..b42ed02e 100644 --- a/backend/admin/package.json +++ b/backend/admin/package.json @@ -1,6 +1,6 @@ { "name": "admin-dashboard", - "version": "0.1.5", + "version": "0.1.6", "private": true, "scripts": { "dev": "next dev -p 3888", diff --git a/backend/config.py b/backend/config.py index 86a511f5..631905f9 100644 --- a/backend/config.py +++ b/backend/config.py @@ -9,7 +9,7 @@ class Settings(BaseSettings): PROJECT_NAME: str = "KunFlix" # 后端版本号 —— 全项目版本的单一来源,与 frontend/package.json、 # backend/admin/package.json 及 CHANGELOG.md 保持同步 - VERSION: str = "0.1.5" + VERSION: str = "0.1.6" # --------------------------------------------------------------- # Database diff --git a/backend/routers/chats.py b/backend/routers/chats.py index 19dfac29..847269eb 100644 --- a/backend/routers/chats.py +++ b/backend/routers/chats.py @@ -143,6 +143,9 @@ async def get_session_messages( "content": deserialized.get("text") or "", "skill_calls": deserialized.get("skill_calls", []), "tool_calls": deserialized.get("tool_calls", []), + "video_tasks": deserialized.get("video_tasks", []), + "music_tasks": deserialized.get("music_tasks", []), + "tts_tasks": deserialized.get("tts_tasks", []), "multi_agent": deserialized.get("multi_agent"), "created_at": msg.created_at }) diff --git a/backend/services/chat_generation.py b/backend/services/chat_generation.py index b1202cfa..14df6f74 100644 --- a/backend/services/chat_generation.py +++ b/backend/services/chat_generation.py @@ -296,6 +296,10 @@ def _content_len(c): return len(c) if isinstance(c, str) else sum(len(p.get('tex # 从智能体配置读取工具调用轮次限制,默认100,范围10-200 MAX_TOOL_ROUNDS = max(10, min(200, agent.max_tool_rounds or 100)) all_tool_calls = [] # 记录所有执行的普通工具 + # 媒体任务跨轮持久化列表(ctx.*_tasks 在 SSE emit 后即清空,需在清空前收集) + all_video_tasks: list = [] + all_music_tasks: list = [] + all_tts_tasks: list = [] tool_generated_image_count = 0 # generate_image 工具累计生成图片数(跨轮次) result = None generation_failed = False @@ -547,17 +551,20 @@ def _content_len(c): return len(c) if isinstance(c, str) else sum(len(p.get('tex Exception(f"Tool circuit breaker: {_consecutive_tool_failures} consecutive failures") ) - # 发送视频任务创建事件(通知前端启动轮询UI) + # 发送视频任务创建事件(通知前端启动轮询UI),清空前收集以供落库 + all_video_tasks.extend(ctx.video_tasks) for vt in ctx.video_tasks: yield sse("video_task_created", vt) ctx.video_tasks.clear() - # 发送音乐任务创建事件(通知前端启动轮询UI) + # 发送音乐任务创建事件(通知前端启动轮询UI),清空前收集以供落库 + all_music_tasks.extend(ctx.music_tasks) for mt in ctx.music_tasks: yield sse("music_task_created", mt) ctx.music_tasks.clear() - # 发送 TTS 任务创建事件(通知前端启动轮询UI) + # 发送 TTS 任务创建事件(通知前端启动轮询UI),清空前收集以供落库 + all_tts_tasks.extend(ctx.tts_tasks) for tt in ctx.tts_tasks: yield sse("tts_task_created", tt) ctx.tts_tasks.clear() @@ -613,15 +620,28 @@ def _content_len(c): return len(c) if isinstance(c, str) else sum(len(p.get('tex } # Prepare content for assistant(映射表驱动,避免 if-else) + # reasoning 以 包裹拼回 text:刷新后前端思考面板可恢复(parseThinkContent), + # 且多轮对话时 _extract_reasoning_to_msg 能将其还原为 reasoning_content 回传给 DeepSeek + text_value = ( + f"{result.reasoning_content}\n\n{result.full_response}" + if result.reasoning_content else result.full_response + ) + _extra_content = { + "video_tasks": all_video_tasks, + "music_tasks": all_music_tasks, + "tts_tasks": all_tts_tasks, + } _content_builders = { True: lambda: json.dumps({ - "text": result.full_response, + "text": text_value, "skill_calls": [{"skill_name": s, "status": "loaded"} for s in loaded_skills], - "tool_calls": [{"tool_name": tc["name"], "arguments": tc["arguments"], "status": "completed"} for tc in all_tool_calls] + "tool_calls": [{"tool_name": tc["name"], "arguments": tc["arguments"], "status": "completed"} for tc in all_tool_calls], + # 仅写入非空的媒体任务数组,保持既有消息结构向后兼容 + **{k: v for k, v in _extra_content.items() if v}, }, ensure_ascii=False), - False: lambda: result.full_response, + False: lambda: text_value, } - final_content = _content_builders[bool(loaded_skills or all_tool_calls)]() + final_content = _content_builders[bool(loaded_skills or all_tool_calls or _extra_content)]() _persist_state = {"compaction": None, "new_title": None} # mutable container for cross-scope sharing diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 711a1821..30e6aab6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { "@ant-design/nextjs-registry": "^1.3.0", "@e965/xlsx": "^0.20.3", diff --git a/frontend/package.json b/frontend/package.json index 635bd48f..0545ff74 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.1.5", + "version": "0.1.6", "private": true, "scripts": { "dev": "next dev -p 3666", diff --git a/frontend/src/app/demo/task-cards-demo/page.tsx b/frontend/src/app/demo/task-cards-demo/page.tsx new file mode 100644 index 00000000..eb243cf5 --- /dev/null +++ b/frontend/src/app/demo/task-cards-demo/page.tsx @@ -0,0 +1,294 @@ +'use client'; + +import React from 'react'; +import { VideoTaskCard } from '@/components/ai-assistant/VideoTaskCard'; +import { MusicTaskCard } from '@/components/ai-assistant/MusicTaskCard'; +import { TtsTaskCard } from '@/components/ai-assistant/TtsTaskCard'; +import { LazyImage } from '@/components/ai-assistant/LazyImage'; +import { CallTimelinePanel } from '@/components/ai-assistant/CallTimelinePanel'; +import { useTheme } from '@/context/ThemeContext'; + +/* ── 演示素材(public/effect 下的静态图;音视频用静默占位地址) ─────────── */ +const DEMO_IMAGE = '/effect/023576f6-ce4d-4e16-ae0f-22ce69ac363e.jpg'; +const DEMO_IMAGE_2 = '/effect/1619b2eb-11a6-4302-9b0a-c4ee79283af6.jpg'; +const DEMO_BROKEN_SRC = '/demo-missing-media.jpg'; +// data URI 静默 WAV:保证播放器可正常渲染且不产生网络请求 +const SILENT_WAV = + 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA='; + +/* ── 单色主题 token 一览(任务卡实际消费的中性变量,随深浅主题切换) ─────── */ +const THEME_TOKENS = [ + { var: '--card', label: '卡片背景', usage: 'bg-card' }, + { var: '--border', label: '卡片边框', usage: 'border-border' }, + { var: '--muted', label: '徽标/骨架/进度轨', usage: 'bg-muted' }, + { var: '--muted-foreground', label: '次要文字/图标', usage: 'text-muted-foreground' }, + { var: '--foreground', label: '主文字/进度填充', usage: 'text-foreground' }, + { var: '--primary', label: '播放键背景', usage: 'bg-primary' }, + { var: '--destructive', label: '失败态唯一强调色', usage: 'text-destructive' }, +] as const; + +/* ── 工具调用时间轴示例数据(generate_image / generate_video 等工具链) ── */ +const DEMO_SKILL_CALLS = [ + { skill_name: 'media_tools', status: 'loaded' as const }, +]; +const DEMO_TOOL_CALLS = [ + { tool_name: 'generate_image', status: 'executing' as const, arguments: { prompt: '赛博朋克城市夜景' } }, + { + tool_name: 'generate_video', + status: 'completed' as const, + arguments: { prompt: '海浪拍打礁石', video_mode: 'text_to_video' }, + result: '', + duration: 1240, + }, + { + tool_name: 'generate_music', + status: 'completed' as const, + result: JSON.stringify({ error: 'Music generation failed: provider quota exceeded' }), + duration: 380, + }, +]; + +interface SectionProps { + title: string; + description: React.ReactNode; + children: React.ReactNode; +} + +function Section({ title, description, children }: SectionProps) { + return ( +
+
+

{title}

+

{description}

+
+ {children} +
+ ); +} + +function StateCell({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+
+ {label} + {hint && {hint}} +
+
{children}
+
+ ); +} + +/** + * AI 助手任务卡片演示页(视觉验收 / 样式调试用)。 + * + * 覆盖 AI 调用工具时产生的四类媒体任务在对话面板中的真实渲染组件: + * - 图像:Markdown 内联 ![image](url) → LazyImage(无独立任务卡) + * - 视频:VideoTaskCard(__VIDEO_TASK__ 标记 / video_task_created SSE) + * - 音乐:MusicTaskCard(__MUSIC_TASK__ 标记 / music_task_created SSE) + * - 语音:TtsTaskCard(__TTS_TASK__ 标记 / tts_task_created SSE) + * + * 三张任务卡通过 mockStatus 注入静态状态,跳过 /status 轮询; + * 生产调用不传该 prop,行为不变。 + */ +export default function TaskCardsDemoPage() { + const { resolvedTheme } = useTheme(); + + return ( +
+
+ {/* 头部 */} +
+

AI 助手 · 媒体任务卡片演示

+

+ 图像 / 视频 / 音乐 / 语音 四类工具任务在对话面板中的全状态展示 · 当前主题:{resolvedTheme} +

+
+ + {/* 单色主题 token 一览 */} +
+
+ {THEME_TOKENS.map((token) => ( +
+ +
+
{token.label}
+ {token.usage} +
+
+ ))} +
+
+ + {/* 图像任务 */} +
+
+ +
+ +
+
+ + + + + + +
+ +
+ + +
+
+
+ + {/* 视频任务 */} +
+
+ + + + + + + + + + + + +
+
+ + {/* 音乐任务 */} +
+
+ + + + + + + + + + + + +
+
+ + {/* 语音任务 */} +
+
+ + + + + + + + + + + + +
+
+ + {/* 工具调用时间轴 */} +
+
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/ai-assistant/MediaAudioPlayer.tsx b/frontend/src/components/ai-assistant/MediaAudioPlayer.tsx new file mode 100644 index 00000000..736886ec --- /dev/null +++ b/frontend/src/components/ai-assistant/MediaAudioPlayer.tsx @@ -0,0 +1,164 @@ +'use client'; + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Play, Pause, Download } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { cn } from '@/lib/utils'; + +interface MediaAudioPlayerProps { + audioUrl: string; + /** 下载文件名(缺省时从 URL 推断) */ + downloadName?: string; + /** 额外操作按钮(如音乐卡的歌词开关),渲染在下载按钮左侧 */ + extraActions?: React.ReactNode; + className?: string; +} + +/** 秒数 → m:ss */ +function formatTime(sec: number): string { + const safe = Number.isFinite(sec) && sec > 0 ? sec : 0; + const m = Math.floor(safe / 60); + const s = Math.floor(safe % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +/** + * 极简音频播放器(AI 助手媒体任务卡共用)。 + * + * 单色主题:primary 播放键 + muted 进度轨 + foreground 进度填充,随深浅主题自适应。 + * 一行式布局:[播放] [进度条] [时间] [extraActions] [下载]。 + * 控制区统一 onMouseDown 阻断冒泡,使外层可拖拽容器仍能空白处拖拽到画布。 + */ +export function MediaAudioPlayer({ audioUrl, downloadName, extraActions, className }: MediaAudioPlayerProps) { + const { t } = useTranslation(); + const audioRef = useRef(null); + const progressRef = useRef(null); + + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [isSeeking, setIsSeeking] = useState(false); + + // 音频事件绑定 + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + const onEnded = () => setIsPlaying(false); + const onTime = () => { !isSeeking && setCurrentTime(audio.currentTime); }; + const onMeta = () => setDuration(audio.duration || 0); + audio.addEventListener('ended', onEnded); + audio.addEventListener('timeupdate', onTime); + audio.addEventListener('loadedmetadata', onMeta); + return () => { + audio.removeEventListener('ended', onEnded); + audio.removeEventListener('timeupdate', onTime); + audio.removeEventListener('loadedmetadata', onMeta); + }; + }, [isSeeking]); + + const togglePlay = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + const audio = audioRef.current; + if (!audio) return; + const playing = audio.paused; + playing ? await audio.play().catch(() => {}) : audio.pause(); + setIsPlaying(playing); + }, []); + + const seekTo = useCallback((clientX: number) => { + const bar = progressRef.current; + const audio = audioRef.current; + if (!bar || !audio || !duration) return; + const rect = bar.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + audio.currentTime = ratio * duration; + setCurrentTime(ratio * duration); + }, [duration]); + + const onProgressDown = useCallback((e: React.PointerEvent) => { + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + setIsSeeking(true); + seekTo(e.clientX); + }, [seekTo]); + + const onProgressMove = useCallback((e: React.PointerEvent) => { + isSeeking && seekTo(e.clientX); + }, [isSeeking, seekTo]); + + const onProgressUp = useCallback((e: React.PointerEvent) => { + e.currentTarget.releasePointerCapture(e.pointerId); + setIsSeeking(false); + }, []); + + const onDownload = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + const link = document.createElement('a'); + link.href = audioUrl; + link.download = downloadName || audioUrl.split('/').pop() || 'audio'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, [audioUrl, downloadName]); + + const fillPct = duration ? `${(currentTime / duration) * 100}%` : '0%'; + + return ( +
+