diff --git a/Docs/BACKEND_DEV.md b/Docs/BACKEND_DEV.md
index 68a71c6..4dbc96e 100644
--- a/Docs/BACKEND_DEV.md
+++ b/Docs/BACKEND_DEV.md
@@ -29,15 +29,27 @@ backend/
├── app/
│ ├── core/ # config、deps、security、response
│ ├── modules/ # 业务模块(路由 + 逻辑)
-│ │ ├── videos/
-│ │ ├── materials/
-│ │ ├── publish/
-│ │ ├── auth/
-│ │ └── ...
+│ │ ├── videos/ # 视频生成任务
+│ │ ├── materials/ # 素材管理
+│ │ ├── publish/ # 多平台发布
+│ │ ├── auth/ # 认证与会话
+│ │ ├── ai/ # AI 功能(标题标签生成等)
+│ │ ├── assets/ # 静态资源(字体/样式/BGM)
+│ │ ├── ref_audios/ # 声音克隆参考音频
+│ │ ├── login_helper/ # 扫码登录辅助
+│ │ ├── tools/ # 工具接口
+│ │ └── admin/ # 管理员功能
│ ├── repositories/ # Supabase 数据访问
│ ├── services/ # 外部服务集成
+│ │ ├── uploader/ # 平台发布器(douyin/weixin)
+│ │ ├── qr_login_service.py
+│ │ ├── publish_service.py
+│ │ ├── remotion_service.py
+│ │ ├── storage.py
+│ │ └── ...
│ └── tests/
├── assets/ # 字体 / 样式 / bgm
+├── user_data/ # 用户隔离数据(Cookie 等)
├── scripts/
└── requirements.txt
```
@@ -84,6 +96,21 @@ backend/
- 所有文件上传/下载/删除/移动通过 `services/storage.py`。
- 需要重命名时使用 `move_file`,避免直接读写 Storage。
+### Cookie 存储(用户隔离)
+
+多平台扫码登录产生的 Cookie 按用户隔离存储:
+
+```
+backend/user_data/{user_uuid}/cookies/
+├── douyin_cookies.json
+├── weixin_cookies.json
+└── ...
+```
+
+- `publish_service.py` 中通过 `_get_cookies_dir(user_id)` / `_get_cookie_path(user_id, platform)` 定位
+- 会话 key 格式:`"{user_id}_{platform}"`,确保多用户并发登录互不干扰
+- 登录成功后 Cookie 自动保存到对应路径,发布时自动加载
+
---
## 7. 代码约定
@@ -110,13 +137,22 @@ backend/
- `REDIS_URL`
- `GLM_API_KEY`
- `LATENTSYNC_*`
+- `CORS_ORIGINS` (CORS 白名单,默认 *)
+
+### 微信视频号
- `WEIXIN_HEADLESS_MODE` (headful/headless-new)
- `WEIXIN_CHROME_PATH` / `WEIXIN_BROWSER_CHANNEL`
- `WEIXIN_USER_AGENT` / `WEIXIN_LOCALE` / `WEIXIN_TIMEZONE_ID`
- `WEIXIN_FORCE_SWIFTSHADER`
- `WEIXIN_TRANSCODE_MODE` (reencode/faststart/off)
-- `CORS_ORIGINS` (CORS 白名单,默认 *)
-- `SUPABASE_STORAGE_LOCAL_PATH` (本地存储路径)
+
+### 抖音
+- `DOUYIN_HEADLESS_MODE` (headful/headless-new,默认 headless-new)
+- `DOUYIN_CHROME_PATH` / `DOUYIN_BROWSER_CHANNEL`
+- `DOUYIN_USER_AGENT` (默认 Chrome/144)
+- `DOUYIN_LOCALE` / `DOUYIN_TIMEZONE_ID`
+- `DOUYIN_FORCE_SWIFTSHADER`
+- `DOUYIN_DEBUG_ARTIFACTS` / `DOUYIN_RECORD_VIDEO` / `DOUYIN_KEEP_SUCCESS_VIDEO`
- `DOUYIN_COOKIE` (抖音视频下载 Cookie)
---
diff --git a/Docs/BACKEND_README.md b/Docs/BACKEND_README.md
index 2fea4ad..935e2b4 100644
--- a/Docs/BACKEND_README.md
+++ b/Docs/BACKEND_README.md
@@ -15,11 +15,22 @@ backend/
├── app/
│ ├── core/ # 核心配置 (config.py, security.py, response.py)
│ ├── modules/ # 业务模块 (router/service/workflow/schemas)
+│ │ ├── videos/ # 视频生成任务
+│ │ ├── materials/ # 素材管理
+│ │ ├── publish/ # 多平台发布
+│ │ ├── auth/ # 认证与会话
+│ │ ├── ai/ # AI 功能(标题标签生成)
+│ │ ├── assets/ # 静态资源(字体/样式/BGM)
+│ │ ├── ref_audios/ # 声音克隆参考音频
+│ │ ├── login_helper/ # 扫码登录辅助
+│ │ ├── tools/ # 工具接口(文案提取等)
+│ │ └── admin/ # 管理员功能
│ ├── repositories/ # Supabase 数据访问
-│ ├── services/ # 外部服务集成 (TTS/Remotion/Storage 等)
+│ ├── services/ # 外部服务集成 (TTS/Remotion/Storage/Uploader 等)
│ └── tests/ # 单元测试与集成测试
├── scripts/ # 运维脚本 (watchdog.py, init_db.py)
├── assets/ # 资源库 (fonts, bgm, styles)
+├── user_data/ # 用户隔离数据 (Cookie 等)
└── requirements.txt # 依赖清单
```
@@ -41,12 +52,11 @@ backend/
2. **视频生成 (Videos)**
* `POST /api/videos/generate`: 提交生成任务
- * `GET /api/videos/tasks/{task_id}`: 查询任务状态
+ * `GET /api/videos/tasks/{task_id}`: 查询单个任务状态
+ * `GET /api/videos/tasks`: 获取用户所有任务列表
* `GET /api/videos/generated`: 获取历史视频列表
* `DELETE /api/videos/generated/{video_id}`: 删除历史视频
-> **修正 (16:20)**:任务查询与历史列表接口已更新为 `/api/videos/tasks/{task_id}` 与 `/api/videos/generated`。
-
3. **素材管理 (Materials)**
* `POST /api/materials`: 上传素材
* `GET /api/materials`: 获取素材列表
@@ -54,14 +64,33 @@ backend/
4. **社交发布 (Publish)**
* `POST /api/publish`: 发布视频到 抖音/微信视频号/B站/小红书
+ * `POST /api/publish/login`: 扫码登录平台
+ * `GET /api/publish/login/status`: 查询登录状态(含刷脸验证二维码)
+ * `GET /api/publish/accounts`: 获取已登录账号列表
-> 提示:视频号发布建议使用 headful + xvfb-run 运行后端,避免 headless 解码失败。
+> 提示:视频号/抖音发布建议使用 headful + xvfb-run 运行后端。
5. **资源库 (Assets)**
* `GET /api/assets/subtitle-styles`: 字幕样式列表
* `GET /api/assets/title-styles`: 标题样式列表
* `GET /api/assets/bgm`: 背景音乐列表
+6. **声音克隆 (Ref Audios)**
+ * `POST /api/ref-audios`: 上传参考音频 (multipart/form-data)
+ * `GET /api/ref-audios`: 获取参考音频列表
+ * `PUT /api/ref-audios/{id}`: 重命名参考音频
+ * `DELETE /api/ref-audios/{id}`: 删除参考音频
+
+7. **AI 功能 (AI)**
+ * `POST /api/ai/generate-meta`: AI 生成标题和标签
+
+8. **工具 (Tools)**
+ * `POST /api/tools/extract-script`: 从视频链接提取文案
+
+9. **健康检查**
+ * `GET /api/lipsync/health`: LatentSync 服务健康状态
+ * `GET /api/voiceclone/health`: Qwen3-TTS 服务健康状态
+
### 统一响应结构
```json
@@ -79,10 +108,17 @@ backend/
`POST /api/videos/generate` 支持以下可选字段:
+- `tts_mode`: TTS 模式 (`edgetts` / `voiceclone`)
+- `voice`: EdgeTTS 音色 ID(edgetts 模式)
+- `ref_audio_id` / `ref_text`: 参考音频 ID 与文本(voiceclone 模式)
+- `title`: 片头标题文字
- `subtitle_style_id`: 字幕样式 ID
- `title_style_id`: 标题样式 ID
- `subtitle_font_size`: 字幕字号(覆盖样式默认值)
- `title_font_size`: 标题字号(覆盖样式默认值)
+- `title_top_margin`: 标题距顶部像素
+- `subtitle_bottom_margin`: 字幕距底部像素
+- `enable_subtitles`: 是否启用字幕
- `bgm_id`: 背景音乐 ID
- `bgm_volume`: 背景音乐音量(0-1,默认 0.2)
diff --git a/Docs/DEPLOY_MANUAL.md b/Docs/DEPLOY_MANUAL.md
index 2aadcc5..11f0aa6 100644
--- a/Docs/DEPLOY_MANUAL.md
+++ b/Docs/DEPLOY_MANUAL.md
@@ -115,6 +115,16 @@ playwright install chromium
```
> 提示:视频号发布建议使用系统 Chrome + xvfb-run(避免 headless 解码失败)。
+> 抖音发布同样建议 headful 模式 (`DOUYIN_HEADLESS_MODE=headful`)。
+
+### 扫码登录注意事项
+
+- **Cookie 按用户隔离**:每个用户的 Cookie 存储在 `backend/user_data/{uuid}/cookies/` 目录下,多用户并发登录互不干扰。
+- **抖音 QR 登录关键教训**:
+ - 扫码后绝对**不能重新加载 QR 页面**,否则会销毁会话 token
+ - 使用**新标签页**检测登录完成状态(检查 URL 包含 `creator-micro` + session cookies 存在)
+ - 抖音可能弹出**刷脸验证**,后端会自动提取验证二维码返回给前端展示
+- **微信视频号发布**:标题、描述、标签统一写入"视频描述"字段
---
@@ -189,8 +199,17 @@ cp .env.example .env
| `WEIXIN_TIMEZONE_ID` | Asia/Shanghai | 视频号时区 |
| `WEIXIN_FORCE_SWIFTSHADER` | true | 强制软件 WebGL,避免 context lost |
| `WEIXIN_TRANSCODE_MODE` | reencode | 上传前转码 (reencode/faststart/off) |
+| `DOUYIN_HEADLESS_MODE` | headless-new | 抖音 Playwright 模式 (headful/headless-new) |
+| `DOUYIN_CHROME_PATH` | `/usr/bin/google-chrome` | 抖音 Chrome 路径 |
+| `DOUYIN_BROWSER_CHANNEL` | | 抖音 Chromium 通道 (可选) |
+| `DOUYIN_USER_AGENT` | Chrome/144 UA | 抖音浏览器指纹 UA |
+| `DOUYIN_LOCALE` | zh-CN | 抖音语言环境 |
+| `DOUYIN_TIMEZONE_ID` | Asia/Shanghai | 抖音时区 |
+| `DOUYIN_FORCE_SWIFTSHADER` | true | 强制软件 WebGL |
+| `DOUYIN_DEBUG_ARTIFACTS` | false | 保留调试截图 |
+| `DOUYIN_RECORD_VIDEO` | false | 录制浏览器操作视频 |
+| `DOUYIN_KEEP_SUCCESS_VIDEO` | false | 成功后保留录屏 |
| `CORS_ORIGINS` | `*` | CORS 允许源 (生产环境建议白名单) |
-| `SUPABASE_STORAGE_LOCAL_PATH` | 默认路径 | Supabase 本地存储路径 |
| `DOUYIN_COOKIE` | 空 | 抖音视频下载 Cookie (文案提取功能) |
---
@@ -265,6 +284,7 @@ cat > run_backend.sh << 'EOF'
set -e
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
export WEIXIN_HEADLESS_MODE=headful
+export DOUYIN_HEADLESS_MODE=headful
export WEIXIN_DEBUG_ARTIFACTS=false
export WEIXIN_RECORD_VIDEO=false
export DOUYIN_DEBUG_ARTIFACTS=false
diff --git a/Docs/DevLogs/Day20.md b/Docs/DevLogs/Day20.md
index 571d56b..58eedf7 100644
--- a/Docs/DevLogs/Day20.md
+++ b/Docs/DevLogs/Day20.md
@@ -65,6 +65,15 @@ pm2 restart vigent2-latentsync
# Remotion 已自动编译
```
+### 🎨 交互与体验优化 (17:00)
+
+- [x] **UX-1**: PublishPage 图片加载优化 (`
` → `next/image`)
+- [x] **UX-2**: 按钮 Loading 状态统一 (提取脚本弹窗 + 发布页)
+- [x] **UX-3**: 骨架屏加载优化 (发布页加载中状态)
+- [x] **UX-4**: 全局快捷键支持 (ESC 关闭弹窗, Enter 确认)
+- [x] **UX-5**: 移除全局 GlobalTaskIndicator (视觉降噪)
+- [x] **UX-6**: 视频生成完成自动刷新列表并选中最新
+
### 🐛 缺陷修复与回归治理 (17:30)
#### 严重缺陷修复
@@ -85,6 +94,10 @@ pm2 restart vigent2-latentsync
- *原因*: 重构移除旧逻辑后,新用户或无缓存用户进入页面无默认选中。
- *修复*: 在 `isRestored` 且无选中时,增加兜底逻辑自动选中列表第一项。
-- [x] **REF-1**: 持久化逻辑全站收敛
- - *优化*: 清理 `useBgm`, `useGeneratedVideos`, `useTitleSubtitleStyles` 中的冗余 `localStorage` 读取。
- - *优化*: 修复 `useMaterials` 中的闭包陷阱(使用函数式更新),防止覆盖已恢复的状态。
+- [x] **REG-3**: 素材选择持久化失效 (闭包陷阱)
+ - *原因*: `useMaterials` 加载回调中捕获了旧的 `selectedMaterial` 状态,覆盖了已恢复的值。
+ - *修复*: 改为函数式状态更新 (`setState(prev => ...)`),确保基于最新状态判断。
+
+- [x] **REF-1**: 持久化逻辑全站收敛与排查
+ - *优化*: 清理 `useBgm`, `useGeneratedVideos`, `useTitleSubtitleStyles` 中的冗余 `localStorage` 读取,统一由 `useHomePersistence` 管理。
+ - *排查*: 深度排查 `useRefAudios`, `useTitleSubtitleStyles` 等模块,确认逻辑健壮,无类似回归风险。
diff --git a/Docs/DevLogs/Day21.md b/Docs/DevLogs/Day21.md
index 5751270..ce5f599 100644
--- a/Docs/DevLogs/Day21.md
+++ b/Docs/DevLogs/Day21.md
@@ -90,3 +90,159 @@ await api.post('/api/publish', { video_path: video.path, ... });
pm2 restart vigent2-backend # Remotion 容错
npm run build && pm2 restart vigent2-frontend # 前端持久化修复
```
+
+---
+
+## 🎨 浮动样式预览窗口优化 (Day 21)
+
+### 概述
+标题与字幕面板中的预览区域原本是内联折叠的,展开后调节下方滑块时看不到预览效果。改为 `position: fixed` 浮动窗口,固定在视口左上角,滚动页面时预览始终可见,边调边看。
+
+### 已完成优化
+
+#### 1. 新建浮动预览组件 `FloatingStylePreview.tsx`
+- `createPortal(jsx, document.body)` 渲染到 body 层级,脱离面板 DOM 树
+- `position: fixed` + 左上角固定定位,滚动时不移动
+- `z-index: 150`(低于 VideoPreviewModal 的 200)
+- 顶部标题栏 + X 关闭按钮,ESC 键关闭
+- 桌面端固定宽度 280px,移动端自适应(最大 360px)
+- `previewScale = windowWidth / previewBaseWidth` 自行计算缩放
+- `maxHeight: calc(100dvh - 32px)` 防止超出视口
+
+#### 2. 修改 `TitleSubtitlePanel.tsx`
+- 删除内联预览区域(`ref={previewContainerRef}` 整块 JSX)
+- 条件渲染 ``,按钮文本保持"预览样式"/"收起预览"
+- 移除 `previewScale`、`previewAspectRatio`、`previewContainerRef` props
+- 保留 `previewBaseWidth/Height`(浮动窗口需要原始尺寸计算 scale)
+
+#### 3. 清理 `useHomeController.ts`
+- 移除 `previewContainerWidth` 状态
+- 移除 `titlePreviewContainerRef` ref
+- 移除 ResizeObserver useEffect(浮动窗口自管尺寸,不再需要)
+
+#### 4. 简化 `HomePage.tsx` 传参
+- 移除 `previewContainerWidth`、`titlePreviewContainerRef` 解构
+- 移除 `previewScale`、`previewAspectRatio`、`previewContainerRef` prop 传递
+
+#### 5. 移动端适配
+- `ScriptEditor.tsx`:标题行改为 `flex-wrap`,"AI生成标题标签"按钮不再溢出
+- 预览默认比例从 1280×720 (16:9) 改为 1080×1920 (9:16),符合抖音竖屏视频
+
+### 涉及文件汇总
+
+| 文件 | 变更 |
+|------|------|
+| `frontend/src/features/home/ui/FloatingStylePreview.tsx` | **新建** 浮动预览组件 |
+| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 移除内联预览,渲染浮动组件 |
+| `frontend/src/features/home/model/useHomeController.ts` | 移除 preview 容器相关状态和 ResizeObserver |
+| `frontend/src/features/home/ui/HomePage.tsx` | 简化 props 传递,默认比例改 9:16 |
+| `frontend/src/features/home/ui/ScriptEditor.tsx` | 移动端按钮换行适配 |
+
+### 重启要求
+```bash
+npm run build && pm2 restart vigent2-frontend
+```
+
+---
+
+## 🔧 多平台发布体系重构:用户隔离与抖音刷脸验证 (Day 21)
+
+### 概述
+重构发布系统的两大核心问题:① 多用户场景下 Cookie/会话缺乏隔离,② 抖音登录新增刷脸验证步骤无法处理。同时修复了平台配置混用和微信视频号发布流程问题。
+
+---
+
+### 一、平台配置独立化
+
+#### 问题
+所有平台(抖音、微信、B站、小红书)共用 WEIXIN_* 配置,导致 User-Agent、Headless 模式等设置不匹配。
+
+#### 修复 — `config.py`
+- 新增 `DOUYIN_*` 独立配置项:`DOUYIN_HEADLESS_MODE`、`DOUYIN_USER_AGENT`(Chrome/144)、`DOUYIN_LOCALE`、`DOUYIN_TIMEZONE_ID`、`DOUYIN_CHROME_PATH`、`DOUYIN_FORCE_SWIFTSHADER`、调试开关等
+- 微信保持已有 `WEIXIN_*` 配置
+- B站/小红书使用通用默认值
+
+#### 修复 — `qr_login_service.py` 平台配置映射
+```python
+# 之前:所有平台都用 WEIXIN 设置
+# 之后:每个平台独立配置
+PLATFORM_CONFIGS = {
+ "douyin": { headless, user_agent, locale, timezone... },
+ "weixin": { headless, user_agent, locale, timezone... },
+ "bilibili": { 通用配置 },
+ "xiaohongshu": { 通用配置 },
+}
+```
+
+---
+
+### 二、用户隔离的 Cookie 管理
+
+#### 问题
+多用户共享同一套 Cookie 文件,用户 A 的登录态可能被用户 B 覆盖。
+
+#### 修复 — `publish_service.py`
+- `_get_cookies_dir(user_id)` → `backend/user_data/{uuid}/cookies/`
+- `_get_cookie_path(user_id, platform)` → 按用户+平台返回独立 Cookie 文件路径
+- `_get_session_key(user_id, platform)` → `"{user_id}_{platform}"` 格式的会话 key
+- 登录/发布流程全链路传入 `user_id`,清理残留会话避免干扰
+
+---
+
+### 三、抖音刷脸验证二维码
+
+#### 问题
+抖音扫码登录后可能弹出刷脸验证窗口,内含新的二维码需要用户再次扫描,前端无法感知和展示。
+
+#### 修复 — 后端 `qr_login_service.py`
+- 扩展 QR 选择器:支持跨 iframe 搜索二维码元素
+- 抖音 API 拦截:监听 `check_qrconnect` 响应,检测 `redirect_url`
+- 检测 "完成验证" / "请前往APP完成验证" 文案
+- 在验证弹窗内找到正方形二维码(排除头像),截图返回给前端
+- API 确认后直接导航到 redirect_url(不重新加载 QR 页,避免销毁会话)
+
+#### 修复 — 后端 `publish_service.py`
+- `get_login_session_status()` 新增 `face_verify_qr` 字段返回
+- 登录成功且 Cookie 保存后自动清理会话
+
+#### 修复 — 前端
+- `usePublishController.ts`:新增 `faceVerifyQr` 状态,轮询时获取 `face_verify_qr` 字段
+- `PublishPage.tsx`:QR 弹窗优先展示刷脸验证二维码,附提示文案
+
+```tsx
+{faceVerifyQr ? (
+ <>
+
+
需要身份验证,请用抖音APP扫描上方二维码完成刷脸验证
+ >
+) : /* 普通登录二维码 */ }
+```
+
+---
+
+### 四、微信视频号发布流程优化
+
+#### 修复 — `weixin_uploader.py`
+- 添加 `user_id` 参数支持,发布截图目录隔离
+- 新增 `post_create` API 响应监听,精准判断发布成功
+- 发布结果判定:URL 离开创建页 或 API 确认提交 → 视为成功
+- 标题/标签处理改为统一写入"视频描述"字段(不再单独填写 title/tags)
+
+---
+
+### 涉及文件汇总
+
+| 文件 | 变更 |
+|------|------|
+| `backend/app/core/config.py` | 新增 DOUYIN_* 独立配置项 |
+| `backend/app/services/qr_login_service.py` | 平台配置拆分、刷脸验证二维码、跨 iframe 选择器 |
+| `backend/app/services/publish_service.py` | 用户隔离 Cookie 管理、刷脸验证状态返回 |
+| `backend/app/services/uploader/weixin_uploader.py` | user_id 支持、post_create API 监听、描述字段合并 |
+| `frontend/src/features/publish/model/usePublishController.ts` | faceVerifyQr 状态 |
+| `frontend/src/features/publish/ui/PublishPage.tsx` | 刷脸验证二维码展示 |
+
+### 重启要求
+```bash
+pm2 restart vigent2-backend # 发布服务 + QR登录
+npm run build && pm2 restart vigent2-frontend # 刷脸验证UI
+```
diff --git a/Docs/Doc_Rules.md b/Docs/Doc_Rules.md
index e54f078..6081422 100644
--- a/Docs/Doc_Rules.md
+++ b/Docs/Doc_Rules.md
@@ -31,8 +31,6 @@
| ⚡ **Med** | `Docs/FRONTEND_DEV.md` | **(前端规范)** API封装、日期格式化、新页面规范 |
| ⚡ **Med** | `Docs/FRONTEND_README.md` | **(前端文档)** 功能说明、页面变更 |
| 🧊 **Low** | `Docs/*_DEPLOY.md` | **(子系统部署)** LatentSync/Qwen3/字幕等独立部署文档 |
-| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
-| 🧊 **Low** | `Docs/architecture_plan.md` | **(前端架构)** 拆分计划与阶段目标 |
---
@@ -97,7 +95,7 @@
### 必须执行的检查步骤
-**1. 快速浏览全文**(使用 `view_file` 或 `grep_search`)
+**1. 快速浏览全文**(使用 `Read` 或 `Grep`)
```markdown
# 检查是否存在:
- 同主题的旧章节?
@@ -144,66 +142,41 @@
> **核心原则**:使用正确的工具,避免字符编码问题
-### ✅ 推荐工具:apply_patch
+### ✅ 推荐工具:Edit / Read / Grep
**使用场景**:
-- 追加新章节到文件末尾
-- 修改/替换现有章节内容
-- 更新状态标记(🔄 → ✅)
-- 修正错误内容
-
-**优势**:
-- ✅ 自动处理字符编码(Windows CRLF)
-- ✅ 精确替换,不会误删其他内容
-- ✅ 有错误提示,方便调试
+- `Read`:更新前先查看文件当前内容
+- `Edit`:精确替换现有内容、追加新章节
+- `Grep`:搜索文件中是否已有相关章节
+- `Write`:创建新文件(如 Day{N+1}.md)
**注意事项**:
```markdown
-1. **必须精确匹配**:TargetContent 必须与文件完全一致
-2. **处理换行符**:文件使用 \r\n,不要漏掉 \r
-3. **合理范围**:StartLine/EndLine 应覆盖目标内容
-4. **先读后写**:编辑前先 view_file 确认内容
+1. **先读后写**:编辑前先用 Read 确认内容
+2. **精确匹配**:Edit 的 old_string 必须与文件内容完全一致
+3. **避免重复**:编辑前用 Grep 检查是否已存在同主题章节
```
-### ❌ 禁止使用:命令行工具
+### ❌ 禁止使用:命令行工具修改文档
**禁止场景**:
-- ❌ 使用 `echo >>` 追加内容(编码问题)
-- ❌ 使用 PowerShell 直接修改文档(破坏格式)
-- ❌ 使用 sed/awk 等命令行工具
+- ❌ 使用 `echo >>` 追加内容
+- ❌ 使用 `sed` / `awk` 修改文档
+- ❌ 使用 `cat < **修正 (18:10)**:当前实现采用 Next.js 16、FastAPI BackgroundTasks 与 Supabase Storage/Auth,自动发布基于 Playwright。
-
----
-
-## ✅ 现状补充 (Day 17)
-
-- 前端已拆分为组件化结构(`features/home/ui/`),主页面逻辑集中。
-- 通用工具 `media.ts` 统一处理 API Base / 资源 URL / 日期格式化。
-- 作品预览弹窗统一样式,并支持素材/发布预览复用。
-- 标题/字幕预览按素材分辨率缩放,效果更接近成片。
-
----
-
-## 分阶段实施计划
-
-### 阶段一:核心功能验证 (MVP)
-
-> **目标**:验证 LatentSync + EdgeTTS 效果,跑通端到端流程
-
-#### 1.1 环境搭建
-
-参考 `models/LatentSync/DEPLOY.md` 完成 LatentSync 环境与权重部署。
-
-#### 1.2 集成 EdgeTTS
-
-```python
-# tts_engine.py
-import edge_tts
-import asyncio
-
-async def text_to_speech(text: str, voice: str = "zh-CN-YunxiNeural", output_path: str = "output.mp3"):
- communicate = edge_tts.Communicate(text, voice)
- await communicate.save(output_path)
- return output_path
-```
-
-#### 1.3 端到端测试脚本
-
-```python
-# test_pipeline.py
-"""
-1. 文案 → EdgeTTS → 音频
-2. 静态视频 + 音频 → LatentSync → 口播视频
-3. 添加字幕 → FFmpeg → 最终视频
-"""
-```
-
-#### 1.4 验证标准
-- [ ] LatentSync 能正常推理
-- [ ] 唇形与音频同步率 > 90%
-- [ ] 单个视频生成时间 < 2 分钟
-
----
-
-### 阶段二:后端 API 开发
-
-> **目标**:将核心功能封装为 API,支持异步任务
-
-#### 2.1 项目结构
-
-```
-backend/
-├── app/
-│ ├── main.py # FastAPI 入口
-│ ├── api/
-│ │ ├── videos.py # 视频生成 API
-│ │ ├── materials.py # 素材管理 API
-│ │ └── publish.py # 发布管理 API
-│ ├── services/
-│ │ ├── tts_service.py # TTS 服务
-│ │ ├── lipsync_service.py # 唇形同步服务
-│ │ └── video_service.py # 视频合成服务
-│ ├── tasks/
-│ │ └── celery_tasks.py # Celery 异步任务
-│ ├── models/
-│ │ └── schemas.py # Pydantic 模型
-│ └── core/
-│ └── config.py # 配置管理
-├── requirements.txt
-└── docker-compose.yml # Redis + API
-```
-
-#### 2.2 核心 API 设计
-
-| 端点 | 方法 | 功能 |
-|------|------|------|
-| `/api/materials` | POST | 上传视频素材 | ✅ |
-| `/api/materials` | GET | 获取素材列表 | ✅ |
-| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
-| `/api/videos/tasks/{id}` | GET | 查询任务状态 | ✅ |
-| `/api/videos/generated` | GET | 获取历史作品列表 | ✅ |
-| `/api/publish` | POST | 发布到社交平台 | ✅ |
-
-#### 2.3 BackgroundTasks 任务定义
-
-```python
-# app/api/videos.py
-background_tasks.add_task(_process_video_generation, task_id, req, user_id)
-```
-
----
-
-### 阶段三:前端 Web UI
-
-> **目标**:提供用户友好的操作界面
-
-#### 3.1 页面设计
-
-| 页面 | 功能 |
-|------|------|
-| **素材库** | 上传/管理多场景视频素材 |
-| **生成视频** | 输入文案、选择素材、生成预览 |
-| **任务中心** | 查看生成进度、下载视频 |
-| **发布管理** | 绑定平台、一键发布、定时发布 |
-
-#### 3.2 技术实现
-
-```bash
-# 创建 Next.js 项目
-npx create-next-app@latest frontend --typescript --tailwind --app
-
-# 安装依赖
-cd frontend
-npm install axios swr
-```
-
----
-
-### 阶段四:社交媒体发布
-
-> **目标**:集成 social-auto-upload,支持多平台发布
-
-#### 4.1 复用 social-auto-upload
-
-```bash
-# 复制模块
-cp -r SuperIPAgent/social-auto-upload backend/social_upload
-```
-
-#### 4.2 Cookie 管理
-
-```python
-# 用户通过浏览器登录 → 保存 Cookie → 后续自动发布
-```
-
-#### 4.3 支持平台
-- 抖音
-- 小红书
-- 微信视频号
-- 快手
-
----
-
-### 阶段五:优化与扩展
-
-| 功能 | 实现方式 |
-|------|----------|
-| **声音克隆** | 集成 GPT-SoVITS,用自己的声音 |
-| **AI 标题/标签生成** | 调用大模型 API 自动生成标题与标签 ✅ |
-| **批量生成** | 上传 Excel/CSV,批量生成视频 |
-| **字幕编辑器** | 可视化调整字幕样式、位置 |
-| **Docker 部署** | 一键部署到云服务器 | ✅ |
-
----
-
-### 阶段六:MuseTalk 服务器部署 (Day 2-3) ✅
-
-> **目标**:在双显卡服务器上部署 MuseTalk 环境
-
-- [x] Conda 环境配置 (musetalk)
-- [x] 模型权重下载 (~7GB)
-- [x] Subprocess 调用方式实现
-- [x] 健康检查功能
-
-### 阶段七:MuseTalk 完整修复 (Day 4) ✅
-
-> **目标**:解决推理脚本的各种兼容性问题
-
-- [x] 权重检测路径修复 (软链接)
-- [x] 音视频长度不匹配修复
-- [x] 推理脚本错误日志增强
-- [x] 视频合成 MP4 生成验证
-
-### 阶段八:前端功能增强 (Day 5) ✅
-
-> **目标**:提升用户体验
-
-- [x] Web 视频上传功能
-- [x] 上传进度显示
-- [x] 自动刷新素材列表
-
-### 阶段九:唇形同步模型升级 (Day 6) ✅
-
-> **目标**:从 MuseTalk 迁移到 LatentSync 1.6
-
-- [x] MuseTalk → LatentSync 1.6 迁移
-- [x] 后端代码适配 (config.py, lipsync_service.py)
-- [x] Latent Diffusion 架构 (512x512 高清)
-- [x] 服务器端到端验证
-
-### 阶段十:性能优化 (Day 6) ✅
-
-> **目标**:提升系统响应速度和稳定性
-
-- [x] 视频预压缩优化 (1080p → 720p 自动适配)
-- [x] 进度更新细化 (实时反馈)
-- [x] **常驻模型服务** (Persistent Server, 0s 加载)
-- [x] **GPU 并发控制** (串行队列防崩溃)
-
-### 阶段十一:社交媒体发布完善 (Day 7) ✅
-
-> **目标**:实现全自动扫码登录和多平台发布
-
-- [x] QR码自动登录 (Playwright headless + Stealth)
-- [x] 多平台上传器架构 (B站/抖音/小红书)
-- [x] Cookie 自动管理
-- [x] 定时发布功能
-
-### 阶段十二:用户体验优化 (Day 8) ✅
-
-> **目标**:提升文件管理和历史记录功能
-
-- [x] 文件名保留 (时间戳前缀 + 原始名称)
-- [x] 视频持久化 (历史视频列表 API)
-- [x] 素材/视频删除功能
-
-### 阶段十三:发布模块优化 (Day 9) ✅
-
-> **目标**:代码质量优化 + 发布功能验证
-
-- [x] B站/抖音登录+发布验证通过
-- [x] 资源清理保障 (try-finally)
-- [x] 超时保护 (消除无限循环)
-- [x] 完整类型提示
-
-### 阶段十四:用户认证系统 (Day 9) ✅
-
-> **目标**:实现安全、隔离的多用户认证体系
-
-- [x] Supabase 云数据库集成 (本地自托管)
-- [x] JWT + HttpOnly Cookie 认证架构
-- [x] 用户表与权限表设计 (RLS 准备)
-- [x] 认证部署文档 (Docs/SUPABASE_DEPLOY.md)
-
-### 阶段十五:部署稳定性优化 (Day 9) ✅
-
-> **目标**:确保生产环境服务长期稳定
-
-- [x] 依赖冲突修复 (bcrypt)
-- [x] 前端构建修复 (Production Build)
-- [x] PM2 进程守护配置
-- [x] 部署手册更新 (Docs/DEPLOY_MANUAL.md)
-
-### 阶段十六:HTTPS 全栈部署 (Day 10) ✅
-
-> **目标**:实现安全的公网 HTTPS 访问
-
-- [x] 阿里云 Nginx 反向代理配置
-- [x] Let's Encrypt SSL 证书集成
-- [x] Supabase 自托管部署 (Docker)
-- [x] 端口冲突解决 (3003/8008/8444)
-- [x] Basic Auth 管理后台保护
-
-### 阶段十七:声音克隆功能集成 (Day 13) ✅
-
-> **目标**:实现用户自定义声音克隆能力
-
-- [x] Qwen3-TTS HTTP 服务 (独立 FastAPI,端口 8009)
-- [x] 声音克隆服务封装 (voice_clone_service.py)
-- [x] 参考音频管理 API (上传/列表/删除)
-- [x] 前端 TTS 模式选择 UI
-- [x] Supabase ref-audios Bucket 配置
-- [x] 端到端测试验证
-
-### 阶段十八:手机号登录迁移 (Day 15) ✅
-
-> **目标**:将认证系统从邮箱迁移到手机号
-
-- [x] 数据库 Schema 迁移 (email → phone)
-- [x] 后端 API 适配 (auth.py/admin.py)
-- [x] 11位手机号校验 (正则验证)
-- [x] 修改密码功能 (/api/auth/change-password)
-- [x] 账户设置下拉菜单 (修改密码 + 有效期显示 + 退出)
-- [x] 前端登录/注册页面更新
-- [x] 数据库迁移脚本 (migrate_to_phone.sql)
-
-### 阶段十九:深度性能优化与服务守护 (Day 16) ✅
-
-> **目标**:提升系统响应速度与服务稳定性
-
-- [x] Flash Attention 2 集成 (Qwen3-TTS 加速 5x)
-- [x] LatentSync 性能调优 (OMP 线程限制 + 原生 Flash Attn)
-- [x] Watchdog 服务守护 (自动重启僵死服务)
-- [x] 文档体系更新 (部署手册与运维指南)
-
----
-
-## 项目目录结构 (最终)
-
----
-
-## 开发时间估算
-
-| 阶段 | 预计时间 | 说明 |
-|------|----------|------|
-| 阶段一 | 2-3 天 | 环境搭建 + 效果验证 |
-| 阶段二 | 3-4 天 | 后端 API 开发 |
-| 阶段三 | 3-4 天 | 前端 UI 开发 |
-| 阶段四 | 2 天 | 社交发布集成 |
-| 阶段五 | 按需 | 持续优化 |
-
-**总计**:约 10-13 天可完成 MVP
-
----
-
-### 阶段二十:代码质量与安全优化 (Day 20) ✅
-
-> **目标**:全面提升代码健壮性、安全性与配置灵活性
-
-- [x] **安全性修复**:硬编码 Cookie/Key 移除,ffprobe 安全调用,日志脱敏
-- [x] **配置化改造**:存储路径、CORS、录屏开关全面环境变量化
-- [x] **性能优化**:API 异步改造 (httpx/asyncio),大文件流式上传
-- [x] **构建优化**:Remotion 预编译,统一启动脚本 `run_backend.sh`
-
----
-
-## 验证计划
-
-### 阶段一验证
-1. 运行 `test_pipeline.py` 脚本
-2. 检查生成视频的唇形同步效果
-3. 确认音画同步
-
-### 阶段二验证
-1. 使用 Postman/curl 测试所有 API 端点
-2. 验证任务队列正常工作
-3. 检查视频生成完整流程
-
-### 阶段三验证
-1. 在浏览器中完成完整操作流程
-2. 验证上传、生成、下载功能
-3. 检查响应式布局
-
-### 阶段四验证
-1. 发布一个测试视频到抖音
-2. 验证定时发布功能
-3. 检查发布状态同步
-
----
-
-## 硬件要求
-
-| 配置 | 最低要求 | 推荐配置 |
-|------|----------|----------|
-| **GPU** | NVIDIA GTX 1060 6GB | RTX 3060 12GB+ |
-| **内存** | 16GB | 32GB |
-| **存储** | 100GB SSD | 500GB SSD |
-| **CUDA** | 11.7+ | 12.0+ |
-
----
-
-## 下一步行动
-
-1. **确认你的 GPU 配置** - MuseTalk 需要 NVIDIA GPU
-2. **选择开发起点** - 从阶段一开始验证效果
-3. **确定项目位置** - 在哪个目录创建项目
-
----
-
-> [!IMPORTANT]
-> 请确认以上计划是否符合你的需求,有任何需要调整的地方请告诉我。
diff --git a/README.md b/README.md
index 4bc9b3e..2b066ea 100644
--- a/README.md
+++ b/README.md
@@ -15,24 +15,24 @@
## ✨ 功能特性
-### 核心能力
-- 🎬 **高清唇形同步** - LatentSync 1.6 驱动,512×512 高分辨率 Latent Diffusion 模型。
-- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音) 和 **Qwen3-TTS** (3秒极速声音克隆)。
-- 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。
-- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
-- 🖼️ **作品预览一致性** - 标题/字幕预览按素材分辨率缩放,效果更接近成片。
-- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。
-- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
-- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
+### 核心能力
+- 🎬 **高清唇形同步** - LatentSync 1.6 驱动,512×512 高分辨率 Latent Diffusion 模型。
+- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音) 和 **Qwen3-TTS** (3秒极速声音克隆)。
+- 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。
+- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
+- 🖼️ **作品预览一致性** - 标题/字幕预览按素材分辨率缩放,效果更接近成片。
+- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。
+- 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。
+- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
-### 平台化功能
-- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
-- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
-- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。
-- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
-- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
-- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
-- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
+### 平台化功能
+- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
+- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
+- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。
+- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
+- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
+- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
+- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
---
@@ -40,7 +40,7 @@
| 领域 | 核心技术 | 说明 |
|------|----------|------|
-| **前端** | Next.js 16 | TypeScript, TailwindCSS, SWR |
+| **前端** | Next.js 16 | TypeScript, TailwindCSS, SWR |
| **后端** | FastAPI | Python 3.10, AsyncIO, PM2 |
| **数据库** | Supabase | PostgreSQL, Storage (本地/S3), Auth |
| **唇形同步** | LatentSync 1.6 | PyTorch 2.5, Diffusers, DeepCache |
@@ -58,11 +58,11 @@
- **[部署手册 (DEPLOY_MANUAL.md)](Docs/DEPLOY_MANUAL.md)** - 👈 **部署请看这里**!包含完整的环境搭建步骤。
- [参考音频服务部署 (QWEN3_TTS_DEPLOY.md)](Docs/QWEN3_TTS_DEPLOY.md) - 声音克隆模型部署指南。
- [LatentSync 部署指南](models/LatentSync/DEPLOY.md) - 唇形同步模型独立部署。
-- [用户认证部署 (AUTH_DEPLOY.md)](Docs/AUTH_DEPLOY.md) - Supabase 与 Auth 系统配置。
+- [Supabase 部署指南 (SUPABASE_DEPLOY.md)](Docs/SUPABASE_DEPLOY.md) - Supabase 与认证系统配置。
-### 开发文档
-- [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。
-- [后端开发规范](Docs/BACKEND_DEV.md) - 分层约定与开发习惯。
+### 开发文档
+- [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。
+- [后端开发规范](Docs/BACKEND_DEV.md) - 分层约定与开发习惯。
- [前端开发指南](Docs/FRONTEND_DEV.md) - UI 组件与页面规范。
- [开发日志 (DevLogs)](Docs/DevLogs/) - 每日开发进度与技术决策记录。
@@ -74,9 +74,11 @@
ViGent2/
├── backend/ # FastAPI 后端服务
│ ├── app/ # 核心业务逻辑
-│ ├── scripts/ # 运维脚本 (Watchdog 等)
-│ └── tests/ # 测试用例
+│ ├── assets/ # 字体 / 样式 / BGM
+│ ├── user_data/ # 用户隔离数据 (Cookie 等)
+│ └── scripts/ # 运维脚本 (Watchdog 等)
├── frontend/ # Next.js 前端应用
+├── remotion/ # Remotion 视频渲染 (标题/字幕合成)
├── models/ # AI 模型仓库
│ ├── LatentSync/ # 唇形同步服务
│ └── Qwen3-TTS/ # 声音克隆服务
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 06a8be1..31c1156 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -30,7 +30,7 @@ class Settings(BaseSettings):
# Douyin Playwright 配置
DOUYIN_HEADLESS_MODE: str = "headless-new"
- DOUYIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ DOUYIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"
DOUYIN_LOCALE: str = "zh-CN"
DOUYIN_TIMEZONE_ID: str = "Asia/Shanghai"
DOUYIN_CHROME_PATH: str = "/usr/bin/google-chrome"
diff --git a/backend/app/modules/videos/schemas.py b/backend/app/modules/videos/schemas.py
index 5ddd76d..de27491 100644
--- a/backend/app/modules/videos/schemas.py
+++ b/backend/app/modules/videos/schemas.py
@@ -15,5 +15,7 @@ class GenerateRequest(BaseModel):
title_style_id: Optional[str] = None
subtitle_font_size: Optional[int] = None
title_font_size: Optional[int] = None
+ title_top_margin: Optional[int] = None
+ subtitle_bottom_margin: Optional[int] = None
bgm_id: Optional[str] = None
bgm_volume: Optional[float] = 0.2
diff --git a/backend/app/modules/videos/workflow.py b/backend/app/modules/videos/workflow.py
index 68e94a9..0166224 100644
--- a/backend/app/modules/videos/workflow.py
+++ b/backend/app/modules/videos/workflow.py
@@ -216,6 +216,16 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
title_style = {}
title_style["font_size"] = int(req.title_font_size)
+ if req.title_top_margin is not None and req.title:
+ if title_style is None:
+ title_style = {}
+ title_style["top_margin"] = int(req.title_top_margin)
+
+ if req.subtitle_bottom_margin is not None and req.enable_subtitles:
+ if subtitle_style is None:
+ subtitle_style = {}
+ subtitle_style["bottom_margin"] = int(req.subtitle_bottom_margin)
+
if use_remotion:
subtitle_style = prepare_style_for_remotion(
subtitle_style,
diff --git a/backend/app/services/publish_service.py b/backend/app/services/publish_service.py
index 938ab12..6e49526 100644
--- a/backend/app/services/publish_service.py
+++ b/backend/app/services/publish_service.py
@@ -17,20 +17,20 @@ from app.services.storage import storage_service
# Import platform uploaders
from .uploader.bilibili_uploader import BilibiliUploader
from .uploader.douyin_uploader import DouyinUploader
-from .uploader.xiaohongshu_uploader import XiaohongshuUploader
-from .uploader.weixin_uploader import WeixinUploader
+from .uploader.xiaohongshu_uploader import XiaohongshuUploader
+from .uploader.weixin_uploader import WeixinUploader
class PublishService:
"""Social media publishing service (with user isolation)"""
# 支持的平台配置
- PLATFORMS: Dict[str, Dict[str, Any]] = {
- "douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
- "weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": True},
- "bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
- "xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True},
- }
+ PLATFORMS: Dict[str, Dict[str, Any]] = {
+ "douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
+ "weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": True},
+ "bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
+ "xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True},
+ }
def __init__(self) -> None:
# 存储活跃的登录会话,用于跟踪登录状态
@@ -175,36 +175,36 @@ class PublishService:
tid=kwargs.get('tid', 122),
copyright=kwargs.get('copyright', 1)
)
- elif platform == "douyin":
- uploader = DouyinUploader(
- title=title,
- file_path=local_video_path,
- tags=tags,
- publish_date=publish_time,
- account_file=str(account_file),
- description=description,
- user_id=user_id,
- )
- elif platform == "xiaohongshu":
- uploader = XiaohongshuUploader(
- title=title,
- file_path=local_video_path,
- tags=tags,
- publish_date=publish_time,
- account_file=str(account_file),
- description=description
- )
- elif platform == "weixin":
- uploader = WeixinUploader(
- title=title,
- file_path=local_video_path,
- tags=tags,
- publish_date=publish_time,
- account_file=str(account_file),
- description=description,
- user_id=user_id,
- )
- else:
+ elif platform == "douyin":
+ uploader = DouyinUploader(
+ title=title,
+ file_path=local_video_path,
+ tags=tags,
+ publish_date=publish_time,
+ account_file=str(account_file),
+ description=description,
+ user_id=user_id,
+ )
+ elif platform == "xiaohongshu":
+ uploader = XiaohongshuUploader(
+ title=title,
+ file_path=local_video_path,
+ tags=tags,
+ publish_date=publish_time,
+ account_file=str(account_file),
+ description=description
+ )
+ elif platform == "weixin":
+ uploader = WeixinUploader(
+ title=title,
+ file_path=local_video_path,
+ tags=tags,
+ publish_date=publish_time,
+ account_file=str(account_file),
+ description=description,
+ user_id=user_id,
+ )
+ else:
logger.warning(f"[发布] {platform} 上传功能尚未实现")
return {
"success": False,
@@ -236,30 +236,38 @@ class PublishService:
async def login(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
"""
启动QR码登录流程
-
+
Args:
platform: 平台 ID
user_id: 用户 ID (用于 Cookie 隔离)
-
+
Returns:
dict: 包含二维码base64图片
"""
if platform not in self.PLATFORMS:
return {"success": False, "message": "不支持的平台"}
-
+
try:
from .qr_login_service import QRLoginService
-
+
# 获取用户专属的 Cookie 目录
cookies_dir = self._get_cookies_dir(user_id)
-
+
+ # 清理旧的活跃会话(避免残留会话干扰新登录)
+ session_key = self._get_session_key(platform, user_id)
+ if session_key in self.active_login_sessions:
+ old_service = self.active_login_sessions.pop(session_key)
+ try:
+ await old_service._cleanup()
+ except Exception:
+ pass
+
# 创建QR登录服务
qr_service = QRLoginService(platform, cookies_dir)
-
+
# 存储活跃会话 (带用户隔离)
- session_key = self._get_session_key(platform, user_id)
self.active_login_sessions[session_key] = qr_service
-
+
# 启动登录并获取二维码
result = await qr_service.start_login()
@@ -273,27 +281,28 @@ class PublishService:
}
def get_login_session_status(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
- """获取活跃登录会话的状态"""
+ """获取活跃登录会话的状态(仅用于扫码轮询)"""
session_key = self._get_session_key(platform, user_id)
-
- # 1. 如果有活跃的扫码会话,优先检查它
+
+ # 只检查活跃的扫码会话,不检查 Cookie 文件
+ # Cookie 文件检查会导致"重新登录"时误判为已登录
if session_key in self.active_login_sessions:
qr_service = self.active_login_sessions[session_key]
status = qr_service.get_login_status()
-
+
# 如果登录成功且Cookie已保存,清理会话
if status["success"] and status["cookies_saved"]:
del self.active_login_sessions[session_key]
return {"success": True, "message": "登录成功"}
-
- return {"success": False, "message": "等待扫码..."}
-
- # 2. 检查本地Cookie文件是否存在
- cookie_file = self._get_cookie_path(platform, user_id)
- if cookie_file.exists():
- return {"success": True, "message": "已登录 (历史状态)"}
-
- return {"success": False, "message": "未登录"}
+
+ # 刷脸验证:传递新二维码给前端
+ result: Dict[str, Any] = {"success": False, "message": "等待扫码..."}
+ if status.get("face_verify_qr"):
+ result["face_verify_qr"] = status["face_verify_qr"]
+ return result
+
+ # 没有活跃会话 → 返回 False(前端不应在无会话时轮询)
+ return {"success": False, "message": "无活跃登录会话"}
def logout(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
"""
diff --git a/backend/app/services/qr_login_service.py b/backend/app/services/qr_login_service.py
index dffbdaf..a5746fe 100644
--- a/backend/app/services/qr_login_service.py
+++ b/backend/app/services/qr_login_service.py
@@ -1,59 +1,67 @@
-"""
-QR码自动登录服务
-后端Playwright无头模式获取二维码,前端扫码后自动保存Cookie
-"""
+"""
+QR码自动登录服务
+后端Playwright无头模式获取二维码,前端扫码后自动保存Cookie
+"""
import asyncio
import time
-import base64
-import json
-from pathlib import Path
+import base64
+import json
+from pathlib import Path
from typing import Optional, Dict, Any, List, Sequence, Mapping, Union
from playwright.async_api import async_playwright, Page, Frame, BrowserContext, Browser, Playwright as PW
from loguru import logger
from app.core.config import settings
-
-
+
+
class QRLoginService:
- """QR码登录服务"""
-
- # 登录监控超时 (秒)
- LOGIN_TIMEOUT = 120
-
+ """QR码登录服务"""
+
+ # 登录监控超时 (秒)
+ LOGIN_TIMEOUT = 180
+
def __init__(self, platform: str, cookies_dir: Path) -> None:
self.platform = platform
- self.cookies_dir = cookies_dir
- self.qr_code_image: Optional[str] = None
- self.login_success: bool = False
- self.cookies_data: Optional[Dict[str, Any]] = None
-
- # Playwright 资源 (手动管理生命周期)
- self.playwright: Optional[PW] = None
- self.browser: Optional[Browser] = None
- self.context: Optional[BrowserContext] = None
-
- # 每个平台使用多个选择器 (使用逗号分隔,Playwright会同时等待它们)
+ self.cookies_dir = cookies_dir
+ self.qr_code_image: Optional[str] = None
+ self.login_success: bool = False
+ self.cookies_data: Optional[Dict[str, Any]] = None
+
+ # Playwright 资源 (手动管理生命周期)
+ self.playwright: Optional[PW] = None
+ self.browser: Optional[Browser] = None
+ self.context: Optional[BrowserContext] = None
+
+ # 抖音 check_qrconnect API 响应拦截
+ self._qr_api_confirmed: bool = False
+ self._qr_redirect_url: Optional[str] = None
+ self._douyin_needs_verify: bool = False # 需要APP验证
+
+ # 刷脸验证二维码(点击刷脸后页面展示新二维码,需要前端再次展示给用户)
+ self._face_verify_qr: Optional[str] = None # base64 截图
+
+ # 每个平台使用多个选择器 (使用逗号分隔,Playwright会同时等待它们)
self.platform_configs = {
- "bilibili": {
- "url": "https://passport.bilibili.com/login",
- "qr_selectors": [
- "div[class*='qrcode'] canvas", # 常见canvas二维码
- "div[class*='qrcode'] img", # 常见图片二维码
- ".qrcode-img img", # 旧版
- ".login-scan-box img", # 扫码框
- "div[class*='scan'] img"
- ],
- "success_indicator": "https://www.bilibili.com/"
- },
- "douyin": {
- "url": "https://creator.douyin.com/",
- "qr_selectors": [
- ".qrcode img", # 优先尝试
- "img[alt='qrcode']",
- "canvas[class*='qr']",
- "img[src*='qr']"
- ],
- "success_indicator": "https://creator.douyin.com/creator-micro"
- },
+ "bilibili": {
+ "url": "https://passport.bilibili.com/login",
+ "qr_selectors": [
+ "div[class*='qrcode'] canvas", # 常见canvas二维码
+ "div[class*='qrcode'] img", # 常见图片二维码
+ ".qrcode-img img", # 旧版
+ ".login-scan-box img", # 扫码框
+ "div[class*='scan'] img"
+ ],
+ "success_indicator": "https://www.bilibili.com/"
+ },
+ "douyin": {
+ "url": "https://creator.douyin.com/",
+ "qr_selectors": [
+ ".qrcode img", # 优先尝试
+ "img[alt='qrcode']",
+ "canvas[class*='qr']",
+ "img[src*='qr']"
+ ],
+ "success_indicator": "https://creator.douyin.com/creator-micro"
+ },
"xiaohongshu": {
"url": "https://creator.xiaohongshu.com/",
"qr_selectors": [
@@ -79,10 +87,15 @@ class QRLoginService:
}
def _resolve_headless_mode(self) -> str:
- if self.platform != "weixin":
- return "headless"
- mode = (settings.WEIXIN_HEADLESS_MODE or "").strip().lower()
- return mode or "headful"
+ # 抖音和微信使用 headful 模式(xvfb 虚拟显示),避免反爬检测
+ # 其他平台使用 headless-new
+ if self.platform == "douyin":
+ mode = (settings.DOUYIN_HEADLESS_MODE or "").strip().lower()
+ return mode or "headful"
+ if self.platform == "weixin":
+ mode = (settings.WEIXIN_HEADLESS_MODE or "").strip().lower()
+ return mode or "headful"
+ return "headless-new"
def _is_square_bbox(self, bbox: Optional[Dict[str, float]], min_side: int = 100) -> bool:
if not bbox:
@@ -158,20 +171,20 @@ class QRLoginService:
except Exception:
continue
return None
-
- async def start_login(self) -> Dict[str, Any]:
- """
- 启动登录流程
-
- Returns:
- dict: 包含二维码base64和状态
- """
- if self.platform not in self.platform_configs:
- return {"success": False, "message": "不支持的平台"}
-
- config = self.platform_configs[self.platform]
-
- try:
+
+ async def start_login(self) -> Dict[str, Any]:
+ """
+ 启动登录流程
+
+ Returns:
+ dict: 包含二维码base64和状态
+ """
+ if self.platform not in self.platform_configs:
+ return {"success": False, "message": "不支持的平台"}
+
+ config = self.platform_configs[self.platform]
+
+ try:
# 1. 启动 Playwright (不使用 async with,手动管理生命周期)
self.playwright = await async_playwright().start()
@@ -180,46 +193,66 @@ class QRLoginService:
launch_args = [
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
- '--disable-dev-shm-usage'
+ '--disable-dev-shm-usage',
]
if headless and mode in ("new", "headless-new", "headless_new"):
launch_args.append("--headless=new")
+ if not headless:
+ # headful 模式下 xvfb 没有 GPU,需要软件渲染
+ launch_args.extend([
+ '--use-gl=swiftshader',
+ '--disable-gpu',
+ ])
# Stealth模式启动浏览器
launch_options: Dict[str, Any] = {
"headless": headless,
"args": launch_args,
}
- if self.platform == "weixin":
+ # 根据平台选择对应的浏览器配置
+ if self.platform == "douyin":
+ chrome_path = (settings.DOUYIN_CHROME_PATH or "").strip()
+ browser_channel = (settings.DOUYIN_BROWSER_CHANNEL or "").strip()
+ user_agent = settings.DOUYIN_USER_AGENT
+ locale = settings.DOUYIN_LOCALE
+ timezone_id = settings.DOUYIN_TIMEZONE_ID
+ elif self.platform == "weixin":
chrome_path = (settings.WEIXIN_CHROME_PATH or "").strip()
- if chrome_path:
- if Path(chrome_path).exists():
- launch_options["executable_path"] = chrome_path
- else:
- logger.warning(f"[weixin] WEIXIN_CHROME_PATH not found: {chrome_path}")
- else:
- channel = (settings.WEIXIN_BROWSER_CHANNEL or "").strip()
- if channel:
- launch_options["channel"] = channel
+ browser_channel = (settings.WEIXIN_BROWSER_CHANNEL or "").strip()
+ user_agent = settings.WEIXIN_USER_AGENT
+ locale = settings.WEIXIN_LOCALE
+ timezone_id = settings.WEIXIN_TIMEZONE_ID
+ else:
+ # B站、小红书等:使用通用默认值
+ chrome_path = (settings.WEIXIN_CHROME_PATH or "").strip()
+ browser_channel = ""
+ user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ locale = "zh-CN"
+ timezone_id = "Asia/Shanghai"
+
+ if chrome_path and Path(chrome_path).exists():
+ launch_options["executable_path"] = chrome_path
+ elif browser_channel:
+ launch_options["channel"] = browser_channel
self.browser = await self.playwright.chromium.launch(**launch_options)
-
+
# 配置真实浏览器特征
self.context = await self.browser.new_context(
viewport={'width': 1920, 'height': 1080},
- user_agent=settings.WEIXIN_USER_AGENT,
- locale=settings.WEIXIN_LOCALE,
- timezone_id=settings.WEIXIN_TIMEZONE_ID
+ user_agent=user_agent,
+ locale=locale,
+ timezone_id=timezone_id
)
-
- page = await self.context.new_page()
-
- # 注入stealth.js
- stealth_path = Path(__file__).parent / 'uploader' / 'stealth.min.js'
- if stealth_path.exists():
- await page.add_init_script(path=str(stealth_path))
- logger.debug(f"[{self.platform}] Stealth模式已启用")
-
+
+ page = await self.context.new_page()
+
+ # 注入stealth.js
+ stealth_path = Path(__file__).parent / 'uploader' / 'stealth.min.js'
+ if stealth_path.exists():
+ await page.add_init_script(path=str(stealth_path))
+ logger.debug(f"[{self.platform}] Stealth模式已启用")
+
urls_to_try = [config["url"]]
if self.platform == "weixin":
urls_to_try = [
@@ -228,6 +261,60 @@ class QRLoginService:
]
qr_image = None
+
+ # 抖音:拦截 QR 登录相关 API 响应,检测登录成功
+ if self.platform == "douyin":
+ async def _on_douyin_qr_response(response):
+ try:
+ url = response.url or ""
+ if "check_qrconnect" not in url.lower():
+ return
+
+ body = None
+ try:
+ body = await response.json()
+ except Exception:
+ try:
+ text = await response.text()
+ import re as _re
+ m = _re.search(r'\{.*\}', text, _re.DOTALL)
+ if m:
+ body = json.loads(m.group())
+ except Exception:
+ pass
+
+ if not body:
+ return
+
+ data = body.get("data", {})
+ redirect_url = data.get("redirect_url", "")
+ status_val = data.get("status", "")
+ desc = data.get("description", body.get("description", ""))
+
+ logger.info(
+ f"[douyin][qr-poll] status={status_val} "
+ f"desc={desc[:60]} redirect={'yes' if redirect_url else 'no'}"
+ )
+
+ # 检测需要APP验证
+ if "完成验证" in desc or "验证后" in desc:
+ self._douyin_needs_verify = True
+ logger.warning("[douyin] 需要APP验证")
+
+ if self._qr_api_confirmed:
+ return
+
+ # 检测登录成功:出现 redirect_url
+ if redirect_url:
+ self._qr_redirect_url = redirect_url
+ self._qr_api_confirmed = True
+ logger.success(f"[douyin] 登录确认!redirect_url={redirect_url[:120]}")
+
+ except Exception as e:
+ logger.debug(f"[douyin][qr-poll] error: {e}")
+
+ page.on("response", _on_douyin_qr_response)
+
for url in urls_to_try:
logger.info(f"[{self.platform}] 打开登录页: {url}")
wait_until = "domcontentloaded" if self.platform == "weixin" else "networkidle"
@@ -240,72 +327,94 @@ class QRLoginService:
qr_image = await self._extract_qr_code(page, config["qr_selectors"])
if qr_image:
break
-
- if not qr_image:
- await self._cleanup()
- return {"success": False, "message": "未找到二维码"}
-
- logger.info(f"[{self.platform}] 二维码已获取,等待扫码...")
-
- # 启动后台监控任务 (浏览器保持开启)
- asyncio.create_task(
- self._monitor_login_status(page, config["success_indicator"])
- )
-
- return {
- "success": True,
- "qr_code": qr_image,
- "message": "请扫码登录"
- }
-
- except Exception as e:
- logger.exception(f"[{self.platform}] 启动登录失败: {e}")
- await self._cleanup()
- return {"success": False, "message": f"启动失败: {str(e)}"}
-
- async def _extract_qr_code(self, page: Page, selectors: List[str]) -> Optional[str]:
- """
- 提取二维码图片 (优化策略顺序)
- 根据日志分析:抖音和B站使用 Text 策略成功率最高
- """
- qr_element = None
-
- # 针对抖音和B站:优先使用 Text 策略 (成功率最高,速度最快)
- if self.platform in ("douyin", "bilibili"):
- # 尝试最多2次 (首次 + 1次重试)
- for attempt in range(2):
- if attempt > 0:
- logger.info(f"[{self.platform}] 等待页面加载后重试...")
- await asyncio.sleep(2)
-
- # 策略1: Text (优先,成功率最高)
- qr_element = await self._try_text_strategy(page)
- if qr_element:
- try:
- screenshot = await qr_element.screenshot()
- return base64.b64encode(screenshot).decode()
- except Exception as e:
- logger.warning(f"[{self.platform}] Text策略截图失败: {e}")
- qr_element = None
-
- # 策略2: CSS (备用)
- if not qr_element:
- try:
- combined_selector = ", ".join(selectors)
- logger.debug(f"[{self.platform}] 策略2(CSS): 开始等待...")
- # 增加超时到5秒,抖音页面加载较慢
- el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
- if el:
- logger.info(f"[{self.platform}] 策略2(CSS): 匹配成功")
- screenshot = await el.screenshot()
- return base64.b64encode(screenshot).decode()
- except Exception as e:
- logger.warning(f"[{self.platform}] 策略2(CSS) 失败: {e}")
-
- # 如果已成功,退出循环
- if qr_element:
- break
- else:
+
+ if not qr_image:
+ await self._cleanup()
+ return {"success": False, "message": "未找到二维码"}
+
+ logger.info(f"[{self.platform}] 二维码已获取,等待扫码...")
+
+ # 启动后台监控任务 (浏览器保持开启)
+ asyncio.create_task(
+ self._monitor_login_status(page, config["success_indicator"])
+ )
+
+ return {
+ "success": True,
+ "qr_code": qr_image,
+ "message": "请扫码登录"
+ }
+
+ except Exception as e:
+ logger.exception(f"[{self.platform}] 启动登录失败: {e}")
+ await self._cleanup()
+ return {"success": False, "message": f"启动失败: {str(e)}"}
+
+ async def _extract_qr_code(self, page: Page, selectors: List[str]) -> Optional[str]:
+ """
+ 提取二维码图片 (优化策略顺序)
+ 抖音:CSS 优先(Text 策略每次超时 15 秒)
+ B站:Text 优先
+ 其他:CSS -> Text
+ """
+ qr_element = None
+
+ if self.platform == "douyin":
+ # 抖音:CSS 优先,Text 备用(CSS 成功率高且快)
+ for attempt in range(2):
+ if attempt > 0:
+ logger.info(f"[{self.platform}] 等待页面加载后重试...")
+ await asyncio.sleep(2)
+
+ # 策略1: CSS (快速)
+ try:
+ combined_selector = ", ".join(selectors)
+ logger.debug(f"[{self.platform}] 策略CSS: 开始等待...")
+ el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
+ if el:
+ logger.info(f"[{self.platform}] 策略CSS: 匹配成功")
+ screenshot = await el.screenshot()
+ return base64.b64encode(screenshot).decode()
+ except Exception as e:
+ logger.warning(f"[{self.platform}] 策略CSS 失败: {e}")
+
+ # 策略2: Text (备用)
+ qr_element = await self._try_text_strategy(page)
+ if qr_element:
+ try:
+ screenshot = await qr_element.screenshot()
+ return base64.b64encode(screenshot).decode()
+ except Exception as e:
+ logger.warning(f"[{self.platform}] Text策略截图失败: {e}")
+
+ elif self.platform == "bilibili":
+ # B站:Text 优先
+ for attempt in range(2):
+ if attempt > 0:
+ logger.info(f"[{self.platform}] 等待页面加载后重试...")
+ await asyncio.sleep(2)
+
+ qr_element = await self._try_text_strategy(page)
+ if qr_element:
+ try:
+ screenshot = await qr_element.screenshot()
+ return base64.b64encode(screenshot).decode()
+ except Exception as e:
+ logger.warning(f"[{self.platform}] Text策略截图失败: {e}")
+ qr_element = None
+
+ if not qr_element:
+ try:
+ combined_selector = ", ".join(selectors)
+ logger.debug(f"[{self.platform}] 策略CSS: 开始等待...")
+ el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
+ if el:
+ logger.info(f"[{self.platform}] 策略CSS: 匹配成功")
+ screenshot = await el.screenshot()
+ return base64.b64encode(screenshot).decode()
+ except Exception as e:
+ logger.warning(f"[{self.platform}] 策略CSS 失败: {e}")
+ else:
# 其他平台 (小红书/微信等):保持原顺序 CSS -> Text
# 策略1: CSS 选择器
try:
@@ -328,36 +437,31 @@ class QRLoginService:
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
except Exception as e:
logger.warning(f"[{self.platform}] 策略1(CSS) 失败: {e}")
-
- # 策略2: Text
+
+ # 策略2: Text
if not qr_element:
qr_element = await self._try_text_strategy(page)
if not qr_element and self.platform == "weixin":
qr_element = await self._try_text_strategy_in_frames(page)
-
- # 如果找到元素,截图返回
- if qr_element:
- try:
- screenshot = await qr_element.screenshot()
- return base64.b64encode(screenshot).decode()
- except Exception as e:
- logger.error(f"[{self.platform}] 截图失败: {e}")
-
- # 所有策略失败
- logger.error(f"[{self.platform}] 所有QR码提取策略失败")
-
- # 保存调试截图
- debug_dir = Path(__file__).parent.parent.parent / 'debug_screenshots'
- debug_dir.mkdir(exist_ok=True)
- await page.screenshot(path=str(debug_dir / f"{self.platform}_debug.png"))
-
- return None
-
+
+ # 如果找到元素,截图返回
+ if qr_element:
+ try:
+ screenshot = await qr_element.screenshot()
+ return base64.b64encode(screenshot).decode()
+ except Exception as e:
+ logger.error(f"[{self.platform}] 截图失败: {e}")
+
+ # 所有策略失败
+ logger.error(f"[{self.platform}] 所有QR码提取策略失败")
+
+ return None
+
async def _try_text_strategy(self, page: Union[Page, Frame]) -> Optional[Any]:
- """基于文本查找二维码图片"""
- try:
- logger.debug(f"[{self.platform}] 策略Text: 开始搜索...")
+ """基于文本查找二维码图片"""
+ try:
+ logger.debug(f"[{self.platform}] 策略Text: 开始搜索...")
keywords = [
"扫码登录",
"二维码",
@@ -368,138 +472,265 @@ class QRLoginService:
"请使用微信扫码",
"视频号"
]
-
- for kw in keywords:
- try:
- text_el = page.get_by_text(kw, exact=False).first
- await text_el.wait_for(state="visible", timeout=2000)
-
- # 向上查找图片
- parent = text_el
- for _ in range(5):
- parent = parent.locator("..")
+
+ for kw in keywords:
+ try:
+ text_el = page.get_by_text(kw, exact=False).first
+ await text_el.wait_for(state="visible", timeout=2000)
+
+ # 向上查找图片
+ parent = text_el
+ for _ in range(5):
+ parent = parent.locator("..")
candidates = parent.locator("img, canvas")
min_side = 120 if self.platform == "weixin" else 100
best = await self._pick_best_candidate(candidates, min_side=min_side)
if best:
logger.info(f"[{self.platform}] 策略Text: 成功")
return best
- except Exception:
- continue
- except Exception as e:
- logger.warning(f"[{self.platform}] 策略Text 失败: {e}")
- return None
-
- async def _monitor_login_status(self, page: Page, success_url: str):
- """监控登录状态"""
- try:
- logger.info(f"[{self.platform}] 开始监控登录状态...")
+ except Exception:
+ continue
+ except Exception as e:
+ logger.warning(f"[{self.platform}] 策略Text 失败: {e}")
+ return None
+
+ async def _monitor_login_status(self, page: Page, success_url: str):
+ """监控登录状态 — 简洁版
+
+ 策略:
+ 1. 监听页面 URL 变化和 session cookie 出现(通用,适用所有平台)
+ 2. 抖音特殊:如果 API 拦截到 redirect_url,直接导航过去拿 cookie
+ 3. 抖音特殊:如果需要APP验证且JS轮询停了,等用户验证完后
+ 用 page.goto 重新访问首页,让服务器分配 session
+ """
+ try:
+ logger.info(f"[{self.platform}] 开始监控登录状态...")
key_cookies = {
"bilibili": ["SESSDATA"],
- "douyin": ["sessionid"],
+ "douyin": ["sessionid", "sessionid_ss", "sid_guard", "sid_tt", "uid_tt"],
"xiaohongshu": ["web_session"],
- "weixin": [
- "wxuin",
- "wxsid",
- "pass_ticket",
- "webwx_data_ticket",
- "uin",
- "skey",
- "p_uin",
- "p_skey",
- "pac_uid",
- ],
+ "weixin": ["wxuin", "wxsid", "pass_ticket", "uin", "skey",
+ "p_uin", "p_skey", "pac_uid"],
}
target_cookies = key_cookies.get(self.platform, [])
-
- for i in range(self.LOGIN_TIMEOUT):
- await asyncio.sleep(1)
-
- try:
- if not self.context: break # 避免意外关闭
-
- cookies = [dict(cookie) for cookie in await self.context.cookies()]
- current_url = page.url
- has_cookie = any((c.get('name') in target_cookies) for c in cookies) if target_cookies else False
-
- if i % 5 == 0:
- logger.debug(f"[{self.platform}] 等待登录... HasCookie: {has_cookie}")
-
- if success_url in current_url or has_cookie:
- logger.success(f"[{self.platform}] 登录成功!")
- self.login_success = True
- await asyncio.sleep(2) # 缓冲
-
- # 保存Cookie
- final_cookies = [dict(cookie) for cookie in await self.context.cookies()]
- await self._save_cookies(final_cookies)
- break
-
- except Exception as e:
- logger.warning(f"[{self.platform}] 监控循环警告: {e}")
- break
-
- if not self.login_success:
- logger.warning(f"[{self.platform}] 登录超时")
-
- except Exception as e:
- logger.error(f"[{self.platform}] 监控异常: {e}")
- finally:
- await self._cleanup()
-
- async def _cleanup(self) -> None:
- """清理资源"""
- if self.context:
- try:
- await self.context.close()
- except Exception:
- pass
- self.context = None
- if self.browser:
- try:
- await self.browser.close()
- except Exception:
- pass
- self.browser = None
- if self.playwright:
- try:
- await self.playwright.stop()
- except Exception:
- pass
- self.playwright = None
-
+ initial_url = page.url
+ _verify_detected_at: Optional[int] = None # 检测到需要验证的时间点(循环计数)
+
+ for i in range(self.LOGIN_TIMEOUT):
+ await asyncio.sleep(1)
+ if not self.context:
+ break
+
+ try:
+ # ── 检查 session cookie ──
+ cookies = [dict(c) for c in await self.context.cookies()]
+ cookie_names = [c.get("name") for c in cookies]
+ has_session = any(n in cookie_names for n in target_cookies) if target_cookies else False
+ current_url = page.url
+
+ # 每10秒打一次日志
+ if i % 10 == 0:
+ logger.info(
+ f"[{self.platform}] 等待登录... i={i} "
+ f"URL={current_url[:80]} session={has_session} "
+ f"cookies={len(cookies)}"
+ )
+
+ # ── 成功条件:有 session cookie ──
+ if has_session:
+ logger.success(f"[{self.platform}] 登录成功!检测到session cookie")
+ self.login_success = True
+ await asyncio.sleep(2)
+ final = [dict(c) for c in await self.context.cookies()]
+ await self._save_cookies(final)
+ break
+
+ # ── 成功条件:URL 跳转到目标页 ──
+ if success_url in current_url:
+ logger.success(f"[{self.platform}] 登录成功!URL={current_url[:80]}")
+ self.login_success = True
+ await asyncio.sleep(2)
+ final = [dict(c) for c in await self.context.cookies()]
+ await self._save_cookies(final)
+ break
+
+ # ── 抖音:API 拦截到 redirect_url → 直接导航 ──
+ if self.platform == "douyin" and self._qr_api_confirmed and self._qr_redirect_url:
+ logger.info(f"[douyin] 导航到 redirect_url...")
+ try:
+ await page.goto(self._qr_redirect_url, wait_until="domcontentloaded", timeout=30000)
+ except Exception:
+ pass
+ await asyncio.sleep(3)
+ # 重置,下一轮循环会检查 cookie
+ self._qr_api_confirmed = False
+ self._qr_redirect_url = None
+ continue
+
+ # ── 抖音:需要APP验证,点击"手机刷脸验证"选项 ──
+ if self.platform == "douyin" and self._douyin_needs_verify:
+ if _verify_detected_at is None:
+ _verify_detected_at = i
+ logger.info("[douyin] 检测到身份验证弹窗,将点击手机刷脸验证...")
+
+ elapsed = i - _verify_detected_at
+ # 第一次:点击"手机刷脸验证"选项
+ if elapsed == 2:
+ try:
+ clicked = await page.evaluate("""() => {
+ // 查找身份验证弹窗中的选项
+ const allEls = document.querySelectorAll('div, span, p, a, li');
+ for (const el of allEls) {
+ const text = (el.textContent || '').trim();
+ // 点击"手机刷脸验证"
+ if (text.includes('刷脸验证') && text.length < 30) {
+ el.click();
+ return '刷脸验证';
+ }
+ }
+ return null;
+ }""")
+ if clicked:
+ logger.info(f"[douyin] 已点击验证选项: {clicked}")
+ else:
+ logger.warning("[douyin] 未找到验证选项")
+ except Exception as e:
+ logger.warning(f"[douyin] 点击验证选项异常: {e}")
+
+ # 点击后等待新二维码出现,提取弹窗内二维码截图
+ if elapsed == 5 and not self._face_verify_qr:
+ try:
+ # 用 JS 在"刷脸验证"弹窗内找最大的正方形 img(即二维码,跳过头像)
+ qr_selector = await page.evaluate("""() => {
+ // 找到包含"刷脸验证"文字的弹窗
+ const allEls = document.querySelectorAll('div, h2, h3, span, p');
+ let modal = null;
+ for (const el of allEls) {
+ const text = (el.textContent || '').trim();
+ if (text.includes('刷脸验证') && text.length < 20) {
+ modal = el;
+ for (let i = 0; i < 8; i++) {
+ if (!modal.parentElement) break;
+ modal = modal.parentElement;
+ if (modal.offsetWidth > 250 && modal.offsetHeight > 250) break;
+ }
+ break;
+ }
+ }
+ if (!modal) return null;
+
+ // 用 offsetWidth/Height(显示尺寸)而非 naturalWidth(源文件可能很大)
+ const imgs = modal.querySelectorAll('img');
+ let best = null;
+ let bestArea = 0;
+ for (const img of imgs) {
+ const w = img.offsetWidth;
+ const h = img.offsetHeight;
+ if (w < 80 || h < 80) continue;
+ const ratio = Math.abs(w - h) / Math.max(w, h);
+ if (ratio > 0.3) continue;
+ const area = w * h;
+ if (area > bestArea) {
+ bestArea = area;
+ best = img;
+ }
+ }
+ if (best) {
+ best.setAttribute('data-face-qr', 'true');
+ return 'img[data-face-qr="true"]';
+ }
+ return null;
+ }""")
+
+ if qr_selector:
+ qr_el = page.locator(qr_selector).first
+ if await qr_el.is_visible():
+ screenshot = await qr_el.screenshot()
+ self._face_verify_qr = base64.b64encode(screenshot).decode()
+ logger.info("[douyin] 刷脸弹窗内二维码截图已捕获")
+ else:
+ logger.warning("[douyin] 二维码元素不可见")
+
+ if not self._face_verify_qr:
+ # 兜底:整页截图
+ logger.warning("[douyin] 未在弹窗内找到二维码,使用全页截图")
+ screenshot = await page.screenshot()
+ self._face_verify_qr = base64.b64encode(screenshot).decode()
+ except Exception as e:
+ logger.warning(f"[douyin] 截取刷脸二维码异常: {e}")
+
+ # 之后每10秒打一次日志
+ if elapsed > 0 and elapsed % 10 == 0:
+ logger.info(f"[douyin] 等待用户完成手机验证... ({elapsed}s)")
+
+ except Exception as e:
+ logger.warning(f"[{self.platform}] 监控异常: {e}")
+
+ if not self.login_success:
+ logger.warning(f"[{self.platform}] 登录超时")
+
+ except Exception as e:
+ logger.error(f"[{self.platform}] 监控异常: {e}")
+ finally:
+ await self._cleanup()
+
+ async def _cleanup(self) -> None:
+ """清理资源"""
+ if self.context:
+ try:
+ await self.context.close()
+ except Exception:
+ pass
+ self.context = None
+ if self.browser:
+ try:
+ await self.browser.close()
+ except Exception:
+ pass
+ self.browser = None
+ if self.playwright:
+ try:
+ await self.playwright.stop()
+ except Exception:
+ pass
+ self.playwright = None
+
async def _save_cookies(self, cookies: Sequence[Mapping[str, Any]]) -> None:
- """保存Cookie到文件"""
- try:
- cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
-
- if self.platform == "bilibili":
- # Bilibili 使用简单格式 (biliup库需要)
+ """保存Cookie到文件"""
+ try:
+ cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
+
+ if self.platform == "bilibili":
+ # Bilibili 使用简单格式 (biliup库需要)
cookie_dict = {c.get('name'): c.get('value') for c in cookies if c.get('name')}
- required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
- cookie_dict = {k: v for k, v in cookie_dict.items() if k in required}
-
- with open(cookie_file, 'w', encoding='utf-8') as f:
- json.dump(cookie_dict, f, indent=2)
- self.cookies_data = cookie_dict
- else:
- # Douyin/Xiaohongshu 使用 Playwright storage_state 完整格式
- # 这样可以直接用 browser.new_context(storage_state=file)
- storage_state = {
- "cookies": cookies,
- "origins": []
- }
- with open(cookie_file, 'w', encoding='utf-8') as f:
- json.dump(storage_state, f, indent=2)
- self.cookies_data = storage_state
-
- logger.success(f"[{self.platform}] Cookie已保存")
- except Exception as e:
- logger.error(f"[{self.platform}] 保存Cookie失败: {e}")
-
- def get_login_status(self) -> Dict[str, Any]:
- """获取登录状态"""
- return {
- "success": self.login_success,
- "cookies_saved": self.cookies_data is not None
- }
+ required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
+ cookie_dict = {k: v for k, v in cookie_dict.items() if k in required}
+
+ with open(cookie_file, 'w', encoding='utf-8') as f:
+ json.dump(cookie_dict, f, indent=2)
+ self.cookies_data = cookie_dict
+ else:
+ # Douyin/Xiaohongshu 使用 Playwright storage_state 完整格式
+ # 这样可以直接用 browser.new_context(storage_state=file)
+ storage_state = {
+ "cookies": cookies,
+ "origins": []
+ }
+ with open(cookie_file, 'w', encoding='utf-8') as f:
+ json.dump(storage_state, f, indent=2)
+ self.cookies_data = storage_state
+
+ logger.success(f"[{self.platform}] Cookie已保存")
+ except Exception as e:
+ logger.error(f"[{self.platform}] 保存Cookie失败: {e}")
+
+ def get_login_status(self) -> Dict[str, Any]:
+ """获取登录状态"""
+ result: Dict[str, Any] = {
+ "success": self.login_success,
+ "cookies_saved": self.cookies_data is not None
+ }
+ # 刷脸验证:返回新二维码截图给前端展示
+ if self._face_verify_qr:
+ result["face_verify_qr"] = self._face_verify_qr
+ return result
diff --git a/backend/app/services/uploader/weixin_uploader.py b/backend/app/services/uploader/weixin_uploader.py
index 1544556..41a8b63 100644
--- a/backend/app/services/uploader/weixin_uploader.py
+++ b/backend/app/services/uploader/weixin_uploader.py
@@ -127,6 +127,22 @@ class WeixinUploader(BaseUploader):
return False
def _attach_debug_listeners(self, page) -> None:
+ # post_create 响应监听始终注册(不依赖 debug 开关)
+ def log_post_create(response):
+ try:
+ url = response.url or ""
+ if "/post/post_create" in url:
+ if response.status < 400:
+ self._post_create_submitted = True
+ logger.info("[weixin][publish] post_create API ok")
+ else:
+ self._publish_api_error = f"发布请求失败(HTTP {response.status})"
+ logger.warning(f"[weixin][publish] post_create_failed status={response.status}")
+ except Exception:
+ pass
+
+ page.on("response", log_post_create)
+
if not self._debug_artifacts_enabled():
return
@@ -1210,15 +1226,7 @@ class WeixinUploader(BaseUploader):
return False
async def _wait_for_publish_result(self, page):
- success_texts = [
- "\u53d1\u5e03\u6210\u529f",
- "\u53d1\u5e03\u5b8c\u6210",
- "\u5df2\u53d1\u5e03",
- "\u5ba1\u6838\u4e2d",
- "\u5f85\u5ba1\u6838",
- "\u63d0\u4ea4\u6210\u529f",
- "\u5df2\u63d0\u4ea4",
- ]
+ """点击发表后等待结果:页面离开创建页即视为成功"""
failure_texts = [
"\u53d1\u5e03\u5931\u8d25",
"\u53d1\u5e03\u5f02\u5e38",
@@ -1229,38 +1237,33 @@ class WeixinUploader(BaseUploader):
"\u7f51\u7edc\u5f02\u5e38",
]
+ # 记录点击发表时的 URL,用于判断是否跳转
+ create_url = page.url
start_time = time.time()
- last_capture = -1
+
while time.time() - start_time < self.PUBLISH_TIMEOUT:
current_url = page.url
+ # API 层面报错 → 直接失败
if self._publish_api_error:
return False, self._publish_api_error, False
- if self._post_create_submitted and (
- "/post/list" in current_url
- or "/platform/post/list" in current_url
- ):
- return True, "发布成功:已进入内容列表", False
+ # 核心判定:URL 离开了创建页(跳转到列表页或其他页面)→ 发布成功
+ if current_url != create_url and "/post/create" not in current_url:
+ logger.info(f"[weixin] page navigated away from create page: {current_url}")
+ return True, "发布成功:页面已跳转", False
- if "channels.weixin.qq.com/platform" in current_url:
- for text in success_texts:
- if await self._is_text_visible(page, text, exact=False):
- return True, f"发布成功:{text}", False
+ # post_create API 已确认成功 → 也视为成功
+ if self._post_create_submitted:
+ logger.info("[weixin] post_create API confirmed success")
+ return True, "发布成功:API 已确认", False
+ # 检查页面上的失败文案
for text in failure_texts:
if await self._is_text_visible(page, text, exact=False):
return False, f"发布失败:{text}", False
- for text in success_texts:
- if await self._is_text_visible(page, text, exact=False):
- return True, f"发布成功:{text}", False
-
logger.info("[weixin] waiting for publish result...")
- elapsed = int(time.time() - start_time)
- if elapsed % 20 == 0 and elapsed != last_capture:
- last_capture = elapsed
- await self._save_debug_screenshot(page, "publish_waiting")
await asyncio.sleep(self.POLL_INTERVAL)
return False, "发布超时", True
diff --git a/frontend/src/features/home/model/useHomeController.ts b/frontend/src/features/home/model/useHomeController.ts
index cb431e0..7e85655 100644
--- a/frontend/src/features/home/model/useHomeController.ts
+++ b/frontend/src/features/home/model/useHomeController.ts
@@ -99,13 +99,15 @@ export const useHomeController = () => {
const [enableSubtitles, setEnableSubtitles] = useState(true);
const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState("");
const [selectedTitleStyleId, setSelectedTitleStyleId] = useState("");
- const [subtitleFontSize, setSubtitleFontSize] = useState(60);
- const [titleFontSize, setTitleFontSize] = useState(90);
+ const [subtitleFontSize, setSubtitleFontSize] = useState(80);
+ const [titleFontSize, setTitleFontSize] = useState(120);
const [subtitleSizeLocked, setSubtitleSizeLocked] = useState(false);
const [titleSizeLocked, setTitleSizeLocked] = useState(false);
+ const [titleTopMargin, setTitleTopMargin] = useState(62);
+ const [subtitleBottomMargin, setSubtitleBottomMargin] = useState(80);
const [showStylePreview, setShowStylePreview] = useState(false);
const [materialDimensions, setMaterialDimensions] = useState<{ width: number; height: number } | null>(null);
- const [previewContainerWidth, setPreviewContainerWidth] = useState(0);
+
// 背景音乐相关状态
const [selectedBgmId, setSelectedBgmId] = useState("");
@@ -124,7 +126,7 @@ export const useHomeController = () => {
const [editMaterialName, setEditMaterialName] = useState("");
const bgmItemRefs = useRef>({});
const bgmListContainerRef = useRef(null);
- const titlePreviewContainerRef = useRef(null);
+
const materialItemRefs = useRef>({});
const videoItemRefs = useRef>({});
@@ -354,6 +356,10 @@ export const useHomeController = () => {
setTitleFontSize,
setSubtitleSizeLocked,
setTitleSizeLocked,
+ titleTopMargin,
+ setTitleTopMargin,
+ subtitleBottomMargin,
+ setSubtitleBottomMargin,
selectedBgmId,
setSelectedBgmId,
bgmVolume,
@@ -446,25 +452,6 @@ export const useHomeController = () => {
};
}, [materials, selectedMaterial]);
- useEffect(() => {
- if (!showStylePreview) return;
- const container = titlePreviewContainerRef.current;
- if (!container) return;
-
- setPreviewContainerWidth(container.getBoundingClientRect().width);
-
- const resizeObserver = new ResizeObserver((entries) => {
- for (const entry of entries) {
- setPreviewContainerWidth(entry.contentRect.width);
- }
- });
-
- resizeObserver.observe(container);
-
- return () => {
- resizeObserver.disconnect();
- };
- }, [showStylePreview]);
useEffect(() => {
if (subtitleSizeLocked || subtitleStyles.length === 0) return;
@@ -707,6 +694,14 @@ export const useHomeController = () => {
payload.title_font_size = Math.round(titleFontSize);
}
+ if (videoTitle.trim()) {
+ payload.title_top_margin = Math.round(titleTopMargin);
+ }
+
+ if (enableSubtitles) {
+ payload.subtitle_bottom_margin = Math.round(subtitleBottomMargin);
+ }
+
if (enableBgm && selectedBgmId) {
payload.bgm_id = selectedBgmId;
payload.bgm_volume = bgmVolume;
@@ -810,14 +805,16 @@ export const useHomeController = () => {
subtitleFontSize,
setSubtitleFontSize,
setSubtitleSizeLocked,
+ titleTopMargin,
+ setTitleTopMargin,
+ subtitleBottomMargin,
+ setSubtitleBottomMargin,
enableSubtitles,
setEnableSubtitles,
resolveAssetUrl,
getFontFormat,
buildTextShadow,
- previewContainerWidth,
materialDimensions,
- titlePreviewContainerRef,
ttsMode,
setTtsMode,
voices: VOICES,
diff --git a/frontend/src/features/home/model/useHomePersistence.ts b/frontend/src/features/home/model/useHomePersistence.ts
index 4aca830..78cc99d 100644
--- a/frontend/src/features/home/model/useHomePersistence.ts
+++ b/frontend/src/features/home/model/useHomePersistence.ts
@@ -35,6 +35,10 @@ interface UseHomePersistenceOptions {
setTitleFontSize: React.Dispatch>;
setSubtitleSizeLocked: React.Dispatch>;
setTitleSizeLocked: React.Dispatch>;
+ titleTopMargin: number;
+ setTitleTopMargin: React.Dispatch>;
+ subtitleBottomMargin: number;
+ setSubtitleBottomMargin: React.Dispatch>;
selectedBgmId: string;
setSelectedBgmId: React.Dispatch>;
bgmVolume: number;
@@ -71,6 +75,10 @@ export const useHomePersistence = ({
setTitleFontSize,
setSubtitleSizeLocked,
setTitleSizeLocked,
+ titleTopMargin,
+ setTitleTopMargin,
+ subtitleBottomMargin,
+ setSubtitleBottomMargin,
selectedBgmId,
setSelectedBgmId,
bgmVolume,
@@ -100,6 +108,8 @@ export const useHomePersistence = ({
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
const savedBgmVolume = localStorage.getItem(`vigent_${storageKey}_bgmVolume`);
const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`);
+ const savedTitleTopMargin = localStorage.getItem(`vigent_${storageKey}_titleTopMargin`);
+ const savedSubtitleBottomMargin = localStorage.getItem(`vigent_${storageKey}_subtitleBottomMargin`);
setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。");
setVideoTitle(savedTitle ? clampTitle(savedTitle) : "");
@@ -132,6 +142,15 @@ export const useHomePersistence = ({
if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true');
if (savedSelectedVideoId) setSelectedVideoId(savedSelectedVideoId);
+ if (savedTitleTopMargin) {
+ const parsed = parseInt(savedTitleTopMargin, 10);
+ if (!Number.isNaN(parsed)) setTitleTopMargin(parsed);
+ }
+ if (savedSubtitleBottomMargin) {
+ const parsed = parseInt(savedSubtitleBottomMargin, 10);
+ if (!Number.isNaN(parsed)) setSubtitleBottomMargin(parsed);
+ }
+
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsRestored(true);
}, [
@@ -149,6 +168,8 @@ export const useHomePersistence = ({
setText,
setTitleFontSize,
setTitleSizeLocked,
+ setTitleTopMargin,
+ setSubtitleBottomMargin,
setTtsMode,
setVideoTitle,
setVoice,
@@ -213,6 +234,18 @@ export const useHomePersistence = ({
}
}, [titleFontSize, storageKey, isRestored]);
+ useEffect(() => {
+ if (isRestored) {
+ localStorage.setItem(`vigent_${storageKey}_titleTopMargin`, String(titleTopMargin));
+ }
+ }, [titleTopMargin, storageKey, isRestored]);
+
+ useEffect(() => {
+ if (isRestored) {
+ localStorage.setItem(`vigent_${storageKey}_subtitleBottomMargin`, String(subtitleBottomMargin));
+ }
+ }, [subtitleBottomMargin, storageKey, isRestored]);
+
useEffect(() => {
if (isRestored) {
localStorage.setItem(`vigent_${storageKey}_bgmId`, selectedBgmId);
diff --git a/frontend/src/features/home/ui/FloatingStylePreview.tsx b/frontend/src/features/home/ui/FloatingStylePreview.tsx
new file mode 100644
index 0000000..becedfd
--- /dev/null
+++ b/frontend/src/features/home/ui/FloatingStylePreview.tsx
@@ -0,0 +1,226 @@
+import { useEffect } from "react";
+import { createPortal } from "react-dom";
+import { X } from "lucide-react";
+
+interface SubtitleStyleOption {
+ id: string;
+ label: string;
+ font_family?: string;
+ font_file?: string;
+ font_size?: number;
+ highlight_color?: string;
+ normal_color?: string;
+ stroke_color?: string;
+ stroke_size?: number;
+ letter_spacing?: number;
+ bottom_margin?: number;
+ is_default?: boolean;
+}
+
+interface TitleStyleOption {
+ id: string;
+ label: string;
+ font_family?: string;
+ font_file?: string;
+ font_size?: number;
+ color?: string;
+ stroke_color?: string;
+ stroke_size?: number;
+ letter_spacing?: number;
+ font_weight?: number;
+ top_margin?: number;
+ is_default?: boolean;
+}
+
+interface FloatingStylePreviewProps {
+ onClose: () => void;
+ videoTitle: string;
+ titleStyles: TitleStyleOption[];
+ selectedTitleStyleId: string;
+ titleFontSize: number;
+ subtitleStyles: SubtitleStyleOption[];
+ selectedSubtitleStyleId: string;
+ subtitleFontSize: number;
+ titleTopMargin: number;
+ subtitleBottomMargin: number;
+ enableSubtitles: boolean;
+ resolveAssetUrl: (path?: string | null) => string | null;
+ getFontFormat: (fontFile?: string) => string;
+ buildTextShadow: (color: string, size: number) => string;
+ previewBaseWidth: number;
+ previewBaseHeight: number;
+}
+
+const DESKTOP_WIDTH = 280;
+
+export function FloatingStylePreview({
+ onClose,
+ videoTitle,
+ titleStyles,
+ selectedTitleStyleId,
+ titleFontSize,
+ subtitleStyles,
+ selectedSubtitleStyleId,
+ subtitleFontSize,
+ titleTopMargin,
+ subtitleBottomMargin,
+ enableSubtitles,
+ resolveAssetUrl,
+ getFontFormat,
+ buildTextShadow,
+ previewBaseWidth,
+ previewBaseHeight,
+}: FloatingStylePreviewProps) {
+ const isMobile = typeof window !== "undefined" && window.innerWidth < 640;
+ const windowWidth = isMobile
+ ? Math.min(window.innerWidth - 32, 360)
+ : DESKTOP_WIDTH;
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [onClose]);
+
+ const previewScale = windowWidth / previewBaseWidth;
+ const previewHeight = previewBaseHeight * previewScale;
+
+ const activeSubtitleStyle = subtitleStyles.find((s) => s.id === selectedSubtitleStyleId)
+ || subtitleStyles.find((s) => s.is_default)
+ || subtitleStyles[0];
+
+ const activeTitleStyle = titleStyles.find((s) => s.id === selectedTitleStyleId)
+ || titleStyles.find((s) => s.is_default)
+ || titleStyles[0];
+
+ const previewTitleText = videoTitle.trim() || "这里是标题预览";
+ const subtitleHighlightText = "最近,一个叫Cloudbot";
+ const subtitleNormalText = "的开源项目在GitHub上彻底火了";
+
+ const subtitleHighlightColor = activeSubtitleStyle?.highlight_color || "#FFE600";
+ const subtitleNormalColor = activeSubtitleStyle?.normal_color || "#FFFFFF";
+ const subtitleStrokeColor = activeSubtitleStyle?.stroke_color || "#000000";
+ const subtitleStrokeSize = activeSubtitleStyle?.stroke_size ?? 3;
+ const subtitleLetterSpacing = activeSubtitleStyle?.letter_spacing ?? 2;
+ const subtitleFontFamilyName = `SubtitlePreview-${activeSubtitleStyle?.id || "default"}`;
+ const subtitleFontUrl = activeSubtitleStyle?.font_file
+ ? resolveAssetUrl(`fonts/${activeSubtitleStyle.font_file}`)
+ : null;
+
+ const titleColor = activeTitleStyle?.color || "#FFFFFF";
+ const titleStrokeColor = activeTitleStyle?.stroke_color || "#000000";
+ const titleStrokeSize = activeTitleStyle?.stroke_size ?? 8;
+ const titleLetterSpacing = activeTitleStyle?.letter_spacing ?? 4;
+ const titleFontWeight = activeTitleStyle?.font_weight ?? 900;
+ const titleFontFamilyName = `TitlePreview-${activeTitleStyle?.id || "default"}`;
+ const titleFontUrl = activeTitleStyle?.font_file
+ ? resolveAssetUrl(`fonts/${activeTitleStyle.font_file}`)
+ : null;
+
+ const content = (
+
+ {/* 标题栏 */}
+
+
+ {/* 预览内容 */}
+
+ {(titleFontUrl || subtitleFontUrl) && (
+
+ )}
+
+
+
+ {previewTitleText}
+
+
+
+ {enableSubtitles ? (
+ <>
+ {subtitleHighlightText}
+ {subtitleNormalText}
+ >
+ ) : (
+ 字幕已关闭
+ )}
+
+
+
+
+ );
+
+ return createPortal(content, document.body);
+}
diff --git a/frontend/src/features/home/ui/HomePage.tsx b/frontend/src/features/home/ui/HomePage.tsx
index 25ed564..00e3627 100644
--- a/frontend/src/features/home/ui/HomePage.tsx
+++ b/frontend/src/features/home/ui/HomePage.tsx
@@ -65,14 +65,16 @@ export function HomePage() {
subtitleFontSize,
setSubtitleFontSize,
setSubtitleSizeLocked,
+ titleTopMargin,
+ setTitleTopMargin,
+ subtitleBottomMargin,
+ setSubtitleBottomMargin,
enableSubtitles,
setEnableSubtitles,
resolveAssetUrl,
getFontFormat,
buildTextShadow,
- previewContainerWidth,
materialDimensions,
- titlePreviewContainerRef,
ttsMode,
setTtsMode,
voices,
@@ -201,20 +203,17 @@ export function HomePage() {
setSubtitleFontSize(value);
setSubtitleSizeLocked(true);
}}
+ titleTopMargin={titleTopMargin}
+ onTitleTopMarginChange={setTitleTopMargin}
+ subtitleBottomMargin={subtitleBottomMargin}
+ onSubtitleBottomMarginChange={setSubtitleBottomMargin}
enableSubtitles={enableSubtitles}
onToggleSubtitles={setEnableSubtitles}
resolveAssetUrl={resolveAssetUrl}
getFontFormat={getFontFormat}
buildTextShadow={buildTextShadow}
- previewScale={previewContainerWidth && (materialDimensions?.width || 1280)
- ? previewContainerWidth / (materialDimensions?.width || 1280)
- : 1}
- previewAspectRatio={materialDimensions
- ? `${materialDimensions.width} / ${materialDimensions.height}`
- : "16 / 9"}
- previewBaseWidth={materialDimensions?.width || 1280}
- previewBaseHeight={materialDimensions?.height || 720}
- previewContainerRef={titlePreviewContainerRef}
+ previewBaseWidth={materialDimensions?.width || 1080}
+ previewBaseHeight={materialDimensions?.height || 1920}
/>
{/* 配音方式选择 */}
diff --git a/frontend/src/features/home/ui/ScriptEditor.tsx b/frontend/src/features/home/ui/ScriptEditor.tsx
index dd221bf..1830df8 100644
--- a/frontend/src/features/home/ui/ScriptEditor.tsx
+++ b/frontend/src/features/home/ui/ScriptEditor.tsx
@@ -16,12 +16,12 @@ export function ScriptEditor({
isGeneratingMeta,
}: ScriptEditorProps) {
return (
-
-
+
+
✍️ 文案提取与编辑
-
+
+
+
+ onSubtitleBottomMarginChange(parseInt(e.target.value, 10))}
+ className="w-full accent-purple-500"
+ />
+
)}
diff --git a/frontend/src/features/publish/model/usePublishController.ts b/frontend/src/features/publish/model/usePublishController.ts
index 846abb5..bf7e4e0 100644
--- a/frontend/src/features/publish/model/usePublishController.ts
+++ b/frontend/src/features/publish/model/usePublishController.ts
@@ -16,7 +16,7 @@ import {
} from "@/shared/types/publish";
const fetcher = (url: string) =>
- api.get
>(url).then((res) => unwrap(res.data));
+ api.get>(url).then((res) => unwrap(res.data));
export const usePublishController = () => {
const apiBase = getApiBaseUrl();
@@ -36,6 +36,7 @@ export const usePublishController = () => {
const [qrCodeImage, setQrCodeImage] = useState(null);
const [qrPlatform, setQrPlatform] = useState(null);
const [isLoadingQR, setIsLoadingQR] = useState(false);
+ const [faceVerifyQr, setFaceVerifyQr] = useState(null);
const { userId, isLoading: isAuthLoading } = useAuth();
const { isGenerating } = useTask();
@@ -194,8 +195,12 @@ export const usePublishController = () => {
if (data.success) {
setQrCodeImage(null);
setQrPlatform(null);
+ setFaceVerifyQr(null);
toast.success("✅ 登录成功!");
fetchAccounts();
+ } else if (data.face_verify_qr && !faceVerifyQr) {
+ // 刷脸验证:后端捕获了新二维码页面截图
+ setFaceVerifyQr(data.face_verify_qr);
}
},
}
@@ -210,7 +215,7 @@ export const usePublishController = () => {
setQrCodeImage(null);
toast.error("登录超时,请重试");
}
- }, 120000);
+ }, 180000);
}
return () => clearTimeout(timer);
}, [qrPlatform]);
@@ -317,6 +322,7 @@ export const usePublishController = () => {
const closeQrModal = () => {
setQrCodeImage(null);
setQrPlatform(null);
+ setFaceVerifyQr(null);
};
return {
@@ -324,7 +330,7 @@ export const usePublishController = () => {
selectedVideo, setSelectedVideo, videoFilter, setVideoFilter,
previewVideoUrl, setPreviewVideoUrl, selectedPlatforms,
title, titleInput, tags, setTags,
- isPublishing, publishResults, qrCodeImage, qrPlatform, isLoadingQR,
+ isPublishing, publishResults, qrCodeImage, qrPlatform, isLoadingQR, faceVerifyQr,
fetchAccounts, fetchVideos, togglePlatform, handlePublish,
handleLogin, handleLogout, platformIcons, filteredVideos,
handlePreviewVideo, closeQrModal,
diff --git a/frontend/src/features/publish/ui/PublishPage.tsx b/frontend/src/features/publish/ui/PublishPage.tsx
index 899868d..b5e5230 100644
--- a/frontend/src/features/publish/ui/PublishPage.tsx
+++ b/frontend/src/features/publish/ui/PublishPage.tsx
@@ -36,6 +36,7 @@ export function PublishPage() {
qrCodeImage,
qrPlatform,
isLoadingQR,
+ faceVerifyQr,
togglePlatform,
handlePublish,
handleLogin,
@@ -63,6 +64,20 @@ export function PublishPage() {
正在获取二维码...
+ ) : faceVerifyQr ? (
+ <>
+
+
+ 需要身份验证,请用抖音APP扫描上方二维码完成刷脸验证
+
+ >
) : qrCodeImage ? (
<>