Compare commits

...

1 Commits
v4.6.0 ... main

Author SHA1 Message Date
Kevin Wong
b289006844 更新 2026-03-05 17:23:22 +08:00
19 changed files with 2588 additions and 151 deletions

View File

@@ -161,6 +161,8 @@ backend/user_data/{user_uuid}/cookies/
- 业务逻辑写在 service/workflow。 - 业务逻辑写在 service/workflow。
- 数据库访问写在 repositories。 - 数据库访问写在 repositories。
- 统一使用 `loguru` 打日志。 - 统一使用 `loguru` 打日志。
- GLM SDK 调用统一收口到 `services/glm_service.py`(通过统一入口方法),避免在模块内重复拼装 `chat.completions.create` 调用代码。
- 涉及文案深度学习的抓取调用router 侧应透传 `current_user.id``creator_scraper`,以便复用用户 Cookie 上下文并保持 `analysis_id` 用户隔离。
--- ---

View File

@@ -117,6 +117,13 @@ backend/
9. **工具 (Tools)** 9. **工具 (Tools)**
* `POST /api/tools/extract-script`: 从视频链接提取文案(需登录) * `POST /api/tools/extract-script`: 从视频链接提取文案(需登录)
* `POST /api/tools/analyze-creator`: 分析博主标题并返回热门话题(需登录)
* `POST /api/tools/generate-topic-script`: 基于选中话题生成文案(需登录)
> 文案深度学习说明:
> - 平台支持:抖音 / B站博主主页链接。
> - 抓取策略:当前统一使用 Playwright 主链路抓取标题(抖音/B站并结合用户登录态 Cookie 上下文增强成功率。
> - `analysis_id` 绑定 `user_id` 且有 TTL默认 20 分钟),用于后续“生成文案”阶段安全读取标题上下文。
10. **健康检查** 10. **健康检查**
* `GET /api/videos/lipsync/health`: 唇形同步服务健康状态(含 LatentSync + MuseTalk + 混合路由阈值) * `GET /api/videos/lipsync/health`: 唇形同步服务健康状态(含 LatentSync + MuseTalk + 混合路由阈值)
@@ -241,7 +248,7 @@ pip install -r requirements.txt
SUPABASE_URL=http://localhost:8008 SUPABASE_URL=http://localhost:8008
SUPABASE_KEY=your_service_role_key SUPABASE_KEY=your_service_role_key
# GLM API (用于 AI 标题生成) # GLM API (用于 AI 标题/改写/翻译/文案深度学习)
GLM_API_KEY=your_glm_api_key GLM_API_KEY=your_glm_api_key
# LatentSync 配置 # LatentSync 配置

View File

