Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b289006844 |
@@ -161,6 +161,8 @@ backend/user_data/{user_uuid}/cookies/
|
||||
- 业务逻辑写在 service/workflow。
|
||||
- 数据库访问写在 repositories。
|
||||
- 统一使用 `loguru` 打日志。
|
||||
- GLM SDK 调用统一收口到 `services/glm_service.py`(通过统一入口方法),避免在模块内重复拼装 `chat.completions.create` 调用代码。
|
||||
- 涉及文案深度学习的抓取调用,router 侧应透传 `current_user.id` 到 `creator_scraper`,以便复用用户 Cookie 上下文并保持 `analysis_id` 用户隔离。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -117,6 +117,13 @@ backend/
|
||||
|
||||
9. **工具 (Tools)**
|
||||
* `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. **健康检查**
|
||||
* `GET /api/videos/lipsync/health`: 唇形同步服务健康状态(含 LatentSync + MuseTalk + 混合路由阈值)
|
||||
@@ -241,7 +248,7 @@ pip install -r requirements.txt
|
||||
SUPABASE_URL=http://localhost:8008
|
||||
SUPABASE_KEY=your_service_role_key
|
||||
|
||||
# GLM API (用于 AI 标题生成)
|
||||
# GLM API (用于 AI 标题/改写/翻译/文案深度学习)
|
||||
GLM_API_KEY=your_glm_api_key
|
||||
|
||||
# LatentSync 配置
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
1. 修复首页与发布成功弹窗点击下载时被浏览器当作在线播放(新开标签页)的问题。
|
||||
2. 将下载修复开始后的开发内容从 `Day31` 拆分到 `Day32`,保持日志按天清晰归档。
|
||||
3. 根据安全审计报告(`Temp/安全审计报告.md`),实施第一批 6 项无功能风险的安全修复。
|
||||
4. 统一弹窗关闭交互:默认支持点空白关闭,发布成功清理弹窗保持强制留存。
|
||||
4. 统一弹窗关闭交互(仅关闭策略):默认支持点空白关闭,发布成功清理弹窗保持强制留存。
|
||||
|
||||
---
|
||||
|
||||
@@ -91,12 +91,13 @@
|
||||
|
||||
---
|
||||
|
||||
## ✅ 4) 弹窗关闭交互统一(UX)
|
||||
## ✅ 4) 弹窗关闭策略统一(UX)
|
||||
|
||||
### 目标
|
||||
|
||||
- 保持统一交互预期:业务弹窗默认可通过 `X` 与点击遮罩关闭。
|
||||
- 保留关键流程保护:发布成功清理弹窗继续禁止遮罩关闭,避免误触导致流程中断。
|
||||
- 说明:按钮位置与视觉样式统一属于 Day33 范畴,本日志仅记录关闭策略统一。
|
||||
|
||||
### 调整内容
|
||||
|
||||
@@ -126,7 +127,7 @@
|
||||
| `frontend/src/components/AccountSettingsDropdown.tsx` | 修改密码弹窗支持点击遮罩关闭 |
|
||||
| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 录音弹窗改为 `closeOnOverlay={!isRecording}`(录音中禁遮罩关闭) |
|
||||
| `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_DEV.md` | 补充下载接口 `attachment` 约定 |
|
||||
| `Docs/FRONTEND_README.md` | 补充首页/发布弹窗下载统一同源接口说明 |
|
||||
|
||||
290
Docs/DevLogs/Day33.md
Normal file
290
Docs/DevLogs/Day33.md
Normal 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` ✅
|
||||
@@ -39,8 +39,12 @@ frontend/src/
|
||||
│ │ ├── MaterialSelector.tsx
|
||||
│ │ ├── ScriptEditor.tsx
|
||||
│ │ ├── ScriptExtractionModal.tsx
|
||||
│ │ ├── RewriteModal.tsx
|
||||
│ │ ├── ScriptLearningModal.tsx
|
||||
│ │ ├── script-extraction/
|
||||
│ │ │ └── useScriptExtraction.ts
|
||||
│ │ ├── script-learning/
|
||||
│ │ │ └── useScriptLearning.ts
|
||||
│ │ ├── TitleSubtitlePanel.tsx
|
||||
│ │ ├── FloatingStylePreview.tsx
|
||||
│ │ ├── VoiceSelector.tsx
|
||||
@@ -69,7 +73,8 @@ frontend/src/
|
||||
│ │ ├── useTitleInput.ts
|
||||
│ │ └── usePublishPrefetch.ts
|
||||
│ ├── ui/
|
||||
│ │ └── SelectPopover.tsx # 统一下拉/BottomSheet 选择器
|
||||
│ │ ├── SelectPopover.tsx # 统一下拉/BottomSheet 选择器
|
||||
│ │ └── AppModal.tsx # 统一弹窗基座
|
||||
│ ├── types/
|
||||
│ │ ├── user.ts # User 类型定义
|
||||
│ │ └── publish.ts # 发布相关类型
|
||||
@@ -213,7 +218,7 @@ body {
|
||||
|
||||
## 统一弹窗规范 (AppModal)
|
||||
|
||||
所有居中弹窗(如视频预览、文案提取、AI 改写、文案扩展编辑、录音、密码修改)统一使用 `@/shared/ui/AppModal` + `AppModalHeader`:
|
||||
所有居中弹窗(如视频预览、文案提取、AI 改写、文案深度学习、文案扩展编辑、录音、密码修改)统一使用 `@/shared/ui/AppModal` + `AppModalHeader`:
|
||||
|
||||
- 统一遮罩与层级:`fixed inset-0` + `bg-black/80` + `backdrop-blur-sm` + 明确 `z-index`
|
||||
- 统一挂载位置:通过 Portal 挂载到 `document.body`,避免局部容器/层叠上下文影响,确保是全页面弹窗
|
||||
@@ -225,6 +230,19 @@ body {
|
||||
- 统一滚动策略:弹窗打开时锁定背景滚动(`lockBodyScroll`),内容区自行滚动
|
||||
- 特殊层级场景(例如视频预览压过下拉)使用更高 `z-index`(如 `z-[320]`)
|
||||
|
||||
### 文案类弹窗结果操作栏规范
|
||||
|
||||
适用组件:
|
||||
- `ScriptExtractionModal`
|
||||
- `RewriteModal`
|
||||
- `ScriptLearningModal`
|
||||
|
||||
统一要求:
|
||||
- 结果页操作按钮统一放在内容底部(Action Grid),避免“标题右上角按钮 + 底部按钮”混排。
|
||||
- 主按钮统一为高亮渐变(如「填入文案」),其余按钮统一次级样式(`bg-white/10`)。
|
||||
- 动作文案尽量统一:`填入文案` / `复制` / `重新生成`(或与当前流程等价的返回动作)。
|
||||
- 按钮尺寸、圆角、间距保持一致(推荐 `py-2.5 px-3 rounded-lg text-sm`)。
|
||||
|
||||
---
|
||||
|
||||
## 发布后清理弹窗规范 (CleanupContext)
|
||||
@@ -248,7 +266,7 @@ body {
|
||||
所有需要认证的 API 请求**必须**使用 `@/shared/api/axios` 导出的 axios 实例。该实例已配置:
|
||||
- 自动携带 `credentials: include`
|
||||
- 遇到 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` 直调
|
||||
|
||||
**使用方式:**
|
||||
|
||||
|
||||
@@ -91,13 +91,12 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
- **到期续费**: 会员到期后登录自动跳转付费页续费,流程与首次开通一致。
|
||||
- **管理员激活**: 管理员手动激活功能并存,两种方式互不影响。
|
||||
|
||||
### 9. 文案提取助手 (`ScriptExtractionModal`)
|
||||
- **多源提取**: 支持文件拖拽上传与 URL 粘贴 (B站/抖音/TikTok)。
|
||||
- **AI 智能改写**: 集成 GLM-4.7-Flash,自动改写为口播文案。
|
||||
- **登录鉴权**: 依赖后端受保护接口(`/api/tools/extract-script`),未登录会触发全局 401 跳转登录。
|
||||
- **自定义提示词**: 可自定义改写提示词,留空使用默认;设置持久化到 localStorage。
|
||||
- **一键填入**: 提取结果直接填充至视频生成输入框。
|
||||
- **智能交互**: 实时进度展示,防误触设计。
|
||||
### 9. 文案创作助手(3 个弹窗)
|
||||
- **文案提取助手** (`ScriptExtractionModal`): 支持文件上传与 URL 提取(需登录),提取结果可一键填入主编辑器。
|
||||
- **AI 智能改写** (`RewriteModal`): 基于 GLM-4.7-Flash 改写文案,支持自定义提示词持久化。
|
||||
- **文案深度学习** (`ScriptLearningModal`): 输入抖音/B站博主主页,分析热门话题并生成口播文案(需登录)。
|
||||
- **统一结果操作栏**: 三个弹窗结果页统一底部 Action Grid 风格,主按钮为「填入文案」,次按钮统一「复制 / 重新生成(或等价返回操作)」。
|
||||
- **登录鉴权**: 依赖受保护接口(`/api/tools/*`、`/api/ai/*`),未登录会触发全局 401 跳转登录。
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
@@ -161,6 +160,7 @@ src/
|
||||
- 业务选择器统一使用 `SelectPopover`(桌面 Popover / 移动端 BottomSheet);`ScriptEditor` 的“历史文案 / AI多语言”保留原轻量菜单。
|
||||
- 业务弹窗统一使用 `AppModal`(统一遮罩、头部、关闭行为与滚动策略)。
|
||||
- 弹窗关闭策略:默认支持 `ESC` / `X` / 点击空白关闭;仅发布成功清理弹窗为强制流程(不允许空白关闭,也不显示 `X`)。
|
||||
- 文案类弹窗结果页按钮统一:底部 Action Grid、主次按钮层级一致、文案动作命名一致(填入/复制/重新生成)。
|
||||
- 视频预览弹窗层级高于下拉菜单;下拉内支持连续预览。
|
||||
- 页面同时适配桌面端与移动端;长列表统一隐藏滚动条。
|
||||
- 详细 UI 规范、持久化规范与交互约束请查看 `Docs/FRONTEND_DEV.md`。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# ViGent2 开发任务清单 (Task Log)
|
||||
|
||||
**项目**: ViGent2 数字人口播视频生成系统
|
||||
**进度**: 100% (Day 32 - 视频下载同源修复 + 安全整改第一批)
|
||||
**更新时间**: 2026-03-04
|
||||
**进度**: 100% (Day 33 - 文案深度学习落地 + 抓取稳定性增强 + 弹窗操作统一)
|
||||
**更新时间**: 2026-03-05
|
||||
|
||||
---
|
||||
|
||||
@@ -10,7 +10,17 @@
|
||||
|
||||
> 这里记录了每一天的核心开发内容与 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] **发布成功弹窗下载改造**: `CleanupContext` 从传 URL 改为传 `videoId`,下载按钮改走同源接口,去掉 `target="_blank"`。
|
||||
- [x] **首页下载改造**: `PreviewPanel` 同步切换到同源下载接口,首页与发布页下载行为一致。
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置;新作品生成后优先选中最新,后续用户手动选择持续持久化。
|
||||
- 🎵 **背景音乐** - 试听 + 搜索选择 + 混音(当前前端固定混音系数,保持配音音量稳定)。
|
||||
- 🧩 **统一选择器交互** - 首页/发布页业务选择项统一 SelectPopover(桌面 Popover / 移动端 BottomSheet),支持自动上拉、已选定位与连续预览。
|
||||
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 智能改写(支持自定义提示词)、标题/标签自动生成、9 语言翻译。
|
||||
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 智能改写(支持自定义提示词)、文案深度学习(博主话题分析+文案生成)、标题/标签自动生成、9 语言翻译。
|
||||
|
||||
### 平台化功能
|
||||
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
import traceback
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.response import success_response
|
||||
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()
|
||||
|
||||
|
||||
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")
|
||||
async def extract_script_tool(
|
||||
file: Optional[UploadFile] = File(None),
|
||||
@@ -33,3 +65,62 @@ async def extract_script_tool(
|
||||
if "Fresh cookies" in msg:
|
||||
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, "文案生成失败,请稍后重试")
|
||||
|
||||
@@ -8,7 +8,7 @@ import subprocess
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Optional, Any
|
||||
from urllib.parse import unquote
|
||||
from urllib.parse import unquote, parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
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}")
|
||||
|
||||
video_id = None
|
||||
match = re.search(r'/video/(\d+)', final_url)
|
||||
if match:
|
||||
video_id = match.group(1)
|
||||
video_id = _extract_douyin_video_id(final_url)
|
||||
if not video_id:
|
||||
video_id = _extract_douyin_video_id(url)
|
||||
|
||||
if not 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,
|
||||
}
|
||||
)
|
||||
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)})")
|
||||
except Exception as 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
|
||||
|
||||
|
||||
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]:
|
||||
"""手动下载 Bilibili 视频 (Playwright Fallback)"""
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
1301
backend/app/services/creator_scraper.py
Normal file
1301
backend/app/services/creator_scraper.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,10 @@ GLM AI 服务
|
||||
使用智谱 GLM 生成标题和标签
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Optional, cast
|
||||
from loguru import logger
|
||||
from zai import ZhipuAiClient
|
||||
|
||||
@@ -25,6 +27,48 @@ class GLMService:
|
||||
self.client = ZhipuAiClient(api_key=settings.GLM_API_KEY)
|
||||
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:
|
||||
"""
|
||||
根据口播文案生成标题和标签
|
||||
@@ -50,22 +94,13 @@ class GLMService:
|
||||
{{"title": "标题", "secondary_title": "副标题", "tags": ["标签1", "标签2", "标签3"]}}"""
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
logger.info(f"Calling GLM API with model: {settings.GLM_MODEL}")
|
||||
|
||||
# 使用 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"}, # 禁用思考模式,加快响应
|
||||
content = await self._call_glm(
|
||||
prompt=prompt,
|
||||
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}")
|
||||
|
||||
# 解析 JSON
|
||||
@@ -76,7 +111,7 @@ class GLMService:
|
||||
logger.error(f"GLM service error: {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 改写文案
|
||||
|
||||
@@ -105,28 +140,126 @@ class GLMService:
|
||||
4. 不要返回多余的解释,只返回改写后的正文"""
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
logger.info(f"Using GLM to rewrite script")
|
||||
|
||||
# 使用 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"},
|
||||
content = await self._call_glm(
|
||||
prompt=prompt,
|
||||
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")
|
||||
return content.strip()
|
||||
return content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"GLM rewrite error: {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:
|
||||
@@ -151,22 +284,15 @@ class GLMService:
|
||||
3. 翻译要自然流畅,符合目标语言的表达习惯"""
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
logger.info(f"Using GLM to translate text to {target_lang}")
|
||||
|
||||
import asyncio
|
||||
response = await asyncio.to_thread(
|
||||
client.chat.completions.create,
|
||||
model=settings.GLM_MODEL,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
thinking={"type": "disabled"},
|
||||
content = await self._call_glm(
|
||||
prompt=prompt,
|
||||
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")
|
||||
return content.strip()
|
||||
return content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"GLM translate error: {e}")
|
||||
|
||||
@@ -287,6 +287,9 @@ export const useHomeController = () => {
|
||||
// 文案提取模态框
|
||||
const [extractModalOpen, setExtractModalOpen] = useState(false);
|
||||
|
||||
// 文案深度学习模态框
|
||||
const [learningModalOpen, setLearningModalOpen] = useState(false);
|
||||
|
||||
// AI 改写模态框
|
||||
const [rewriteModalOpen, setRewriteModalOpen] = useState(false);
|
||||
|
||||
@@ -1123,6 +1126,8 @@ export const useHomeController = () => {
|
||||
setText,
|
||||
extractModalOpen,
|
||||
setExtractModalOpen,
|
||||
learningModalOpen,
|
||||
setLearningModalOpen,
|
||||
rewriteModalOpen,
|
||||
setRewriteModalOpen,
|
||||
handleGenerateMeta,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
||||
import ScriptExtractionModal from "./ScriptExtractionModal";
|
||||
import ScriptLearningModal from "./ScriptLearningModal";
|
||||
import RewriteModal from "./RewriteModal";
|
||||
import { useHomeController } from "@/features/home/model/useHomeController";
|
||||
import { resolveMediaUrl } from "@/shared/lib/media";
|
||||
@@ -53,6 +54,8 @@ export function HomePage() {
|
||||
setText,
|
||||
extractModalOpen,
|
||||
setExtractModalOpen,
|
||||
learningModalOpen,
|
||||
setLearningModalOpen,
|
||||
rewriteModalOpen,
|
||||
setRewriteModalOpen,
|
||||
handleGenerateMeta,
|
||||
@@ -222,6 +225,7 @@ export function HomePage() {
|
||||
text={text}
|
||||
onChangeText={setText}
|
||||
onOpenExtractModal={() => setExtractModalOpen(true)}
|
||||
onOpenLearningModal={() => setLearningModalOpen(true)}
|
||||
onOpenRewriteModal={() => setRewriteModalOpen(true)}
|
||||
onTranslate={handleTranslate}
|
||||
isTranslating={isTranslating}
|
||||
@@ -514,6 +518,12 @@ export function HomePage() {
|
||||
onApply={(newText) => setText(newText)}
|
||||
/>
|
||||
|
||||
<ScriptLearningModal
|
||||
isOpen={learningModalOpen}
|
||||
onClose={() => setLearningModalOpen(false)}
|
||||
onApply={(nextText) => setText(nextText)}
|
||||
/>
|
||||
|
||||
<ClipTrimmer
|
||||
isOpen={clipTrimmerOpen}
|
||||
segment={clipTrimmerSegment}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Loader2, Sparkles } from "lucide-react";
|
||||
import api from "@/shared/api/axios";
|
||||
import { ApiResponse, unwrap } from "@/shared/api/types";
|
||||
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const CUSTOM_PROMPT_KEY = "vigent_rewriteCustomPrompt";
|
||||
|
||||
@@ -78,10 +79,52 @@ export default function RewriteModal({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setRewrittenText("");
|
||||
setError(null);
|
||||
};
|
||||
const handleRetry = () => {
|
||||
setRewrittenText("");
|
||||
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;
|
||||
|
||||
@@ -143,56 +186,63 @@ export default function RewriteModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rewritten result */}
|
||||
{rewrittenText && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-semibold text-purple-300 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
AI 改写结果
|
||||
</h4>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
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"
|
||||
>
|
||||
使用此结果
|
||||
</button>
|
||||
</div>
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-semibold text-gray-400 flex items-center gap-2">
|
||||
📝 原文对比
|
||||
</h4>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
保留原文
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-40 overflow-y-auto hide-scrollbar">
|
||||
<p className="text-gray-400 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{originalText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="w-full py-2.5 px-4 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-colors"
|
||||
>
|
||||
重新改写
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* Rewritten result */}
|
||||
{rewrittenText && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-semibold text-purple-300 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
AI 改写结果
|
||||
</h4>
|
||||
<span className="text-xs text-gray-400">{rewrittenText.length} 字</span>
|
||||
</div>
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-gray-400 flex items-center gap-2">
|
||||
📝 原文对比
|
||||
</h4>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-40 overflow-y-auto hide-scrollbar">
|
||||
<p className="text-gray-400 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{originalText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<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>
|
||||
<button
|
||||
onClick={() => handleCopy(rewrittenText)}
|
||||
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
|
||||
>
|
||||
重新生成
|
||||
</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>
|
||||
</AppModal>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
|
||||
|
||||
@@ -19,6 +19,7 @@ interface ScriptEditorProps {
|
||||
text: string;
|
||||
onChangeText: (value: string) => void;
|
||||
onOpenExtractModal: () => void;
|
||||
onOpenLearningModal: () => void;
|
||||
onOpenRewriteModal: () => void;
|
||||
onTranslate: (targetLang: string) => void;
|
||||
isTranslating: boolean;
|
||||
@@ -34,6 +35,7 @@ export function ScriptEditor({
|
||||
text,
|
||||
onChangeText,
|
||||
onOpenExtractModal,
|
||||
onOpenLearningModal,
|
||||
onOpenRewriteModal,
|
||||
onTranslate,
|
||||
isTranslating,
|
||||
@@ -146,6 +148,13 @@ export function ScriptEditor({
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
文案提取助手
|
||||
</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}>
|
||||
<button
|
||||
onClick={() => setShowLangMenu((prev) => !prev)}
|
||||
|
||||
@@ -228,43 +228,47 @@ export default function ScriptExtractionModal({
|
||||
)}
|
||||
|
||||
{step === "result" && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-semibold text-gray-300 flex items-center gap-2">
|
||||
🎙️ 识别结果
|
||||
</h4>
|
||||
<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 className="space-y-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-semibold text-gray-300 flex items-center gap-2">
|
||||
🎙️ 识别结果
|
||||
</h4>
|
||||
<span className="text-xs text-gray-400">{script.length} 字</span>
|
||||
</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
|
||||
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
|
||||
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>
|
||||
)}
|
||||
|
||||
241
frontend/src/features/home/ui/ScriptLearningModal.tsx
Normal file
241
frontend/src/features/home/ui/ScriptLearningModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user