Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa8088c82 | ||
|
|
31469ca01d | ||
|
|
22ea3dd0db |
@@ -1,35 +1,5 @@
|
|||||||
## 🧩 发布预览与播放修复 (14:10)
|
# Day 17 - 前端重构与体验优化
|
||||||
|
|
||||||
### 内容
|
|
||||||
- 发布页作品预览兼容签名 URL 与相对路径
|
|
||||||
- 参考音频试听统一走 `resolveMediaUrl`
|
|
||||||
- 素材/BGM 选择在列表变化时自动回退有效项
|
|
||||||
- 录音预览 URL 回收、预览弹窗滚动状态恢复、全局任务提示挂载
|
|
||||||
|
|
||||||
### 涉及文件
|
|
||||||
- `frontend/src/app/publish/page.tsx`
|
|
||||||
- `frontend/src/hooks/useMediaPlayers.ts`
|
|
||||||
- `frontend/src/hooks/useBgm.ts`
|
|
||||||
- `frontend/src/hooks/useMaterials.ts`
|
|
||||||
- `frontend/src/components/home/RefAudioPanel.tsx`
|
|
||||||
- `frontend/src/components/VideoPreviewModal.tsx`
|
|
||||||
- `frontend/src/app/layout.tsx`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧩 首页持久化修复 (12:20)
|
|
||||||
|
|
||||||
### 内容
|
|
||||||
- 接入 `useHomePersistence`,补齐 `isRestored` 恢复/保存逻辑
|
|
||||||
- 修复首页刷新后选择项恢复链路,`npm run build` 通过
|
|
||||||
|
|
||||||
### 涉及文件
|
|
||||||
- `frontend/src/app/page.tsx`
|
|
||||||
- `frontend/src/hooks/useHomePersistence.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧩 前端 UI 拆分 (11:00)
|
|
||||||
## 🧩 前端 UI 拆分 (09:10)
|
## 🧩 前端 UI 拆分 (09:10)
|
||||||
|
|
||||||
### 内容
|
### 内容
|
||||||
@@ -96,6 +66,31 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🖼️ 预览弹窗增强 (11:10)
|
||||||
|
|
||||||
|
### 内容
|
||||||
|
- 预览弹窗统一为可复用组件,支持标题与提示
|
||||||
|
- 发布页预览与素材预览共享弹窗样式
|
||||||
|
- 弹窗头部样式统一(图标 + 标题 + 关闭按钮)
|
||||||
|
|
||||||
|
### 涉及文件
|
||||||
|
- `frontend/src/components/VideoPreviewModal.tsx`
|
||||||
|
- `frontend/src/app/page.tsx`
|
||||||
|
- `frontend/src/app/publish/page.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 术语统一 (11:20)
|
||||||
|
|
||||||
|
### 内容
|
||||||
|
- “视频预览” → “作品预览”
|
||||||
|
- “历史视频” → “历史作品”
|
||||||
|
- “选择要发布的视频” → “选择要发布的作品”
|
||||||
|
- “选择素材视频” → “视频素材”
|
||||||
|
- “选择配音方式” → “配音方式”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🧱 Phase 2 Hook 抽取 (11:45)
|
## 🧱 Phase 2 Hook 抽取 (11:45)
|
||||||
|
|
||||||
### 内容
|
### 内容
|
||||||
@@ -117,25 +112,44 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🖼️ 预览弹窗增强 (11:10)
|
## 🧩 首页持久化修复 (12:20)
|
||||||
|
|
||||||
### 内容
|
### 内容
|
||||||
- 预览弹窗统一为可复用组件,支持标题与提示
|
- 接入 `useHomePersistence`,补齐 `isRestored` 恢复/保存逻辑
|
||||||
- 发布页预览与素材预览共享弹窗样式
|
- 修复首页刷新后选择项恢复链路,`npm run build` 通过
|
||||||
- 弹窗头部样式统一(图标 + 标题 + 关闭按钮)
|
|
||||||
|
|
||||||
### 涉及文件
|
### 涉及文件
|
||||||
- `frontend/src/components/VideoPreviewModal.tsx`
|
|
||||||
- `frontend/src/app/page.tsx`
|
- `frontend/src/app/page.tsx`
|
||||||
- `frontend/src/app/publish/page.tsx`
|
- `frontend/src/hooks/useHomePersistence.ts`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧭 术语统一 (11:20)
|
## 🧩 发布预览与播放修复 (14:10)
|
||||||
|
|
||||||
### 内容
|
### 内容
|
||||||
- “视频预览” → “作品预览”
|
- 发布页作品预览兼容签名 URL 与相对路径
|
||||||
- “历史视频” → “历史作品”
|
- 参考音频试听统一走 `resolveMediaUrl`
|
||||||
- “选择要发布的视频” → “选择要发布的作品”
|
- 素材/BGM 选择在列表变化时自动回退有效项
|
||||||
|
- 录音预览 URL 回收、预览弹窗滚动状态恢复、全局任务提示挂载
|
||||||
|
|
||||||
|
### 涉及文件
|
||||||
|
- `frontend/src/app/publish/page.tsx`
|
||||||
|
- `frontend/src/hooks/useMediaPlayers.ts`
|
||||||
|
- `frontend/src/hooks/useBgm.ts`
|
||||||
|
- `frontend/src/hooks/useMaterials.ts`
|
||||||
|
- `frontend/src/components/home/RefAudioPanel.tsx`
|
||||||
|
- `frontend/src/components/VideoPreviewModal.tsx`
|
||||||
|
- `frontend/src/app/layout.tsx`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🧩 标题同步与长度限制 (15:30)
|
||||||
|
|
||||||
|
### 内容
|
||||||
|
- 片头标题修改同步写入发布信息标题
|
||||||
|
- 标题输入兼容中文输入法,限制 15 字(发布信息同规则)
|
||||||
|
|
||||||
|
### 涉及文件
|
||||||
|
- `frontend/src/app/page.tsx`
|
||||||
|
- `frontend/src/components/home/TitleSubtitlePanel.tsx`
|
||||||
|
- `frontend/src/app/publish/page.tsx`
|
||||||
|
|||||||
@@ -220,6 +220,15 @@ import { formatDate } from '@/lib/media';
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 标题输入规则
|
||||||
|
|
||||||
|
- 片头标题与发布信息标题统一限制 15 字。
|
||||||
|
- 中文输入法合成阶段不截断,合成结束后才校验长度。
|
||||||
|
- 首页片头标题修改会同步写入 `vigent_${storageKey}_publish_title`。
|
||||||
|
- 避免使用 `maxLength` 强制截断输入法合成态。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 新增页面 Checklist
|
## 新增页面 Checklist
|
||||||
|
|
||||||
1. [ ] 导入 `import api from '@/lib/axios'`
|
1. [ ] 导入 `import api from '@/lib/axios'`
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
|||||||
- **一键克隆**: 选择参考音频后自动调用 Qwen3-TTS 服务。
|
- **一键克隆**: 选择参考音频后自动调用 Qwen3-TTS 服务。
|
||||||
|
|
||||||
### 4. 字幕与标题 [Day 13 新增]
|
### 4. 字幕与标题 [Day 13 新增]
|
||||||
- **片头标题**: 可选输入,视频开头显示 3 秒淡入淡出标题。
|
- **片头标题**: 可选输入,限制 15 字,视频开头显示 3 秒淡入淡出标题。
|
||||||
|
- **标题同步**: 首页片头标题修改会同步到发布信息标题。
|
||||||
- **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。
|
- **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。
|
||||||
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
|
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
|
||||||
- **样式预设**: 标题/字幕样式选择 + 预览 + 字号调节 (Day 16)。
|
- **样式预设**: 标题/字幕样式选择 + 预览 + 字号调节 (Day 16)。
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ backend/
|
|||||||
|
|
||||||
| 端点 | 方法 | 功能 |
|
| 端点 | 方法 | 功能 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `/api/materials` | POST | 上传素材视频 | ✅ |
|
| `/api/materials` | POST | 上传视频素材 | ✅ |
|
||||||
| `/api/materials` | GET | 获取素材列表 | ✅ |
|
| `/api/materials` | GET | 获取素材列表 | ✅ |
|
||||||
| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
|
| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
|
||||||
| `/api/videos/tasks/{id}` | GET | 查询任务状态 | ✅ |
|
| `/api/videos/tasks/{id}` | GET | 查询任务状态 | ✅ |
|
||||||
@@ -164,7 +164,7 @@ background_tasks.add_task(_process_video_generation, task_id, req, user_id)
|
|||||||
|
|
||||||
| 页面 | 功能 |
|
| 页面 | 功能 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **素材库** | 上传/管理多场景素材视频 |
|
| **素材库** | 上传/管理多场景视频素材 |
|
||||||
| **生成视频** | 输入文案、选择素材、生成预览 |
|
| **生成视频** | 输入文案、选择素材、生成预览 |
|
||||||
| **任务中心** | 查看生成进度、下载视频 |
|
| **任务中心** | 查看生成进度、下载视频 |
|
||||||
| **发布管理** | 绑定平台、一键发布、定时发布 |
|
| **发布管理** | 绑定平台、一键发布、定时发布 |
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
{
|
{
|
||||||
"id": "subtitle_classic_yellow",
|
"id": "subtitle_classic_yellow",
|
||||||
"label": "经典黄字",
|
"label": "经典黄字",
|
||||||
"font_file": "title/思源黑体/SourceHanSansCN-Bold思源黑体免费.otf",
|
"font_file": "DingTalk JinBuTi.ttf",
|
||||||
"font_family": "SourceHanSansCN-Bold",
|
"font_family": "DingTalkJinBuTi",
|
||||||
"font_size": 52,
|
"font_size": 60,
|
||||||
"highlight_color": "#FFE600",
|
"highlight_color": "#FFE600",
|
||||||
"normal_color": "#FFFFFF",
|
"normal_color": "#FFFFFF",
|
||||||
"stroke_color": "#000000",
|
"stroke_color": "#000000",
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"id": "title_pop",
|
||||||
|
"label": "站酷快乐体",
|
||||||
|
"font_file": "title/站酷快乐体.ttf",
|
||||||
|
"font_family": "ZCoolHappy",
|
||||||
|
"font_size": 90,
|
||||||
|
"color": "#FFFFFF",
|
||||||
|
"stroke_color": "#000000",
|
||||||
|
"stroke_size": 8,
|
||||||
|
"letter_spacing": 5,
|
||||||
|
"top_margin": 62,
|
||||||
|
"font_weight": 900,
|
||||||
|
"is_default": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "title_bold_white",
|
"id": "title_bold_white",
|
||||||
"label": "黑体大标题",
|
"label": "黑体大标题",
|
||||||
@@ -11,7 +25,7 @@
|
|||||||
"letter_spacing": 4,
|
"letter_spacing": 4,
|
||||||
"top_margin": 60,
|
"top_margin": 60,
|
||||||
"font_weight": 900,
|
"font_weight": 900,
|
||||||
"is_default": true
|
"is_default": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "title_serif_gold",
|
"id": "title_serif_gold",
|
||||||
@@ -40,19 +54,5 @@
|
|||||||
"top_margin": 60,
|
"top_margin": 60,
|
||||||
"font_weight": 900,
|
"font_weight": 900,
|
||||||
"is_default": false
|
"is_default": false
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "title_pop",
|
|
||||||
"label": "站酷快乐体",
|
|
||||||
"font_file": "title/站酷快乐体.ttf",
|
|
||||||
"font_family": "ZCoolHappy",
|
|
||||||
"font_size": 74,
|
|
||||||
"color": "#FFFFFF",
|
|
||||||
"stroke_color": "#000000",
|
|
||||||
"stroke_size": 8,
|
|
||||||
"letter_spacing": 5,
|
|
||||||
"top_margin": 62,
|
|
||||||
"font_weight": 900,
|
|
||||||
"is_default": false
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
buildTextShadow,
|
buildTextShadow,
|
||||||
formatDate,
|
formatDate,
|
||||||
} from "@/lib/media";
|
} from "@/lib/media";
|
||||||
|
import { clampTitle } from "@/lib/title";
|
||||||
import { useTitleSubtitleStyles } from "@/hooks/useTitleSubtitleStyles";
|
import { useTitleSubtitleStyles } from "@/hooks/useTitleSubtitleStyles";
|
||||||
import { useMaterials } from "@/hooks/useMaterials";
|
import { useMaterials } from "@/hooks/useMaterials";
|
||||||
import { useRefAudios } from "@/hooks/useRefAudios";
|
import { useRefAudios } from "@/hooks/useRefAudios";
|
||||||
@@ -19,6 +20,7 @@ import { useBgm } from "@/hooks/useBgm";
|
|||||||
import { useMediaPlayers } from "@/hooks/useMediaPlayers";
|
import { useMediaPlayers } from "@/hooks/useMediaPlayers";
|
||||||
import { useGeneratedVideos } from "@/hooks/useGeneratedVideos";
|
import { useGeneratedVideos } from "@/hooks/useGeneratedVideos";
|
||||||
import { useHomePersistence } from "@/hooks/useHomePersistence";
|
import { useHomePersistence } from "@/hooks/useHomePersistence";
|
||||||
|
import { useTitleInput } from "@/hooks/useTitleInput";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useTask } from "@/contexts/TaskContext";
|
import { useTask } from "@/contexts/TaskContext";
|
||||||
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
||||||
@@ -307,6 +309,18 @@ export default function Home() {
|
|||||||
selectedRefAudio,
|
selectedRefAudio,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const syncTitleToPublish = (value: string) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(`vigent_${storageKey}_publish_title`, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleInput = useTitleInput({
|
||||||
|
value: videoTitle,
|
||||||
|
onChange: setVideoTitle,
|
||||||
|
onCommit: syncTitleToPublish,
|
||||||
|
});
|
||||||
|
|
||||||
// 加载素材列表和历史视频
|
// 加载素材列表和历史视频
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthLoading) return;
|
if (isAuthLoading) return;
|
||||||
@@ -564,11 +578,11 @@ export default function Home() {
|
|||||||
console.log("[Home] AI生成结果:", data);
|
console.log("[Home] AI生成结果:", data);
|
||||||
|
|
||||||
// 更新首页标题
|
// 更新首页标题
|
||||||
setVideoTitle(data.title || "");
|
const nextTitle = clampTitle(data.title || "");
|
||||||
|
titleInput.commitValue(nextTitle);
|
||||||
|
|
||||||
// 同步到发布页 localStorage
|
// 同步到发布页 localStorage
|
||||||
console.log("[Home] 保存到 publish localStorage - title:", data.title, "tags:", data.tags);
|
console.log("[Home] 保存到 publish localStorage - title:", data.title, "tags:", data.tags);
|
||||||
localStorage.setItem(`vigent_${storageKey}_publish_title`, data.title || "");
|
|
||||||
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(data.tags || []));
|
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(data.tags || []));
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -706,7 +720,9 @@ export default function Home() {
|
|||||||
showStylePreview={showStylePreview}
|
showStylePreview={showStylePreview}
|
||||||
onTogglePreview={() => setShowStylePreview((prev) => !prev)}
|
onTogglePreview={() => setShowStylePreview((prev) => !prev)}
|
||||||
videoTitle={videoTitle}
|
videoTitle={videoTitle}
|
||||||
onTitleChange={setVideoTitle}
|
onTitleChange={titleInput.handleChange}
|
||||||
|
onTitleCompositionStart={titleInput.handleCompositionStart}
|
||||||
|
onTitleCompositionEnd={titleInput.handleCompositionEnd}
|
||||||
titleStyles={titleStyles}
|
titleStyles={titleStyles}
|
||||||
selectedTitleStyleId={selectedTitleStyleId}
|
selectedTitleStyleId={selectedTitleStyleId}
|
||||||
onSelectTitleStyle={setSelectedTitleStyleId}
|
onSelectTitleStyle={setSelectedTitleStyleId}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import useSWR from 'swr';
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import api from "@/lib/axios";
|
import api from "@/lib/axios";
|
||||||
import { getApiBaseUrl, formatDate, resolveMediaUrl, isAbsoluteUrl } from "@/lib/media";
|
import { getApiBaseUrl, formatDate, resolveMediaUrl, isAbsoluteUrl } from "@/lib/media";
|
||||||
|
import { clampTitle } from "@/lib/title";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
|
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
|
||||||
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
import VideoPreviewModal from "@/components/VideoPreviewModal";
|
||||||
|
import { useTitleInput } from "@/hooks/useTitleInput";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
@@ -38,16 +40,16 @@ interface Video {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PublishPage() {
|
export default function PublishPage() {
|
||||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
const [videos, setVideos] = useState<Video[]>([]);
|
const [videos, setVideos] = useState<Video[]>([]);
|
||||||
const [selectedVideo, setSelectedVideo] = useState<string>("");
|
const [selectedVideo, setSelectedVideo] = useState<string>("");
|
||||||
const [videoFilter, setVideoFilter] = useState<string>("");
|
const [videoFilter, setVideoFilter] = useState<string>("");
|
||||||
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
|
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
|
||||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
||||||
const [title, setTitle] = useState<string>("");
|
const [title, setTitle] = useState<string>("");
|
||||||
const [tags, setTags] = useState<string>("");
|
const [tags, setTags] = useState<string>("");
|
||||||
const [isPublishing, setIsPublishing] = useState(false);
|
const [isPublishing, setIsPublishing] = useState(false);
|
||||||
const [publishResults, setPublishResults] = useState<any[]>([]);
|
const [publishResults, setPublishResults] = useState<any[]>([]);
|
||||||
const [scheduleMode, setScheduleMode] = useState<"now" | "scheduled">("now");
|
const [scheduleMode, setScheduleMode] = useState<"now" | "scheduled">("now");
|
||||||
const [publishTime, setPublishTime] = useState<string>("");
|
const [publishTime, setPublishTime] = useState<string>("");
|
||||||
@@ -57,8 +59,13 @@ export default function PublishPage() {
|
|||||||
|
|
||||||
// 使用全局认证状态
|
// 使用全局认证状态
|
||||||
const { userId, isLoading: isAuthLoading } = useAuth();
|
const { userId, isLoading: isAuthLoading } = useAuth();
|
||||||
// 是否已从 localStorage 恢复完成
|
// 是否已从 localStorage 恢复完成
|
||||||
const [isRestored, setIsRestored] = useState(false);
|
const [isRestored, setIsRestored] = useState(false);
|
||||||
|
|
||||||
|
const titleInput = useTitleInput({
|
||||||
|
value: title,
|
||||||
|
onChange: setTitle,
|
||||||
|
});
|
||||||
|
|
||||||
// 加载账号和视频列表
|
// 加载账号和视频列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -81,17 +88,13 @@ export default function PublishPage() {
|
|||||||
|
|
||||||
// 从 localStorage 恢复用户输入(等待认证完成后)
|
// 从 localStorage 恢复用户输入(等待认证完成后)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("[Publish] 恢复检查 - isAuthLoading:", isAuthLoading, "userId:", userId);
|
if (isAuthLoading) return;
|
||||||
if (isAuthLoading) return;
|
|
||||||
|
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest)
|
||||||
console.log("[Publish] 开始从 localStorage 恢复数据,storageKey:", storageKey);
|
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`);
|
||||||
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest)
|
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`);
|
||||||
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`);
|
|
||||||
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`);
|
if (savedTitle) setTitle(clampTitle(savedTitle));
|
||||||
|
|
||||||
console.log("[Publish] localStorage 数据:", { savedTitle, savedTags });
|
|
||||||
|
|
||||||
if (savedTitle) setTitle(savedTitle);
|
|
||||||
if (savedTags) {
|
if (savedTags) {
|
||||||
// 兼容 JSON 数组格式(AI 生成)和字符串格式(手动输入)
|
// 兼容 JSON 数组格式(AI 生成)和字符串格式(手动输入)
|
||||||
try {
|
try {
|
||||||
@@ -106,10 +109,9 @@ export default function PublishPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 恢复完成后才允许保存
|
// 恢复完成后才允许保存
|
||||||
setIsRestored(true);
|
setIsRestored(true);
|
||||||
console.log("[Publish] 恢复完成,isRestored = true");
|
}, [storageKey, isAuthLoading]);
|
||||||
}, [storageKey, isAuthLoading]);
|
|
||||||
|
|
||||||
// 保存用户输入到 localStorage(恢复完成后才保存,未登录用户也可保存)
|
// 保存用户输入到 localStorage(恢复完成后才保存,未登录用户也可保存)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -288,7 +290,7 @@ export default function PublishPage() {
|
|||||||
return videos.filter((v) => v.name.toLowerCase().includes(query));
|
return videos.filter((v) => v.name.toLowerCase().includes(query));
|
||||||
}, [videos, videoFilter]);
|
}, [videos, videoFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh">
|
<div className="min-h-dvh">
|
||||||
<VideoPreviewModal
|
<VideoPreviewModal
|
||||||
onClose={() => setPreviewVideoUrl(null)}
|
onClose={() => setPreviewVideoUrl(null)}
|
||||||
@@ -515,13 +517,15 @@ export default function PublishPage() {
|
|||||||
<label className="block text-gray-400 text-sm mb-2">
|
<label className="block text-gray-400 text-sm mb-2">
|
||||||
标题
|
标题
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => titleInput.handleChange(e.target.value)}
|
||||||
placeholder="输入视频标题..."
|
onCompositionStart={titleInput.handleCompositionStart}
|
||||||
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500"
|
onCompositionEnd={(e) => titleInput.handleCompositionEnd(e.currentTarget.value)}
|
||||||
/>
|
placeholder="输入视频标题..."
|
||||||
|
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-gray-400 text-sm mb-2">
|
<label className="block text-gray-400 text-sm mb-2">
|
||||||
@@ -663,5 +667,5 @@ export default function PublishPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function MaterialSelector({
|
|||||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||||
<div className="flex justify-between items-center gap-2 mb-4">
|
<div className="flex justify-between items-center gap-2 mb-4">
|
||||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 whitespace-nowrap">
|
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 whitespace-nowrap">
|
||||||
📹 选择素材视频
|
📹 视频素材
|
||||||
<span className="ml-1 text-[11px] sm:text-xs text-gray-400/90 font-normal">
|
<span className="ml-1 text-[11px] sm:text-xs text-gray-400/90 font-normal">
|
||||||
(上传自拍视频)
|
(上传自拍视频)
|
||||||
</span>
|
</span>
|
||||||
@@ -112,9 +112,9 @@ export function MaterialSelector({
|
|||||||
) : materials.length === 0 ? (
|
) : materials.length === 0 ? (
|
||||||
<div className="text-center py-8 text-gray-400">
|
<div className="text-center py-8 text-gray-400">
|
||||||
<div className="text-5xl mb-4">📁</div>
|
<div className="text-5xl mb-4">📁</div>
|
||||||
<p>暂无素材视频</p>
|
<p>暂无视频素材</p>
|
||||||
<p className="text-sm mt-2">
|
<p className="text-sm mt-2">
|
||||||
点击上方「📤 上传视频」按钮添加素材
|
点击上方「📤 上传视频」按钮添加视频素材
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ interface TitleSubtitlePanelProps {
|
|||||||
onTogglePreview: () => void;
|
onTogglePreview: () => void;
|
||||||
videoTitle: string;
|
videoTitle: string;
|
||||||
onTitleChange: (value: string) => void;
|
onTitleChange: (value: string) => void;
|
||||||
|
onTitleCompositionStart?: () => void;
|
||||||
|
onTitleCompositionEnd?: (value: string) => void;
|
||||||
titleStyles: TitleStyleOption[];
|
titleStyles: TitleStyleOption[];
|
||||||
selectedTitleStyleId: string;
|
selectedTitleStyleId: string;
|
||||||
onSelectTitleStyle: (id: string) => void;
|
onSelectTitleStyle: (id: string) => void;
|
||||||
@@ -63,6 +65,8 @@ export function TitleSubtitlePanel({
|
|||||||
onTogglePreview,
|
onTogglePreview,
|
||||||
videoTitle,
|
videoTitle,
|
||||||
onTitleChange,
|
onTitleChange,
|
||||||
|
onTitleCompositionStart,
|
||||||
|
onTitleCompositionEnd,
|
||||||
titleStyles,
|
titleStyles,
|
||||||
selectedTitleStyleId,
|
selectedTitleStyleId,
|
||||||
onSelectTitleStyle,
|
onSelectTitleStyle,
|
||||||
@@ -209,11 +213,13 @@ export function TitleSubtitlePanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="text-sm text-gray-300 mb-2 block">片头标题(可选)</label>
|
<label className="text-sm text-gray-300 mb-2 block">片头标题(限制15个字)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={videoTitle}
|
value={videoTitle}
|
||||||
onChange={(e) => onTitleChange(e.target.value)}
|
onChange={(e) => onTitleChange(e.target.value)}
|
||||||
|
onCompositionStart={onTitleCompositionStart}
|
||||||
|
onCompositionEnd={(e) => onTitleCompositionEnd?.(e.currentTarget.value)}
|
||||||
placeholder="输入视频标题,将在片头显示"
|
placeholder="输入视频标题,将在片头显示"
|
||||||
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
|
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function VoiceSelector({
|
|||||||
return (
|
return (
|
||||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
🎙️ 选择配音方式
|
🎙️ 配音方式
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="flex gap-2 mb-4">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { clampTitle } from "@/lib/title";
|
||||||
|
|
||||||
interface RefAudio {
|
interface RefAudio {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -101,7 +102,7 @@ export const useHomePersistence = ({
|
|||||||
const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`);
|
const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`);
|
||||||
|
|
||||||
setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。");
|
setText(savedText || "大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。");
|
||||||
setVideoTitle(savedTitle || "");
|
setVideoTitle(savedTitle ? clampTitle(savedTitle) : "");
|
||||||
setEnableSubtitles(savedSubtitles !== null ? savedSubtitles === 'true' : true);
|
setEnableSubtitles(savedSubtitles !== null ? savedSubtitles === 'true' : true);
|
||||||
setTtsMode((savedTtsMode as 'edgetts' | 'voiceclone') || 'edgetts');
|
setTtsMode((savedTtsMode as 'edgetts' | 'voiceclone') || 'edgetts');
|
||||||
setVoice(savedVoice || "zh-CN-YunxiNeural");
|
setVoice(savedVoice || "zh-CN-YunxiNeural");
|
||||||
|
|||||||
@@ -20,19 +20,15 @@ export const useMaterials = ({
|
|||||||
}: UseMaterialsOptions) => {
|
}: UseMaterialsOptions) => {
|
||||||
const [materials, setMaterials] = useState<Material[]>([]);
|
const [materials, setMaterials] = useState<Material[]>([]);
|
||||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
const [debugData, setDebugData] = useState<string>("");
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [uploadData, setUploadData] = useState<string>("");
|
|
||||||
|
|
||||||
const fetchMaterials = useCallback(async () => {
|
const fetchMaterials = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setFetchError(null);
|
setFetchError(null);
|
||||||
setDebugData("Loading...");
|
|
||||||
|
|
||||||
const { data } = await api.get(`/api/materials?t=${new Date().getTime()}`);
|
const { data } = await api.get(`/api/materials?t=${new Date().getTime()}`);
|
||||||
setDebugData(JSON.stringify(data).substring(0, 200));
|
|
||||||
const nextMaterials = data.materials || [];
|
const nextMaterials = data.materials || [];
|
||||||
setMaterials(nextMaterials);
|
setMaterials(nextMaterials);
|
||||||
|
|
||||||
@@ -45,7 +41,6 @@ export const useMaterials = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取素材失败:", error);
|
console.error("获取素材失败:", error);
|
||||||
setFetchError(String(error));
|
setFetchError(String(error));
|
||||||
setDebugData(`Error: ${String(error)}`);
|
|
||||||
}
|
}
|
||||||
}, [selectedMaterial, setSelectedMaterial]);
|
}, [selectedMaterial, setSelectedMaterial]);
|
||||||
|
|
||||||
@@ -94,7 +89,6 @@ export const useMaterials = ({
|
|||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
fetchMaterials();
|
fetchMaterials();
|
||||||
setUploadData("");
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Upload failed:", err);
|
console.error("Upload failed:", err);
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
@@ -108,11 +102,9 @@ export const useMaterials = ({
|
|||||||
return {
|
return {
|
||||||
materials,
|
materials,
|
||||||
fetchError,
|
fetchError,
|
||||||
debugData,
|
|
||||||
isUploading,
|
isUploading,
|
||||||
uploadProgress,
|
uploadProgress,
|
||||||
uploadError,
|
uploadError,
|
||||||
uploadData,
|
|
||||||
setUploadError,
|
setUploadError,
|
||||||
fetchMaterials,
|
fetchMaterials,
|
||||||
deleteMaterial,
|
deleteMaterial,
|
||||||
|
|||||||
66
frontend/src/hooks/useTitleInput.ts
Normal file
66
frontend/src/hooks/useTitleInput.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { applyTitleLimit, TITLE_MAX_LENGTH } from "@/lib/title";
|
||||||
|
|
||||||
|
interface UseTitleInputOptions {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onCommit?: (value: string) => void;
|
||||||
|
maxLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTitleInput = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onCommit,
|
||||||
|
maxLength = TITLE_MAX_LENGTH,
|
||||||
|
}: UseTitleInputOptions) => {
|
||||||
|
const isComposingRef = useRef(false);
|
||||||
|
const committedRef = useRef(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isComposingRef.current) return;
|
||||||
|
committedRef.current = value;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const commitValue = useCallback(
|
||||||
|
(nextValue: string) => {
|
||||||
|
committedRef.current = nextValue;
|
||||||
|
onChange(nextValue);
|
||||||
|
onCommit?.(nextValue);
|
||||||
|
},
|
||||||
|
[onChange, onCommit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(nextValue: string) => {
|
||||||
|
if (isComposingRef.current) {
|
||||||
|
onChange(nextValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const limited = applyTitleLimit(committedRef.current, nextValue, maxLength);
|
||||||
|
commitValue(limited);
|
||||||
|
},
|
||||||
|
[maxLength, onChange, commitValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCompositionStart = useCallback(() => {
|
||||||
|
isComposingRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCompositionEnd = useCallback(
|
||||||
|
(nextValue: string) => {
|
||||||
|
isComposingRef.current = false;
|
||||||
|
const limited = applyTitleLimit(committedRef.current, nextValue, maxLength);
|
||||||
|
commitValue(limited);
|
||||||
|
},
|
||||||
|
[maxLength, commitValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleChange,
|
||||||
|
handleCompositionStart,
|
||||||
|
handleCompositionEnd,
|
||||||
|
commitValue,
|
||||||
|
maxLength,
|
||||||
|
};
|
||||||
|
};
|
||||||
14
frontend/src/lib/title.ts
Normal file
14
frontend/src/lib/title.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export const TITLE_MAX_LENGTH = 15;
|
||||||
|
|
||||||
|
export const clampTitle = (value: string, maxLength: number = TITLE_MAX_LENGTH) =>
|
||||||
|
value.slice(0, maxLength);
|
||||||
|
|
||||||
|
export const applyTitleLimit = (
|
||||||
|
prev: string,
|
||||||
|
next: string,
|
||||||
|
maxLength: number = TITLE_MAX_LENGTH
|
||||||
|
) => {
|
||||||
|
if (next.length <= maxLength) return next;
|
||||||
|
if (prev.length >= maxLength) return prev;
|
||||||
|
return next.slice(0, maxLength);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user