@@ -7,7 +7,7 @@
1. 修复首页与发布成功弹窗点击下载时被浏览器当作在线播放(新开标签页)的问题。 1. 修复首页与发布成功弹窗点击下载时被浏览器当作在线播放(新开标签页)的问题。
2. 将下载修复开始后的开发内容从 `Day31` 拆分到 `Day32`,保持日志按天清晰归档。 2. 将下载修复开始后的开发内容从 `Day31` 拆分到 `Day32`,保持日志按天清晰归档。
3. 根据安全审计报告(`Temp/安全审计报告.md`),实施第一批 6 项无功能风险的安全修复。 3. 根据安全审计报告(`Temp/安全审计报告.md`),实施第一批 6 项无功能风险的安全修复。
4. 统一弹窗关闭交互:默认支持点空白关闭,发布成功清理弹窗保持强制留存。 4. 统一弹窗关闭交互(仅关闭策略):默认支持点空白关闭,发布成功清理弹窗保持强制留存。
--- ---
@@ -91,12 +91,13 @@
--- ---
## ✅ 4) 弹窗关闭交互统一UX ## ✅ 4) 弹窗关闭策略统一UX
### 目标 ### 目标
- 保持统一交互预期:业务弹窗默认可通过 `X` 与点击遮罩关闭。 - 保持统一交互预期:业务弹窗默认可通过 `X` 与点击遮罩关闭。
- 保留关键流程保护:发布成功清理弹窗继续禁止遮罩关闭,避免误触导致流程中断。 - 保留关键流程保护:发布成功清理弹窗继续禁止遮罩关闭,避免误触导致流程中断。
- 说明:按钮位置与视觉样式统一属于 Day33 范畴,本日志仅记录关闭策略统一。
### 调整内容 ### 调整内容
@@ -126,7 +127,7 @@
| `frontend/src/components/AccountSettingsDropdown.tsx` | 修改密码弹窗支持点击遮罩关闭 | | `frontend/src/components/AccountSettingsDropdown.tsx` | 修改密码弹窗支持点击遮罩关闭 |
| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 录音弹窗改为 `closeOnOverlay={!isRecording}`(录音中禁遮罩关闭) | | `frontend/src/features/home/ui/RefAudioPanel.tsx` | 录音弹窗改为 `closeOnOverlay={!isRecording}`(录音中禁遮罩关闭) |
| `Docs/DevLogs/Day31.md` | 移除下载修复章节与对应验证/覆盖项(迁入 Day32 | | `Docs/DevLogs/Day31.md` | 移除下载修复章节与对应验证/覆盖项(迁入 Day32 |
| `Docs/TASK_COMPLETE.md` | 新增 Day32 Current 区块Day31 取消 Current | | `Docs/TASK_COMPLETE.md` | 当日新增 Day32 区块并接棒 Current(后续由 Day33 接棒 Current |
| `Docs/BACKEND_README.md` | 补充 `/api/videos/generated/{video_id}/download` 接口说明 | | `Docs/BACKEND_README.md` | 补充 `/api/videos/generated/{video_id}/download` 接口说明 |
| `Docs/BACKEND_DEV.md` | 补充下载接口 `attachment` 约定 | | `Docs/BACKEND_DEV.md` | 补充下载接口 `attachment` 约定 |
| `Docs/FRONTEND_README.md` | 补充首页/发布弹窗下载统一同源接口说明 | | `Docs/FRONTEND_README.md` | 补充首页/发布弹窗下载统一同源接口说明 |

290
Docs/DevLogs/Day33.md Normal file
View File

@@ -0,0 +1,290 @@
## 抖音短链文案提取稳健性修复 (Day 33)
### 概述
今天聚焦修复「文案提取助手」里抖音分享短链/口令文本偶发提取失败的问题,并补齐多种抖音落地 URL 形态的兼容。
---
## ✅ 1) 问题复盘
### 现象
- 复制抖音分享口令文本(含 `v.douyin.com` 短链)时,文案提取偶发失败。
- 直接粘贴地址栏链接(如 `jingxuan?modal_id=...`)时,提取成功。
### 根因
- `backend/app/modules/tools/service.py``_download_douyin_manual` 原先只按 `/video/{id}` 提取视频 ID。
- 短链重定向结果并不总是 `/video/{id}`,常见还包括:
- `/share/video/{id}`
- `/user/...?...&vid={id}`
- `/follow/search?...&modal_id={id}`
- 当落到上述形态时会出现 `Could not extract video_id`,导致 fallback 失败。
---
## ✅ 2) 修复方案
### 2.1 抽取统一解析函数
- 新增 `_extract_douyin_video_id(candidate_url)`,统一解析以下 ID 形态:
- 路径:`/video/{id}``/share/video/{id}`
- Query 参数:`modal_id``vid``video_id``aweme_id``item_id`
- 解码后的整串 URL 兜底正则匹配
### 2.2 fallback 提取链路增强
- `_download_douyin_manual` 改为:
1. 优先从重定向后的 `final_url` 提取 `video_id`
2. 若失败,再从原始输入 `url` 提取 `video_id`
- 保持后续下载链路不变:访问 `m.douyin.com/share/video/{video_id}` 提取 `play_addr` 并下载。
---
## 📁 今日修改文件
| 文件 | 改动 |
|------|------|
| `backend/app/modules/tools/service.py` | 新增 `_extract_douyin_video_id`;增强抖音 fallback 的 `video_id` 提取策略(兼容 `share/video``modal_id``vid` 等) |
| `Docs/DevLogs/Day33.md` | 新增 Day33 开发日志,记录问题、根因、修复与验证 |
---
## 🔍 验证记录
- `python -m py_compile backend/app/modules/tools/service.py`
- URL 解析冒烟(函数级):
- `jingxuan?modal_id=...` 可提取 ✅
- `user?...&vid=...` 可提取 ✅
- `follow/search?...&modal_id=...` 可提取 ✅
- 下载链路冒烟(服务级):
- 用户提供的短链口令文本可成功下载临时视频 ✅
- 历史失败样例 `user?...&vid=...` 可成功走通 fallback 下载 ✅
---
## ✅ 3) 文案深度学习:抖音抓取 Playwright 降级增强
### 3.1 问题复盘
- 在「文案深度学习」博主分析链路里,抖音用户页有时返回 JS 壳页(含 `byted_acrawler`),静态 HTML 提取拿不到 `desc`
- 表现为:短链可解析 `sec_uid`,但标题抓取报错“页面结构可能已变更”。
### 3.2 修复方案
-`backend/app/services/creator_scraper.py` 中新增 Playwright 降级抓取:
1. 保留原 HTTP + `ttwid` 抓取作为首选(轻量、快)。
2. 当 HTTP 提取不到标题时,自动切换 Playwright。
3. 监听页面网络响应,定向捕获:
- `/aweme/v1/web/aweme/post/`
- `/aweme/v1/web/user/profile/other/`
4. 解析响应 JSON 中 `desc` 作为视频标题来源,并提取博主昵称。
- 仅在确实失败时返回更准确提示:
- `抖音触发风控验证,暂时无法抓取标题,请稍后重试`
### 3.3 结果
- 给定短链 `https://v.douyin.com/hmFXdx5PvzQ/` 可稳定识别并完成标题抓取。
- 抓取结果可获得有效博主昵称与约 50 条标题(受平台返回数据影响)。
### 3.4 本次新增/更新文件
| 文件 | 改动 |
|------|------|
| `backend/app/services/creator_scraper.py` | 新增抖音 Playwright 降级抓取、网络响应采集、标题/昵称解析优化、错误提示优化 |
| `Docs/DevLogs/Day33.md` | 增补文案深度学习抖音抓取增强记录 |
### 3.5 验证记录
- `python -m py_compile backend/app/services/creator_scraper.py`
- 冒烟验证:
- 短链重定向 + `sec_uid` 提取 ✅
- HTTP 首选链路失败时自动切换 Playwright ✅
- Playwright 网络响应中抓取到 `aweme/post` 数据并提取标题 ✅
---
## ✅ 4) 文案深度学习功能首版落地
### 4.1 后端实现
- 新增博主抓取服务:`backend/app/services/creator_scraper.py`
- `scrape_creator_titles(url)`:平台识别 + 标题抓取统一入口
- `validate_url(url)``https` 强制、域名白名单、DNS 全记录公网校验、逐跳重定向校验
- `cache_titles(titles, user_id)` / `get_cached_titles(analysis_id, user_id)`20 分钟 TTL + 用户绑定
- GLM 服务扩展:`backend/app/services/glm_service.py`
- `analyze_topics(titles)`从标题归纳热门话题≤10
- `generate_script_from_topic(topic, word_count, titles)`:按话题与风格生成文案
- 工具路由新增接口:`backend/app/modules/tools/router.py`
- `POST /api/tools/analyze-creator`
- `POST /api/tools/generate-topic-script`
- 使用 Pydantic JSON 请求模型 + 登录态校验 + 统一 `success_response`
### 4.2 前端实现
- 新增状态逻辑 Hook`frontend/src/features/home/ui/script-learning/useScriptLearning.ts`
- 流程状态:`input -> analyzing -> topics -> generating -> result`
- 管理分析请求、生成请求、错误态、复制、重新生成
- 新增弹窗组件:`frontend/src/features/home/ui/ScriptLearningModal.tsx`
- 步骤式 UI输入链接、话题单选、字数输入、结果展示、填入文案/复制
- 接入首页交互:
- `frontend/src/features/home/ui/ScriptEditor.tsx`:新增「文案深度学习」按钮
- `frontend/src/features/home/model/useHomeController.ts`:新增 `learningModalOpen` 状态
- `frontend/src/features/home/ui/HomePage.tsx`:挂载弹窗并支持回填主编辑器
### 4.3 交互位置与规则
- 按钮位置已按约定落位:
- `历史文案``文案提取助手``文案深度学习``AI多语言`
- 弹窗遵循当前统一策略:支持遮罩点击关闭(非关键流程弹窗)。
### 4.4 验证记录
- 后端语法检查:
- `python -m py_compile backend/app/services/creator_scraper.py backend/app/services/glm_service.py backend/app/modules/tools/router.py`
- 前端构建:
- `cd frontend && npm run build`
- 抖音短链样例联调:
- `https://v.douyin.com/hmFXdx5PvzQ/` 可解析、可抓取标题(触发降级时可自动走 Playwright
---
## ✅ 5) 抖音 Cookie 依赖澄清与 B站频率限制增强
### 5.1 抖音 Cookie 依赖澄清
- 文案深度学习的抖音抓取**不依赖发布管理页登录 Cookie**。
- 当前链路使用:
- 短链解析 + `sec_uid` 提取
- 公共访问链路(`ttwid` + 页面/接口抓取)
- 必要时 Playwright 降级
- 因此用户即使未登录抖音,也可使用该功能(但仍可能受平台风控影响)。
### 5.2 B站“请求过于频繁”优化
-`backend/app/services/creator_scraper.py` 增强 B站抓取稳健性
- 对频率限制场景增加自动重试(指数退避 + 随机抖动)
- 频率限制识别HTTP 412/429、错误码/错误文案)
- HTTP 链路失败后自动切换 Playwright 降级抓取
- 最终报错文案统一为更可理解的提示
- `mid` 提取兼容根路径与子路径(如 `/upload/video`
### 5.3 验证记录
- B站样例联调`https://space.bilibili.com/8047632` 可抓取 50 条标题 ✅
- 抖音短链复测:`https://v.douyin.com/hmFXdx5PvzQ/` 仍可抓取 50 条标题 ✅
---
## ✅ 6) 抖音 + B站 抓取可靠性二次增强
### 6.1 抖音增强
- `backend/app/services/creator_scraper.py`
- `scrape_creator_titles(..., user_id)` 透传用户 ID支持读取用户已登录平台 Cookie 作为增强上下文。
- 抖音抓取新增可选用户 Cookie 注入HTTP 请求 + Playwright 上下文)。
- Playwright 降级抓取轮次从 4 次提升到 8 次,目标改为尽量补齐 `MAX_TITLES=50`
- 保留网络响应抓取主链路(`aweme/post` + `profile/other`),优先 `desc` 提取标题。
### 6.2 B站增强
- 新增 WBI 签名链路(主链路):
- 获取 `wbi_img` key兼容 `nav` 返回 `-101` 但携带 `wbi_img` 的场景)
- 计算 `w_rid/wts` 后调用 `x/space/wbi/arc/search`
- 多页拉取(分页累加)+ 标题去重,尽量补齐 50 条
- 新增 B站会话预热
- `x/frontend/finger/spi` 获取并注入 `buvid3/buvid4`
- 支持读取用户已登录 B站 Cookie若存在提升命中率
- Playwright 降级增强:
- 监听 `x/space/*/arc/search` 响应并解析有效 payload
- 对捕获的 arc URL 进行 `context.request` 二次回放尝试
### 6.3 路由联动
- `backend/app/modules/tools/router.py`
- `/api/tools/analyze-creator` 调用抓取时传入 `current_user.id`,用于平台 Cookie 增强。
### 6.4 结果说明
- 抖音:短链场景稳定性进一步提升,风控页下优先走 Playwright 降级抓取。
- B站已补齐签名链路与降级链路但在平台强风控窗口仍可能返回“请求过于频繁/风控校验失败”,属于平台侧限制。
---
## ✅ 7) 抓取策略最终调整:抖音/B站改为 Playwright 直连
根据产品决策,将文案深度学习的博主标题抓取策略统一为 **Playwright 直连主链路**不再使用“HTTP 主链路 + Playwright 降级”。
### 7.1 调整内容
- `backend/app/services/creator_scraper.py`
- `_scrape_douyin()` 改为直接调用 `_scrape_douyin_with_playwright()`
- `_scrape_bilibili()` 改为直接调用 `_scrape_bilibili_with_playwright()`
- 两个平台均保留 2 次 Playwright 抓取重试。
- 支持优先读取用户隔离 Cookie若缺失再尝试旧版全局 Cookie。
- `backend/app/modules/tools/router.py`
- `analyze-creator` 继续传入 `current_user.id`,用于匹配用户 Cookie 上下文。
### 7.2 影响评估
- 影响范围仅限「文案深度学习」抓取链路。
- **不影响**视频自动化发布、文案提取助手extract-script现有流程。
### 7.3 验证
- 抖音短链样例:`https://v.douyin.com/hmFXdx5PvzQ/` 抓取成功50 条。
- B站样例
- `https://space.bilibili.com/256237759?spm_id_from=...` 抓取成功40 条。
- `https://space.bilibili.com/1140672573` 抓取成功40 条。
---
## ✅ 8) GLM 调用链统一与超时体验优化
### 8.1 现象
- 文案深度学习“生成文案”偶发前端报错:`timeout of 30000ms exceeded`
### 8.2 原因
- 主要是前端请求超时阈值过短30s在模型排队或长文本生成时容易超时。
- 后端虽然统一走 `glm_service`,但各方法内部仍重复编写 SDK 调用代码,维护成本高。
### 8.3 调整
- 前端:`generate-topic-script` 超时从 30s 提升到 90s并优化超时提示文案。
- 后端:`backend/app/services/glm_service.py`
- 新增 `_call_glm(...)` 作为统一调用入口(统一 model / thinking / to_thread / timeout
- `generate_title_tags / rewrite_script / analyze_topics / generate_script_from_topic / translate_text`
全部改为复用该入口
- 保持 `settings.GLM_MODEL` 单点配置,避免多处散落调用
### 8.4 结果
- GLM 调用标准统一,后续参数调整只需改一处。
- 前端超时报错显著减少;如确实超时会给出可理解提示。
---
## ✅ 9) 三个文案弹窗操作按钮统一
### 9.1 目标
- 统一「文案提取助手」「AI 智能改写」「文案深度学习」结果页操作按钮的位置、样式与主次关系。
### 9.2 调整
- `frontend/src/features/home/ui/ScriptExtractionModal.tsx`
- 结果页按钮从“分散在标题右侧 + 底部单独按钮”改为统一底部 Action Grid。
- 按钮统一为:`填入文案``复制``提取下一个``关闭`
- `frontend/src/features/home/ui/RewriteModal.tsx`
- 结果页按钮改为统一底部 Action Grid。
- 新增复制按钮(含 clipboard fallback
- 按钮统一为:`填入文案``复制``重新生成``保留原文`
- `frontend/src/features/home/ui/ScriptLearningModal.tsx`
- 维持同一 Action Grid 风格:`填入文案``复制``重新生成``换个话题`
### 9.3 验证
- `cd frontend && npm run build`

View File

@@ -39,8 +39,12 @@ frontend/src/
│ │ ├── MaterialSelector.tsx │ │ ├── MaterialSelector.tsx
│ │ ├── ScriptEditor.tsx │ │ ├── ScriptEditor.tsx
│ │ ├── ScriptExtractionModal.tsx │ │ ├── ScriptExtractionModal.tsx
│ │ ├── RewriteModal.tsx
│ │ ├── ScriptLearningModal.tsx
│ │ ├── script-extraction/ │ │ ├── script-extraction/
│ │ │ └── useScriptExtraction.ts │ │ │ └── useScriptExtraction.ts
│ │ ├── script-learning/
│ │ │ └── useScriptLearning.ts
│ │ ├── TitleSubtitlePanel.tsx │ │ ├── TitleSubtitlePanel.tsx
│ │ ├── FloatingStylePreview.tsx │ │ ├── FloatingStylePreview.tsx
│ │ ├── VoiceSelector.tsx │ │ ├── VoiceSelector.tsx
@@ -69,7 +73,8 @@ frontend/src/
│ │ ├── useTitleInput.ts │ │ ├── useTitleInput.ts
│ │ └── usePublishPrefetch.ts │ │ └── usePublishPrefetch.ts
│ ├── ui/ │ ├── ui/
│ │ ── SelectPopover.tsx # 统一下拉/BottomSheet 选择器 │ │ ── SelectPopover.tsx # 统一下拉/BottomSheet 选择器
│ │ └── AppModal.tsx # 统一弹窗基座
│ ├── types/ │ ├── types/
│ │ ├── user.ts # User 类型定义 │ │ ├── user.ts # User 类型定义
│ │ └── publish.ts # 发布相关类型 │ │ └── publish.ts # 发布相关类型
@@ -213,7 +218,7 @@ body {
## 统一弹窗规范 (AppModal) ## 统一弹窗规范 (AppModal)
所有居中弹窗如视频预览、文案提取、AI 改写、文案扩展编辑、录音、密码修改)统一使用 `@/shared/ui/AppModal` + `AppModalHeader` 所有居中弹窗如视频预览、文案提取、AI 改写、文案深度学习、文案扩展编辑、录音、密码修改)统一使用 `@/shared/ui/AppModal` + `AppModalHeader`
- 统一遮罩与层级:`fixed inset-0` + `bg-black/80` + `backdrop-blur-sm` + 明确 `z-index` - 统一遮罩与层级:`fixed inset-0` + `bg-black/80` + `backdrop-blur-sm` + 明确 `z-index`
- 统一挂载位置:通过 Portal 挂载到 `document.body`,避免局部容器/层叠上下文影响,确保是全页面弹窗 - 统一挂载位置:通过 Portal 挂载到 `document.body`,避免局部容器/层叠上下文影响,确保是全页面弹窗
@@ -225,6 +230,19 @@ body {
- 统一滚动策略:弹窗打开时锁定背景滚动(`lockBodyScroll`),内容区自行滚动 - 统一滚动策略:弹窗打开时锁定背景滚动(`lockBodyScroll`),内容区自行滚动
- 特殊层级场景(例如视频预览压过下拉)使用更高 `z-index`(如 `z-[320]` - 特殊层级场景(例如视频预览压过下拉)使用更高 `z-index`(如 `z-[320]`
### 文案类弹窗结果操作栏规范
适用组件:
- `ScriptExtractionModal`
- `RewriteModal`
- `ScriptLearningModal`
统一要求:
- 结果页操作按钮统一放在内容底部Action Grid避免“标题右上角按钮 + 底部按钮”混排。
- 主按钮统一为高亮渐变(如「填入文案」),其余按钮统一次级样式(`bg-white/10`)。
- 动作文案尽量统一:`填入文案` / `复制` / `重新生成`(或与当前流程等价的返回动作)。
- 按钮尺寸、圆角、间距保持一致(推荐 `py-2.5 px-3 rounded-lg text-sm`)。
--- ---
## 发布后清理弹窗规范 (CleanupContext) ## 发布后清理弹窗规范 (CleanupContext)
@@ -248,7 +266,7 @@ body {
所有需要认证的 API 请求**必须**使用 `@/shared/api/axios` 导出的 axios 实例。该实例已配置: 所有需要认证的 API 请求**必须**使用 `@/shared/api/axios` 导出的 axios 实例。该实例已配置:
- 自动携带 `credentials: include` - 自动携带 `credentials: include`
- 遇到 401/403 时自动清除 cookie 并跳转登录页 - 遇到 401/403 时自动清除 cookie 并跳转登录页
- AI/Tools 接口(如 `/api/ai/*``/api/tools/extract-script`)现为强制鉴权,禁止匿名 `fetch` 直调 - AI/Tools 接口(如 `/api/ai/*``/api/tools/extract-script``/api/tools/analyze-creator``/api/tools/generate-topic-script`)现为强制鉴权,禁止匿名 `fetch` 直调
**使用方式:** **使用方式:**

View File

@@ -91,13 +91,12 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **到期续费**: 会员到期后登录自动跳转付费页续费,流程与首次开通一致。 - **到期续费**: 会员到期后登录自动跳转付费页续费,流程与首次开通一致。
- **管理员激活**: 管理员手动激活功能并存,两种方式互不影响。 - **管理员激活**: 管理员手动激活功能并存,两种方式互不影响。
### 9. 文案提取助手 (`ScriptExtractionModal`) ### 9. 文案创作助手3 个弹窗)
- **多源提取**: 支持文件拖拽上传与 URL 粘贴 (B站/抖音/TikTok) - **文案提取助手** (`ScriptExtractionModal`): 支持文件上传与 URL 提取(需登录),提取结果可一键填入主编辑器
- **AI 智能改写**: 集成 GLM-4.7-Flash自动改写为口播文案 - **AI 智能改写** (`RewriteModal`): 基于 GLM-4.7-Flash 改写文案,支持自定义提示词持久化
- **登录鉴权**: 依赖后端受保护接口(`/api/tools/extract-script`),未登录会触发全局 401 跳转登录。 - **文案深度学习** (`ScriptLearningModal`): 输入抖音/B站博主主页分析热门话题并生成口播文案登录
- **自定义提示词**: 可自定义改写提示词,留空使用默认;设置持久化到 localStorage - **统一结果操作栏**: 三个弹窗结果页统一底部 Action Grid 风格,主按钮为「填入文案」,次按钮统一「复制 / 重新生成(或等价返回操作)」
- **一键填入**: 提取结果直接填充至视频生成输入框 - **登录鉴权**: 依赖受保护接口(`/api/tools/*``/api/ai/*`),未登录会触发全局 401 跳转登录
- **智能交互**: 实时进度展示,防误触设计。
## 🛠️ 技术栈 ## 🛠️ 技术栈
@@ -161,6 +160,7 @@ src/
- 业务选择器统一使用 `SelectPopover`(桌面 Popover / 移动端 BottomSheet`ScriptEditor` 的“历史文案 / AI多语言”保留原轻量菜单。 - 业务选择器统一使用 `SelectPopover`(桌面 Popover / 移动端 BottomSheet`ScriptEditor` 的“历史文案 / AI多语言”保留原轻量菜单。
- 业务弹窗统一使用 `AppModal`(统一遮罩、头部、关闭行为与滚动策略)。 - 业务弹窗统一使用 `AppModal`(统一遮罩、头部、关闭行为与滚动策略)。
- 弹窗关闭策略:默认支持 `ESC` / `X` / 点击空白关闭;仅发布成功清理弹窗为强制流程(不允许空白关闭,也不显示 `X`)。 - 弹窗关闭策略:默认支持 `ESC` / `X` / 点击空白关闭;仅发布成功清理弹窗为强制流程(不允许空白关闭,也不显示 `X`)。
- 文案类弹窗结果页按钮统一:底部 Action Grid、主次按钮层级一致、文案动作命名一致填入/复制/重新生成)。
- 视频预览弹窗层级高于下拉菜单;下拉内支持连续预览。 - 视频预览弹窗层级高于下拉菜单;下拉内支持连续预览。
- 页面同时适配桌面端与移动端;长列表统一隐藏滚动条。 - 页面同时适配桌面端与移动端;长列表统一隐藏滚动条。
- 详细 UI 规范、持久化规范与交互约束请查看 `Docs/FRONTEND_DEV.md` - 详细 UI 规范、持久化规范与交互约束请查看 `Docs/FRONTEND_DEV.md`

View File

@@ -1,8 +1,8 @@
# ViGent2 开发任务清单 (Task Log) # ViGent2 开发任务清单 (Task Log)
**项目**: ViGent2 数字人口播视频生成系统 **项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 32 - 视频下载同源修复 + 安全整改第一批) **进度**: 100% (Day 33 - 文案深度学习落地 + 抓取稳定性增强 + 弹窗操作统一)
**更新时间**: 2026-03-04 **更新时间**: 2026-03-05
--- ---
@@ -10,7 +10,17 @@
> 这里记录了每一天的核心开发内容与 milestone。 > 这里记录了每一天的核心开发内容与 milestone。
### Day 32: 视频下载同源修复 + 安全整改第一批 + Day 日志拆分归档 (Current) ### Day 33: 文案深度学习落地 + 抓取稳定性增强 + 交互统一 (Current)
- [x] **文案深度学习功能上线**: 新增 `ScriptLearningModal`(输入主页链接 -> 话题分析 -> 生成文案 -> 填入编辑器)与首页入口接入。
- [x] **Tools 新接口**: 新增 `POST /api/tools/analyze-creator``POST /api/tools/generate-topic-script`,并接入登录鉴权。
- [x] **抖音/B站抓取增强**: 博主标题抓取统一升级为 Playwright 直连主链路,支持用户 Cookie 上下文增强与失败重试。
- [x] **GLM 调用统一收口**: `glm_service` 新增统一调用入口,标题生成/改写/翻译/话题分析/话题文案生成全部复用,减少重复代码。
- [x] **超时体验优化**: 文案深度学习“生成文案”前端超时从 30s 提升到 90s并补充超时提示文案。
- [x] **文案弹窗交互统一**: 文案提取/AI 改写/文案深度学习结果页按钮统一为底部 Action Grid主次按钮层级与文案动作统一。
- [x] **依赖升级**: 后端 venv 升级 `yt-dlp``playwright``biliup` 并完成兼容性冒烟验证。
- [x] **文档同步**: 回写 `Day33``FRONTEND_README``FRONTEND_DEV``BACKEND_README``BACKEND_DEV``TASK_COMPLETE`
### Day 32: 视频下载同源修复 + 安全整改第一批 + Day 日志拆分归档
- [x] **下载链路修复**: 新增 `GET /api/videos/generated/{video_id}/download`,统一返回 `Content-Disposition: attachment`,修复“点击下载却新开标签页播放”问题。 - [x] **下载链路修复**: 新增 `GET /api/videos/generated/{video_id}/download`,统一返回 `Content-Disposition: attachment`,修复“点击下载却新开标签页播放”问题。
- [x] **发布成功弹窗下载改造**: `CleanupContext` 从传 URL 改为传 `videoId`,下载按钮改走同源接口,去掉 `target="_blank"` - [x] **发布成功弹窗下载改造**: `CleanupContext` 从传 URL 改为传 `videoId`,下载按钮改走同源接口,去掉 `target="_blank"`
- [x] **首页下载改造**: `PreviewPanel` 同步切换到同源下载接口,首页与发布页下载行为一致。 - [x] **首页下载改造**: `PreviewPanel` 同步切换到同源下载接口,首页与发布页下载行为一致。

View File

@@ -28,7 +28,7 @@
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置;新作品生成后优先选中最新,后续用户手动选择持续持久化。 - 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置;新作品生成后优先选中最新,后续用户手动选择持续持久化。
- 🎵 **背景音乐** - 试听 + 搜索选择 + 混音(当前前端固定混音系数,保持配音音量稳定)。 - 🎵 **背景音乐** - 试听 + 搜索选择 + 混音(当前前端固定混音系数,保持配音音量稳定)。
- 🧩 **统一选择器交互** - 首页/发布页业务选择项统一 SelectPopover桌面 Popover / 移动端 BottomSheet支持自动上拉、已选定位与连续预览。 - 🧩 **统一选择器交互** - 首页/发布页业务选择项统一 SelectPopover桌面 Popover / 移动端 BottomSheet支持自动上拉、已选定位与连续预览。
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash支持 B站/抖音链接文案提取、AI 智能改写(支持自定义提示词)、标题/标签自动生成、9 语言翻译。 - 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash支持 B站/抖音链接文案提取、AI 智能改写(支持自定义提示词)、文案深度学习(博主话题分析+文案生成)、标题/标签自动生成、9 语言翻译。
### 平台化功能 ### 平台化功能
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。 - 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。

View File

@@ -1,15 +1,47 @@
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from typing import Optional from typing import Optional
from urllib.parse import urlparse
import traceback import traceback
from loguru import logger from loguru import logger
from pydantic import BaseModel, Field, field_validator
from app.core.deps import get_current_user from app.core.deps import get_current_user
from app.core.response import success_response from app.core.response import success_response
from app.modules.tools import service from app.modules.tools import service
from app.services import creator_scraper
from app.services.creator_scraper import ALLOWED_INPUT_DOMAINS
from app.services.glm_service import glm_service
router = APIRouter() router = APIRouter()
class AnalyzeCreatorRequest(BaseModel):
url: str = Field(..., description="博主主页链接(仅支持抖音/B站 https 链接)")
@field_validator("url")
@classmethod
def validate_url_format(cls, value: str) -> str:
candidate = value.strip()
if len(candidate) > 500:
raise ValueError("链接过长")
parsed = urlparse(candidate)
if parsed.scheme != "https":
raise ValueError("仅支持 https 链接")
hostname = (parsed.hostname or "").lower()
if hostname not in ALLOWED_INPUT_DOMAINS:
raise ValueError(f"不支持的域名: {hostname}仅支持抖音和B站")
return candidate
class GenerateTopicScriptRequest(BaseModel):
analysis_id: str = Field(..., min_length=8, max_length=80, description="分析结果ID")
topic: str = Field(..., min_length=2, max_length=30, description="选中的话题2-30字")
word_count: int = Field(..., ge=80, le=1000, description="目标字数80-1000")
@router.post("/extract-script") @router.post("/extract-script")
async def extract_script_tool( async def extract_script_tool(
file: Optional[UploadFile] = File(None), file: Optional[UploadFile] = File(None),
@@ -33,3 +65,62 @@ async def extract_script_tool(
if "Fresh cookies" in msg: if "Fresh cookies" in msg:
raise HTTPException(500, "下载失败:目标平台开启了反爬验证,请过段时间重试或直接上传视频文件。") raise HTTPException(500, "下载失败:目标平台开启了反爬验证,请过段时间重试或直接上传视频文件。")
raise HTTPException(500, "文案提取失败,请稍后重试") raise HTTPException(500, "文案提取失败,请稍后重试")
@router.post("/analyze-creator")
async def analyze_creator(
req: AnalyzeCreatorRequest,
current_user: dict = Depends(get_current_user),
):
"""分析博主内容并返回热门话题"""
try:
user_id = str(current_user.get("id") or "").strip()
if not user_id:
raise HTTPException(401, "登录状态无效,请重新登录")
creator_result = await creator_scraper.scrape_creator_titles(req.url, user_id=user_id)
titles = creator_result.get("titles") or []
topics = await glm_service.analyze_topics(titles)
analysis_id = creator_scraper.cache_titles(titles, user_id)
return success_response({
"platform": creator_result.get("platform", ""),
"creator_name": creator_result.get("creator_name", ""),
"topics": topics,
"analysis_id": analysis_id,
"fetched_count": creator_result.get("fetched_count", len(titles)),
})
except ValueError as e:
raise HTTPException(400, str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Analyze creator failed: {e}")
logger.error(traceback.format_exc())
raise HTTPException(500, "博主内容分析失败,请稍后重试")
@router.post("/generate-topic-script")
async def generate_topic_script(
req: GenerateTopicScriptRequest,
current_user: dict = Depends(get_current_user),
):
"""根据话题生成文案"""
try:
user_id = str(current_user.get("id") or "").strip()
if not user_id:
raise HTTPException(401, "登录状态无效,请重新登录")
titles = creator_scraper.get_cached_titles(req.analysis_id, user_id)
script = await glm_service.generate_script_from_topic(req.topic, req.word_count, titles)
return success_response({"script": script})
except ValueError as e:
raise HTTPException(400, str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Generate topic script failed: {e}")
logger.error(traceback.format_exc())
raise HTTPException(500, "文案生成失败,请稍后重试")

View File

@@ -8,7 +8,7 @@ import subprocess
import traceback import traceback
from pathlib import Path from pathlib import Path
from typing import Optional, Any from typing import Optional, Any
from urllib.parse import unquote from urllib.parse import unquote, parse_qs, urlparse
import httpx import httpx
from loguru import logger from loguru import logger
@@ -212,10 +212,9 @@ async def _download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> O
logger.info(f"[douyin-fallback] Final URL: {final_url}") logger.info(f"[douyin-fallback] Final URL: {final_url}")
video_id = None video_id = _extract_douyin_video_id(final_url)
match = re.search(r'/video/(\d+)', final_url) if not video_id:
if match: video_id = _extract_douyin_video_id(url)
video_id = match.group(1)
if not video_id: if not video_id:
logger.error("[douyin-fallback] Could not extract video_id") logger.error("[douyin-fallback] Could not extract video_id")
@@ -236,7 +235,8 @@ async def _download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> O
"cbUrlProtocol": "https", "union": True, "cbUrlProtocol": "https", "union": True,
} }
) )
ttwid = ttwid_resp.cookies.get("ttwid", "") fresh_ttwid = ttwid_resp.cookies.get("ttwid")
ttwid = str(fresh_ttwid) if fresh_ttwid else ""
logger.info(f"[douyin-fallback] Got fresh ttwid (len={len(ttwid)})") logger.info(f"[douyin-fallback] Got fresh ttwid (len={len(ttwid)})")
except Exception as e: except Exception as e:
logger.warning(f"[douyin-fallback] Failed to get ttwid: {e}") logger.warning(f"[douyin-fallback] Failed to get ttwid: {e}")
@@ -296,6 +296,39 @@ async def _download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> O
return None return None
def _extract_douyin_video_id(candidate_url: str) -> Optional[str]:
"""从抖音 URL 中提取视频 ID兼容 video/share/video/modal_id/vid 等形态"""
if not candidate_url:
return None
decoded_url = unquote(candidate_url)
parsed = urlparse(decoded_url)
for source in (decoded_url, parsed.path):
for pattern in (r"/video/(\d+)", r"/share/video/(\d+)"):
match = re.search(pattern, source)
if match:
return match.group(1)
id_keys = ("modal_id", "vid", "video_id", "aweme_id", "item_id")
for pairs in (parse_qs(parsed.query), parse_qs(parsed.fragment)):
for key in id_keys:
values = pairs.get(key, [])
for value in values:
match = re.search(r"(\d+)", value)
if match:
return match.group(1)
inline_match = re.search(
r"(?:[?&#](?:modal_id|vid|video_id|aweme_id|item_id)=)(\d+)",
decoded_url,
)
if inline_match:
return inline_match.group(1)
return None
async def _download_bilibili_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]: async def _download_bilibili_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]:
"""手动下载 Bilibili 视频 (Playwright Fallback)""" """手动下载 Bilibili 视频 (Playwright Fallback)"""
from playwright.async_api import async_playwright from playwright.async_api import async_playwright

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,10 @@ GLM AI 服务
使用智谱 GLM 生成标题和标签 使用智谱 GLM 生成标题和标签
""" """
import asyncio
import json import json
import re import re
from typing import Any, Optional, cast
from loguru import logger from loguru import logger
from zai import ZhipuAiClient from zai import ZhipuAiClient
@@ -25,6 +27,48 @@ class GLMService:
self.client = ZhipuAiClient(api_key=settings.GLM_API_KEY) self.client = ZhipuAiClient(api_key=settings.GLM_API_KEY)
return self.client return self.client
async def _call_glm(
self,
*,
prompt: str,
max_tokens: int,
temperature: float,
action: str,
timeout_seconds: float = 85.0,
) -> str:
"""统一 GLM 调用入口,避免重复调用代码"""
client = self._get_client()
logger.info(
f"{action} | model={settings.GLM_MODEL} | max_tokens={max_tokens} | temperature={temperature}"
)
try:
response = await asyncio.wait_for(
asyncio.to_thread(
client.chat.completions.create,
model=settings.GLM_MODEL,
messages=[{"role": "user", "content": prompt}],
thinking={"type": "disabled"},
max_tokens=max_tokens,
temperature=temperature,
),
timeout=timeout_seconds,
)
except asyncio.TimeoutError as exc:
raise Exception("GLM 请求超时,请稍后重试") from exc
completion = cast(Any, response)
choices = getattr(completion, "choices", None)
if not choices:
raise Exception("AI 返回内容为空")
message = getattr(choices[0], "message", None)
content = getattr(message, "content", "")
text = content.strip() if isinstance(content, str) else str(content or "").strip()
if not text:
raise Exception("AI 返回内容为空")
return text
async def generate_title_tags(self, text: str) -> dict: async def generate_title_tags(self, text: str) -> dict:
""" """
根据口播文案生成标题和标签 根据口播文案生成标题和标签
@@ -50,22 +94,13 @@ class GLMService:
{{"title": "标题", "secondary_title": "副标题", "tags": ["标签1", "标签2", "标签3"]}}""" {{"title": "标题", "secondary_title": "副标题", "tags": ["标签1", "标签2", "标签3"]}}"""
try: try:
client = self._get_client() content = await self._call_glm(
logger.info(f"Calling GLM API with model: {settings.GLM_MODEL}") prompt=prompt,
# 使用 asyncio.to_thread 包装同步 SDK 调用,避免阻塞事件循环
import asyncio
response = await asyncio.to_thread(
client.chat.completions.create,
model=settings.GLM_MODEL,
messages=[{"role": "user", "content": prompt}],
thinking={"type": "disabled"}, # 禁用思考模式,加快响应
max_tokens=500, max_tokens=500,
temperature=0.7 temperature=0.7,
action="生成标题与标签",
timeout_seconds=75.0,
) )
# 提取生成的内容
content = response.choices[0].message.content
logger.info(f"GLM response (model: {settings.GLM_MODEL}): {content}") logger.info(f"GLM response (model: {settings.GLM_MODEL}): {content}")
# 解析 JSON # 解析 JSON
@@ -76,7 +111,7 @@ class GLMService:
logger.error(f"GLM service error: {e}") logger.error(f"GLM service error: {e}")
raise Exception(f"AI 生成失败: {str(e)}") raise Exception(f"AI 生成失败: {str(e)}")
async def rewrite_script(self, text: str, custom_prompt: str = None) -> str: async def rewrite_script(self, text: str, custom_prompt: Optional[str] = None) -> str:
""" """
AI 改写文案 AI 改写文案
@@ -105,28 +140,126 @@ class GLMService:
4. 不要返回多余的解释,只返回改写后的正文""" 4. 不要返回多余的解释,只返回改写后的正文"""
try: try:
client = self._get_client() content = await self._call_glm(
logger.info(f"Using GLM to rewrite script") prompt=prompt,
# 使用 asyncio.to_thread 包装同步 SDK 调用,避免阻塞事件循环
import asyncio
response = await asyncio.to_thread(
client.chat.completions.create,
model=settings.GLM_MODEL,
messages=[{"role": "user", "content": prompt}],
thinking={"type": "disabled"},
max_tokens=2000, max_tokens=2000,
temperature=0.8 temperature=0.8,
action="改写文案",
timeout_seconds=85.0,
) )
content = response.choices[0].message.content
logger.info("GLM rewrite completed") logger.info("GLM rewrite completed")
return content.strip() return content
except Exception as e: except Exception as e:
logger.error(f"GLM rewrite error: {e}") logger.error(f"GLM rewrite error: {e}")
raise Exception(f"AI 改写失败: {str(e)}") raise Exception(f"AI 改写失败: {str(e)}")
async def analyze_topics(self, titles: list[str]) -> list[str]:
"""
分析视频标题列表并归纳热门话题(最多 10 个)
"""
cleaned_titles = [str(title).strip() for title in titles if str(title).strip()]
if not cleaned_titles:
raise Exception("标题列表为空")
limited_titles = cleaned_titles[:50]
titles_text = "\n".join(f"{idx + 1}. {title}" for idx, title in enumerate(limited_titles))
prompt = f"""以下是某短视频博主最近发布的视频标题列表:
{titles_text}
请分析这些标题,归纳总结出该博主内容中最热门的话题方向。
要求:
1. 提取不超过10个话题方向
2. 每个话题用简短短语描述(建议 5-15 字)
3. 按热门程度排序(出现频率高的在前)
4. 只返回话题列表,每行一个,不要编号、解释或多余内容"""
try:
content = await self._call_glm(
prompt=prompt,
max_tokens=500,
temperature=0.5,
action="分析博主话题",
timeout_seconds=85.0,
)
topics = self._parse_topic_lines(content)
if not topics:
raise Exception("未识别到有效话题")
logger.info(f"GLM topic analysis completed: {len(topics)} topics")
return topics[:10]
except Exception as e:
logger.error(f"GLM topic analysis error: {e}")
raise Exception(f"话题分析失败: {str(e)}")
async def generate_script_from_topic(self, topic: str, word_count: int, titles: list[str]) -> str:
"""
根据选中话题与博主标题风格生成文案
"""
topic_value = str(topic or "").strip()
if not topic_value:
raise Exception("话题不能为空")
cleaned_titles = [str(title).strip() for title in titles if str(title).strip()]
if not cleaned_titles:
raise Exception("参考标题为空")
word_count_value = max(80, min(int(word_count), 1000))
sample_titles = "\n".join(f"{idx + 1}. {title}" for idx, title in enumerate(cleaned_titles[:10]))
prompt = f"""请围绕「{topic_value}」这个话题,生成一段短视频口播文案。
参考该博主的标题风格:
{sample_titles}
要求:
1. 文案字数约 {word_count_value}
2. 适合短视频口播,语气自然、有吸引力
3. 开头要有钩子吸引观众
4. 只返回文案正文,不要标题和其他说明"""
try:
content = await self._call_glm(
prompt=prompt,
max_tokens=min(word_count_value * 3, 4000),
temperature=0.8,
action=f"按话题生成文案(topic={topic_value})",
timeout_seconds=88.0,
)
logger.info("GLM topic script generation completed")
return content
except Exception as e:
logger.error(f"GLM topic script generation error: {e}")
raise Exception(f"文案生成失败: {str(e)}")
def _parse_topic_lines(self, content: str) -> list[str]:
lines = [line.strip() for line in str(content or "").splitlines()]
topics: list[str] = []
seen: set[str] = set()
for line in lines:
if not line:
continue
cleaned = re.sub(r"^\s*(?:[-*•]+|\d+[.)、\s]+)", "", line).strip()
cleaned = cleaned.strip('"“”')
if not cleaned:
continue
if cleaned in seen:
continue
seen.add(cleaned)
topics.append(cleaned)
if len(topics) >= 10:
break
return topics
async def translate_text(self, text: str, target_lang: str) -> str: async def translate_text(self, text: str, target_lang: str) -> str:
@@ -151,22 +284,15 @@ class GLMService:
3. 翻译要自然流畅,符合目标语言的表达习惯""" 3. 翻译要自然流畅,符合目标语言的表达习惯"""
try: try:
client = self._get_client() content = await self._call_glm(
logger.info(f"Using GLM to translate text to {target_lang}") prompt=prompt,
import asyncio
response = await asyncio.to_thread(
client.chat.completions.create,
model=settings.GLM_MODEL,
messages=[{"role": "user", "content": prompt}],
thinking={"type": "disabled"},
max_tokens=2000, max_tokens=2000,
temperature=0.3 temperature=0.3,
action=f"翻译文案(target={target_lang})",
timeout_seconds=75.0,
) )
content = response.choices[0].message.content
logger.info("GLM translation completed") logger.info("GLM translation completed")
return content.strip() return content
except Exception as e: except Exception as e:
logger.error(f"GLM translate error: {e}") logger.error(f"GLM translate error: {e}")

View File

@@ -287,6 +287,9 @@ export const useHomeController = () => {
// 文案提取模态框 // 文案提取模态框
const [extractModalOpen, setExtractModalOpen] = useState(false); const [extractModalOpen, setExtractModalOpen] = useState(false);
// 文案深度学习模态框
const [learningModalOpen, setLearningModalOpen] = useState(false);
// AI 改写模态框 // AI 改写模态框
const [rewriteModalOpen, setRewriteModalOpen] = useState(false); const [rewriteModalOpen, setRewriteModalOpen] = useState(false);
@@ -1123,6 +1126,8 @@ export const useHomeController = () => {
setText, setText,
extractModalOpen, extractModalOpen,
setExtractModalOpen, setExtractModalOpen,
learningModalOpen,
setLearningModalOpen,
rewriteModalOpen, rewriteModalOpen,
setRewriteModalOpen, setRewriteModalOpen,
handleGenerateMeta, handleGenerateMeta,

View File

@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import VideoPreviewModal from "@/components/VideoPreviewModal"; import VideoPreviewModal from "@/components/VideoPreviewModal";
import ScriptExtractionModal from "./ScriptExtractionModal"; import ScriptExtractionModal from "./ScriptExtractionModal";
import ScriptLearningModal from "./ScriptLearningModal";
import RewriteModal from "./RewriteModal"; import RewriteModal from "./RewriteModal";
import { useHomeController } from "@/features/home/model/useHomeController"; import { useHomeController } from "@/features/home/model/useHomeController";
import { resolveMediaUrl } from "@/shared/lib/media"; import { resolveMediaUrl } from "@/shared/lib/media";
@@ -53,6 +54,8 @@ export function HomePage() {
setText, setText,
extractModalOpen, extractModalOpen,
setExtractModalOpen, setExtractModalOpen,
learningModalOpen,
setLearningModalOpen,
rewriteModalOpen, rewriteModalOpen,
setRewriteModalOpen, setRewriteModalOpen,
handleGenerateMeta, handleGenerateMeta,
@@ -222,6 +225,7 @@ export function HomePage() {
text={text} text={text}
onChangeText={setText} onChangeText={setText}
onOpenExtractModal={() => setExtractModalOpen(true)} onOpenExtractModal={() => setExtractModalOpen(true)}
onOpenLearningModal={() => setLearningModalOpen(true)}
onOpenRewriteModal={() => setRewriteModalOpen(true)} onOpenRewriteModal={() => setRewriteModalOpen(true)}
onTranslate={handleTranslate} onTranslate={handleTranslate}
isTranslating={isTranslating} isTranslating={isTranslating}
@@ -514,6 +518,12 @@ export function HomePage() {
onApply={(newText) => setText(newText)} onApply={(newText) => setText(newText)}
/> />
<ScriptLearningModal
isOpen={learningModalOpen}
onClose={() => setLearningModalOpen(false)}
onApply={(nextText) => setText(nextText)}
/>
<ClipTrimmer <ClipTrimmer
isOpen={clipTrimmerOpen} isOpen={clipTrimmerOpen}
segment={clipTrimmerSegment} segment={clipTrimmerSegment}

View File

@@ -3,6 +3,7 @@ import { Loader2, Sparkles } from "lucide-react";
import api from "@/shared/api/axios"; import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types"; import { ApiResponse, unwrap } from "@/shared/api/types";
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal"; import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
import { toast } from "sonner";
const CUSTOM_PROMPT_KEY = "vigent_rewriteCustomPrompt"; const CUSTOM_PROMPT_KEY = "vigent_rewriteCustomPrompt";
@@ -78,10 +79,52 @@ export default function RewriteModal({
onClose(); onClose();
}; };
const handleRetry = () => { const handleRetry = () => {
setRewrittenText(""); setRewrittenText("");
setError(null); setError(null);
}; };
const fallbackCopyTextToClipboard = useCallback((text: string) => {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand("copy");
if (successful) {
toast.success("已复制到剪贴板");
} else {
toast.error("复制失败,请手动复制");
}
} catch {
toast.error("复制失败,请手动复制");
}
document.body.removeChild(textArea);
}, []);
const handleCopy = useCallback((text: string) => {
if (!text.trim()) return;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(text)
.then(() => {
toast.success("已复制到剪贴板");
})
.catch(() => {
fallbackCopyTextToClipboard(text);
});
} else {
fallbackCopyTextToClipboard(text);
}
}, [fallbackCopyTextToClipboard]);
if (!isOpen) return null; if (!isOpen) return null;
@@ -143,56 +186,63 @@ export default function RewriteModal({
</div> </div>
)} )}
{/* Rewritten result */} {/* Rewritten result */}
{rewrittenText && ( {rewrittenText && (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h4 className="font-semibold text-purple-300 flex items-center gap-2"> <h4 className="font-semibold text-purple-300 flex items-center gap-2">
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
AI AI
</h4> </h4>
<button <span className="text-xs text-gray-400">{rewrittenText.length} </span>
onClick={handleApply} </div>
className="text-xs bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white px-3 py-1.5 rounded-lg transition-colors shadow-sm" <div className="bg-purple-900/10 border border-purple-500/20 rounded-xl p-4 max-h-60 overflow-y-auto hide-scrollbar">
> <p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
使 {rewrittenText}
</button> </p>
</div> </div>
<div className="bg-purple-900/10 border border-purple-500/20 rounded-xl p-4 max-h-60 overflow-y-auto hide-scrollbar"> </div>
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
{rewrittenText} <div className="space-y-2">
</p> <h4 className="font-semibold text-gray-400 flex items-center gap-2">
</div> 📝
</div> </h4>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-40 overflow-y-auto hide-scrollbar">
<div className="space-y-2"> <p className="text-gray-400 text-sm leading-relaxed whitespace-pre-wrap">
<div className="flex justify-between items-center"> {originalText}
<h4 className="font-semibold text-gray-400 flex items-center gap-2"> </p>
📝 </div>
</h4> </div>
<button
onClick={onClose} <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors" <button
> onClick={handleApply}
className="py-2.5 px-3 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white rounded-lg transition-colors text-sm"
</button> >
</div>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-40 overflow-y-auto hide-scrollbar"> </button>
<p className="text-gray-400 text-sm leading-relaxed whitespace-pre-wrap"> <button
{originalText} onClick={() => handleCopy(rewrittenText)}
</p> className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
</div> >
</div>
</button>
<button <button
onClick={handleRetry} onClick={handleRetry}
className="w-full py-2.5 px-4 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-colors" className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
> >
</button> </button>
</> <button
)} onClick={onClose}
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
</div>
</>
)}
</div> </div>
</AppModal> </AppModal>
); );

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { FileText, History, Languages, Loader2, Maximize2, RotateCcw, Save, Sparkles, Trash2 } from "lucide-react"; import { FileText, GraduationCap, History, Languages, Loader2, Maximize2, RotateCcw, Save, Sparkles, Trash2 } from "lucide-react";
import type { SavedScript } from "@/features/home/model/useSavedScripts"; import type { SavedScript } from "@/features/home/model/useSavedScripts";
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal"; import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
@@ -19,6 +19,7 @@ interface ScriptEditorProps {
text: string; text: string;
onChangeText: (value: string) => void; onChangeText: (value: string) => void;
onOpenExtractModal: () => void; onOpenExtractModal: () => void;
onOpenLearningModal: () => void;
onOpenRewriteModal: () => void; onOpenRewriteModal: () => void;
onTranslate: (targetLang: string) => void; onTranslate: (targetLang: string) => void;
isTranslating: boolean; isTranslating: boolean;
@@ -34,6 +35,7 @@ export function ScriptEditor({
text, text,
onChangeText, onChangeText,
onOpenExtractModal, onOpenExtractModal,
onOpenLearningModal,
onOpenRewriteModal, onOpenRewriteModal,
onTranslate, onTranslate,
isTranslating, isTranslating,
@@ -146,6 +148,13 @@ export function ScriptEditor({
<FileText className="h-3.5 w-3.5" /> <FileText className="h-3.5 w-3.5" />
</button> </button>
<button
onClick={onOpenLearningModal}
className={`${actionBtnBase} bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 text-white`}
>
<GraduationCap className="h-3.5 w-3.5" />
</button>
<div className="relative" ref={langMenuRef}> <div className="relative" ref={langMenuRef}>
<button <button
onClick={() => setShowLangMenu((prev) => !prev)} onClick={() => setShowLangMenu((prev) => !prev)}

View File

@@ -228,43 +228,47 @@ export default function ScriptExtractionModal({
)} )}
{step === "result" && ( {step === "result" && (
<div className="space-y-6"> <div className="space-y-5">
<div className="space-y-2"> <div className="flex justify-between items-center">
<div className="flex justify-between items-center"> <h4 className="font-semibold text-gray-300 flex items-center gap-2">
<h4 className="font-semibold text-gray-300 flex items-center gap-2"> 🎙
🎙 </h4>
</h4> <span className="text-xs text-gray-400">{script.length} </span>
<div className="flex items-center gap-2">
{onApply && (
<button
onClick={() => handleApplyAndClose(script)}
className="text-xs bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1 shadow-sm"
>
📥
</button>
)}
<button
onClick={() => copyToClipboard(script)}
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors"
>
</button>
</div>
</div>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-60 overflow-y-auto hide-scrollbar">
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
{script}
</p>
</div>
</div> </div>
<div className="flex justify-center pt-4"> <div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-72 overflow-y-auto hide-scrollbar">
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
{script}
</p>
</div>
<div className={`grid ${onApply ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-2 sm:grid-cols-3"} gap-2`}>
{onApply && (
<button
onClick={() => handleApplyAndClose(script)}
className="py-2.5 px-3 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white rounded-lg transition-colors text-sm"
>
</button>
)}
<button
onClick={() => copyToClipboard(script)}
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
<button <button
onClick={handleExtractNext} onClick={handleExtractNext}
className="px-6 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors" className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
> >
</button> </button>
<button
onClick={onClose}
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
</div> </div>
</div> </div>
)} )}

View File

@@ -0,0 +1,241 @@
"use client";
import { BookOpen, Sparkles } from "lucide-react";
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
import { useScriptLearning } from "./script-learning/useScriptLearning";
interface ScriptLearningModalProps {
isOpen: boolean;
onClose: () => void;
onApply?: (text: string) => void;
}
const WORD_COUNT_MIN = 80;
const WORD_COUNT_MAX = 1000;
export default function ScriptLearningModal({ isOpen, onClose, onApply }: ScriptLearningModalProps) {
const {
step,
inputUrl,
setInputUrl,
topics,
selectedTopic,
setSelectedTopic,
wordCount,
setWordCount,
generatedScript,
error,
analysisId,
handleAnalyze,
handleGenerate,
handleRegenerate,
backToInput,
backToTopics,
copyToClipboard,
} = useScriptLearning({ isOpen });
if (!isOpen) return null;
const wordCountNum = Number(wordCount);
const wordCountValid = Number.isInteger(wordCountNum)
&& wordCountNum >= WORD_COUNT_MIN
&& wordCountNum <= WORD_COUNT_MAX;
const canGenerate = !!analysisId && !!selectedTopic && wordCountValid;
const handleApplyAndClose = () => {
if (!generatedScript.trim()) return;
onApply?.(generatedScript);
onClose();
};
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
panelClassName="w-full max-w-2xl max-h-[90vh] rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden flex flex-col"
closeOnOverlay
>
<AppModalHeader
title="文案深度学习"
icon={<BookOpen className="h-5 w-5 text-cyan-300" />}
subtitle="分析博主近期选题风格并快速生成文案"
onClose={onClose}
/>
<div className="flex-1 overflow-y-auto p-6">
{step === "input" && (
<div className="space-y-5">
<div className="space-y-2">
<label className="text-sm text-gray-300"></label>
<input
type="text"
value={inputUrl}
onChange={(event) => setInputUrl(event.target.value)}
placeholder="请粘贴抖音或B站博主主页链接..."
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-cyan-500 transition-colors"
/>
<p className="text-xs text-gray-500"> https 使</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
<div className="flex gap-3 pt-1">
<button
type="button"
onClick={onClose}
className="flex-1 py-3 px-4 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-colors"
>
</button>
<button
type="button"
onClick={() => void handleAnalyze()}
disabled={!inputUrl.trim()}
className="flex-1 py-3 px-4 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl transition-all font-medium shadow-lg"
>
</button>
</div>
</div>
)}
{(step === "analyzing" || step === "generating") && (
<div className="flex flex-col items-center justify-center py-20">
<div className="relative w-20 h-20 mb-6">
<div className="absolute inset-0 border-4 border-cyan-500/30 rounded-full" />
<div className="absolute inset-0 border-4 border-t-cyan-500 rounded-full animate-spin" />
</div>
<h4 className="text-xl font-medium text-white mb-2">
{step === "analyzing" ? "正在分析中..." : "正在生成中..."}
</h4>
</div>
)}
{step === "topics" && (
<div className="space-y-5">
<div className="bg-cyan-500/10 border border-cyan-500/30 rounded-xl p-3">
<p className="text-cyan-200 text-sm"></p>
</div>
<div className="space-y-2">
<p className="text-sm text-gray-300"></p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{topics.map((topic) => {
const active = selectedTopic === topic;
return (
<button
key={topic}
type="button"
onClick={() => setSelectedTopic(topic)}
className={`text-left rounded-lg border px-3 py-2.5 text-sm transition-colors ${
active
? "border-cyan-400 bg-cyan-500/20 text-cyan-100"
: "border-white/10 bg-white/5 text-gray-200 hover:border-white/20 hover:bg-white/10"
}`}
>
{topic}
</button>
);
})}
</div>
</div>
<div className="space-y-2">
<label className="text-sm text-gray-300"></label>
<input
type="number"
min={WORD_COUNT_MIN}
max={WORD_COUNT_MAX}
value={wordCount}
onChange={(event) => setWordCount(event.target.value)}
placeholder="请输入目标字数80-1000如 300"
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-cyan-500 transition-colors"
/>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
<div className="flex gap-3 pt-1">
<button
type="button"
onClick={backToInput}
className="flex-1 py-3 px-4 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-colors"
>
</button>
<button
type="button"
onClick={() => void handleGenerate()}
disabled={!canGenerate}
className="flex-1 py-3 px-4 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl transition-all font-medium shadow-lg"
>
</button>
</div>
</div>
)}
{step === "result" && (
<div className="space-y-5">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-cyan-200 flex items-center gap-2">
<Sparkles className="h-4 w-4" />
</h4>
<span className="text-xs text-gray-400">{generatedScript.length} </span>
</div>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-72 overflow-y-auto hide-scrollbar">
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">{generatedScript}</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<button
type="button"
onClick={handleApplyAndClose}
className="py-2.5 px-3 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 text-white rounded-lg transition-colors text-sm"
>
</button>
<button
type="button"
onClick={() => copyToClipboard(generatedScript)}
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
<button
type="button"
onClick={() => void handleRegenerate()}
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
<button
type="button"
onClick={backToTopics}
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
</div>
)}
</div>
</AppModal>
);
}

View File

@@ -0,0 +1,239 @@
import { useCallback, useEffect, useState } from "react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
export type ScriptLearningStep = "input" | "analyzing" | "topics" | "generating" | "result";
const WORD_COUNT_MIN = 80;
const WORD_COUNT_MAX = 1000;
const DEFAULT_WORD_COUNT = "300";
interface UseScriptLearningOptions {
isOpen: boolean;
}
interface AnalyzeCreatorPayload {
topics: string[];
analysis_id: string;
fetched_count: number;
}
interface GenerateTopicScriptPayload {
script: string;
}
export const useScriptLearning = ({ isOpen }: UseScriptLearningOptions) => {
const [step, setStep] = useState<ScriptLearningStep>("input");
const [inputUrl, setInputUrl] = useState("");
const [topics, setTopics] = useState<string[]>([]);
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
const [wordCount, setWordCount] = useState(DEFAULT_WORD_COUNT);
const [generatedScript, setGeneratedScript] = useState("");
const [error, setError] = useState<string | null>(null);
const [analysisId, setAnalysisId] = useState<string | null>(null);
const [fetchedCount, setFetchedCount] = useState(0);
const resetAll = useCallback(() => {
setStep("input");
setInputUrl("");
setTopics([]);
setSelectedTopic(null);
setWordCount(DEFAULT_WORD_COUNT);
setGeneratedScript("");
setError(null);
setAnalysisId(null);
setFetchedCount(0);
}, []);
useEffect(() => {
if (isOpen) {
resetAll();
}
}, [isOpen, resetAll]);
const parseWordCount = useCallback((value: string): number | null => {
const num = Number(value);
if (!Number.isInteger(num)) {
return null;
}
if (num < WORD_COUNT_MIN || num > WORD_COUNT_MAX) {
return null;
}
return num;
}, []);
const handleAnalyze = useCallback(async () => {
const urlValue = inputUrl.trim();
if (!urlValue) {
setError("请先输入博主主页链接");
return;
}
setError(null);
setStep("analyzing");
try {
const { data: res } = await api.post<ApiResponse<AnalyzeCreatorPayload>>(
"/api/tools/analyze-creator",
{ url: urlValue },
{ timeout: 60000 }
);
const payload = unwrap(res);
const topicList = payload.topics || [];
if (topicList.length === 0) {
throw new Error("未识别到可用话题,请更换链接重试");
}
setTopics(topicList);
setSelectedTopic(topicList[0]);
setAnalysisId(payload.analysis_id || null);
setFetchedCount(payload.fetched_count || 0);
setGeneratedScript("");
setStep("topics");
} catch (err: unknown) {
const axiosErr = err as {
response?: { data?: { message?: string } };
message?: string;
};
const msg = axiosErr.response?.data?.message || axiosErr.message || "分析失败,请稍后重试";
setError(msg);
setStep("input");
}
}, [inputUrl]);
const handleGenerate = useCallback(async () => {
if (!analysisId) {
setError("分析结果已失效,请重新分析");
setStep("input");
return;
}
if (!selectedTopic) {
setError("请先选择一个话题");
return;
}
const count = parseWordCount(wordCount.trim());
if (count === null) {
setError(`目标字数需在 ${WORD_COUNT_MIN}-${WORD_COUNT_MAX} 之间`);
return;
}
setError(null);
setStep("generating");
try {
const { data: res } = await api.post<ApiResponse<GenerateTopicScriptPayload>>(
"/api/tools/generate-topic-script",
{
analysis_id: analysisId,
topic: selectedTopic,
word_count: count,
},
{ timeout: 90000 }
);
const payload = unwrap(res);
const script = (payload.script || "").trim();
if (!script) {
throw new Error("生成内容为空,请重试");
}
setGeneratedScript(script);
setStep("result");
} catch (err: unknown) {
const axiosErr = err as {
response?: { data?: { message?: string } };
message?: string;
code?: string;
};
let msg = axiosErr.response?.data?.message || axiosErr.message || "生成失败,请稍后重试";
if (axiosErr.code === "ECONNABORTED" || /timeout/i.test(axiosErr.message || "")) {
msg = "生成超时,请稍后重试(可适当减少目标字数)";
}
setError(msg);
setStep("topics");
}
}, [analysisId, parseWordCount, selectedTopic, wordCount]);
const handleRegenerate = useCallback(async () => {
await handleGenerate();
}, [handleGenerate]);
const backToInput = useCallback(() => {
setError(null);
setStep("input");
}, []);
const backToTopics = useCallback(() => {
setError(null);
setStep("topics");
}, []);
const fallbackCopyTextToClipboard = useCallback((text: string) => {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand("copy");
if (successful) {
toast.success("已复制到剪贴板");
} else {
toast.error("复制失败,请手动复制");
}
} catch {
toast.error("复制失败,请手动复制");
}
document.body.removeChild(textArea);
}, []);
const copyToClipboard = useCallback(
(text: string) => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(text)
.then(() => {
toast.success("已复制到剪贴板");
})
.catch(() => {
fallbackCopyTextToClipboard(text);
});
} else {
fallbackCopyTextToClipboard(text);
}
},
[fallbackCopyTextToClipboard]
);
return {
step,
inputUrl,
setInputUrl,
topics,
selectedTopic,
setSelectedTopic,
wordCount,
setWordCount,
generatedScript,
error,
analysisId,
fetchedCount,
handleAnalyze,
handleGenerate,
handleRegenerate,
backToInput,
backToTopics,
resetAll,
copyToClipboard,
};
};