)}
+ >
+ );
+ if (embedded) return content;
+
+ return (
+
);
}
diff --git a/frontend/src/features/home/ui/HistoryList.tsx b/frontend/src/features/home/ui/HistoryList.tsx
index 5281b29..31714e6 100644
--- a/frontend/src/features/home/ui/HistoryList.tsx
+++ b/frontend/src/features/home/ui/HistoryList.tsx
@@ -16,6 +16,7 @@ interface HistoryListProps {
onRefresh: () => void;
registerVideoRef: (id: string, element: HTMLDivElement | null) => void;
formatDate: (timestamp: number) => string;
+ embedded?: boolean;
}
export function HistoryList({
@@ -26,19 +27,22 @@ export function HistoryList({
onRefresh,
registerVideoRef,
formatDate,
+ embedded = false,
}: HistoryListProps) {
- return (
-
-
-
📂 历史作品
-
-
+ const content = (
+ <>
+ {!embedded && (
+
+
历史作品
+
+
+ )}
{generatedVideos.length === 0 ? (
暂无生成的作品
@@ -66,7 +70,7 @@ export function HistoryList({
e.stopPropagation();
onDeleteVideo(v.id);
}}
- className="p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
+ className="p-1 text-gray-500 hover:text-red-400 opacity-40 group-hover:opacity-100 transition-opacity"
title="删除视频"
>
@@ -75,6 +79,14 @@ export function HistoryList({
))}
)}
+ >
+ );
+
+ if (embedded) return content;
+
+ return (
+
+ {content}
);
}
diff --git a/frontend/src/features/home/ui/HomePage.tsx b/frontend/src/features/home/ui/HomePage.tsx
index 17cc989..ff0b77f 100644
--- a/frontend/src/features/home/ui/HomePage.tsx
+++ b/frontend/src/features/home/ui/HomePage.tsx
@@ -2,6 +2,7 @@
import { useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
+import { RefreshCw } from "lucide-react";
import VideoPreviewModal from "@/components/VideoPreviewModal";
import ScriptExtractionModal from "./ScriptExtractionModal";
import { useHomeController } from "@/features/home/model/useHomeController";
@@ -179,7 +180,15 @@ export function HomePage() {
useEffect(() => {
if (typeof window === "undefined") return;
+ if ("scrollRestoration" in history) {
+ history.scrollRestoration = "manual";
+ }
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
+ // 兜底:等所有恢复 effect + 异步数据加载 settle 后再次强制回顶部
+ const timer = setTimeout(() => {
+ window.scrollTo({ top: 0, left: 0, behavior: "auto" });
+ }, 200);
+ return () => clearTimeout(timer);
}, []);
const clipTrimmerSegment = useMemo(
@@ -201,7 +210,7 @@ export function HomePage() {
{/* 左侧: 输入区域 */}
- {/* 1. 文案输入 */}
+ {/* 一、文案提取与编辑 */}
- {/* 2. 标题和字幕设置 */}
+ {/* 二、标题与字幕 */}
setShowStylePreview((prev) => !prev)}
@@ -268,65 +277,77 @@ export function HomePage() {
previewBaseHeight={outputAspectRatio === "16:9" ? 1080 : 1920}
/>
- {/* 3. 配音方式选择 */}
- setUploadRefError(null)}
- onUploadRefAudio={uploadRefAudio}
- onFetchRefAudios={fetchRefAudios}
- playingAudioId={playingAudioId}
- onTogglePlayPreview={togglePlayPreview}
- editingAudioId={editingAudioId}
- editName={editName}
- onEditNameChange={setEditName}
- onStartEditing={startEditing}
- onSaveEditing={saveEditing}
- onCancelEditing={cancelEditing}
- onDeleteRefAudio={deleteRefAudio}
- onRetranscribe={retranscribeRefAudio}
- retranscribingId={retranscribingId}
- recordedBlob={recordedBlob}
- isRecording={isRecording}
- recordingTime={recordingTime}
- onStartRecording={startRecording}
- onStopRecording={stopRecording}
- onUseRecording={useRecording}
- formatRecordingTime={formatRecordingTime}
- />
- )}
- />
+ {/* 三、配音 */}
+
+
+ 三、配音
+
+
配音方式
+
setUploadRefError(null)}
+ onUploadRefAudio={uploadRefAudio}
+ onFetchRefAudios={fetchRefAudios}
+ playingAudioId={playingAudioId}
+ onTogglePlayPreview={togglePlayPreview}
+ editingAudioId={editingAudioId}
+ editName={editName}
+ onEditNameChange={setEditName}
+ onStartEditing={startEditing}
+ onSaveEditing={saveEditing}
+ onCancelEditing={cancelEditing}
+ onDeleteRefAudio={deleteRefAudio}
+ onRetranscribe={retranscribeRefAudio}
+ retranscribingId={retranscribingId}
+ recordedBlob={recordedBlob}
+ isRecording={isRecording}
+ recordingTime={recordingTime}
+ onStartRecording={startRecording}
+ onStopRecording={stopRecording}
+ onUseRecording={useRecording}
+ formatRecordingTime={formatRecordingTime}
+ />
+ )}
+ />
+
+ fetchGeneratedAudios()}
+ onSelectAudio={selectAudio}
+ onDeleteAudio={deleteAudio}
+ onRenameAudio={renameAudio}
+ hasText={!!text.trim()}
+ missingRefAudio={ttsMode === "voiceclone" && !selectedRefAudio}
+ speed={speed}
+ onSpeedChange={setSpeed}
+ ttsMode={ttsMode}
+ />
+
- {/* 4. 配音列表 */}
- fetchGeneratedAudios()}
- onSelectAudio={selectAudio}
- onDeleteAudio={deleteAudio}
- onRenameAudio={renameAudio}
- hasText={!!text.trim()}
- missingRefAudio={ttsMode === "voiceclone" && !selectedRefAudio}
- speed={speed}
- onSpeedChange={setSpeed}
- ttsMode={ttsMode}
- />
-
- {/* 5. 视频素材 */}
-
+
+ 四、素材编辑
+
+ setUploadError(null)}
registerMaterialRef={registerMaterialRef}
/>
-
- {/* 5.5 时间轴编辑器 — 未选配音/素材时模糊遮挡 */}
-
- {(!selectedAudio || selectedMaterials.length === 0) && (
-
-
- {!selectedAudio ? "请先生成并选中配音" : "请先选择素材"}
-
-
- )}
-
{
- setClipTrimmerSegmentId(seg.id);
- setClipTrimmerOpen(true);
- }}
- />
+
+
+ {(!selectedAudio || selectedMaterials.length === 0) && (
+
+
+ {!selectedAudio ? "请先生成并选中配音" : "请先选择素材"}
+
+
+ )}
+
{
+ setClipTrimmerSegmentId(seg.id);
+ setClipTrimmerOpen(true);
+ }}
+ />
+
- {/* 6. 背景音乐 */}
+ {/* 背景音乐 (不编号) */}
- {/* 7. 生成按钮 */}
+ {/* 生成按钮 (不编号) */}
- {/* 右侧: 预览区域 */}
+ {/* 右侧: 作品区域 */}
-
-
-
fetchGeneratedVideos()}
- registerVideoRef={registerVideoRef}
- formatDate={formatDate}
- />
+ {/* 生成进度(在作品卡片上方) */}
+ {currentTask && isGenerating && (
+
+
+
+ 正在AI生成中...
+ {currentTask.progress || 0}%
+
+
+
+
+ )}
+ {/* 六、作品 */}
+
+
+ 六、作品
+
+
+
作品列表
+
+
+
fetchGeneratedVideos()}
+ registerVideoRef={registerVideoRef}
+ formatDate={formatDate}
+ />
+
+ 作品预览
+
+
diff --git a/frontend/src/features/home/ui/MaterialSelector.tsx b/frontend/src/features/home/ui/MaterialSelector.tsx
index 9c5a803..6402ef4 100644
--- a/frontend/src/features/home/ui/MaterialSelector.tsx
+++ b/frontend/src/features/home/ui/MaterialSelector.tsx
@@ -1,4 +1,4 @@
-import { type ChangeEvent, type MouseEvent } from "react";
+import { type ChangeEvent, type MouseEvent, useMemo } from "react";
import { Upload, RefreshCw, Eye, Trash2, X, Pencil, Check } from "lucide-react";
import type { Material } from "@/shared/types/material";
@@ -25,6 +25,7 @@ interface MaterialSelectorProps {
onDeleteMaterial: (id: string) => void;
onClearUploadError: () => void;
registerMaterialRef: (id: string, element: HTMLDivElement | null) => void;
+ embedded?: boolean;
}
export function MaterialSelector({
@@ -50,19 +51,27 @@ export function MaterialSelector({
onDeleteMaterial,
onClearUploadError,
registerMaterialRef,
+ embedded = false,
}: MaterialSelectorProps) {
- const selectedSet = new Set(selectedMaterials);
+ const selectedSet = useMemo(() => new Set(selectedMaterials), [selectedMaterials]);
const isFull = selectedMaterials.length >= 4;
- return (
-
+ const content = (
+ <>
-
- 📹 视频素材
-
- (可多选,最多4个)
-
-
+ {!embedded ? (
+
+ 视频素材
+
+ (上传自拍视频,最多可选4个)
+
+
+ ) : (
+
+ 视频素材
+ (上传自拍视频,最多可选4个)
+
+ )}
- 📤 上传中...
+ 上传中...
{uploadProgress}%
@@ -108,7 +117,7 @@ export function MaterialSelector({
{uploadError && (
-
❌ {uploadError}
+
{uploadError}
@@ -138,7 +147,7 @@ export function MaterialSelector({
📁
暂无视频素材
- 点击上方「📤 上传视频」按钮添加视频素材
+ 点击上方「上传」按钮添加视频素材
) : (
@@ -183,7 +192,7 @@ export function MaterialSelector({
) : (
-
)}
+ >
+ );
+
+ if (embedded) return content;
+
+ return (
+
+ {content}
);
}
diff --git a/frontend/src/features/home/ui/PreviewPanel.tsx b/frontend/src/features/home/ui/PreviewPanel.tsx
index 84e0ed6..b7c3c68 100644
--- a/frontend/src/features/home/ui/PreviewPanel.tsx
+++ b/frontend/src/features/home/ui/PreviewPanel.tsx
@@ -12,18 +12,20 @@ interface PreviewPanelProps {
currentTask: Task | null;
isGenerating: boolean;
generatedVideo: string | null;
+ embedded?: boolean;
}
export function PreviewPanel({
currentTask,
isGenerating,
generatedVideo,
+ embedded = false,
}: PreviewPanelProps) {
- return (
+ const content = (
<>
{currentTask && isGenerating && (
-
-
⏳ 生成进度
+
+ {!embedded &&
生成进度
}
)}
-
-
🎥 作品预览
+
+ {!embedded &&
作品预览
}
{generatedVideo ? (
@@ -71,4 +73,6 @@ export function PreviewPanel({
>
);
+
+ return content;
}
diff --git a/frontend/src/features/home/ui/RefAudioPanel.tsx b/frontend/src/features/home/ui/RefAudioPanel.tsx
index 27e8d54..0087edc 100644
--- a/frontend/src/features/home/ui/RefAudioPanel.tsx
+++ b/frontend/src/features/home/ui/RefAudioPanel.tsx
@@ -92,7 +92,7 @@ export function RefAudioPanel({
-
📁 我的参考音频
+
📁 我的参考音频 (上传3-10秒语音样本)
{audio.name}
-
+
onTogglePlayPreview(audio, e)}
className="text-gray-400 hover:text-purple-400 text-xs"
@@ -287,9 +287,6 @@ export function RefAudioPanel({
)}
-
- 上传任意语音样本(3-10秒),系统将自动识别内容并克隆声音
-
);
}
diff --git a/frontend/src/features/home/ui/ScriptEditor.tsx b/frontend/src/features/home/ui/ScriptEditor.tsx
index 4b94329..2b6a471 100644
--- a/frontend/src/features/home/ui/ScriptEditor.tsx
+++ b/frontend/src/features/home/ui/ScriptEditor.tsx
@@ -86,7 +86,7 @@ export function ScriptEditor({
- ✍️ 文案提取与编辑
+ 一、文案提取与编辑
{/* 历史文案 */}
@@ -123,7 +123,7 @@ export function ScriptEditor({
e.stopPropagation();
onDeleteScript(script.id);
}}
- className="opacity-0 group-hover:opacity-100 p-1 text-gray-500 hover:text-red-400 transition-all shrink-0"
+ className="opacity-40 group-hover:opacity-100 p-1 text-gray-500 hover:text-red-400 transition-all shrink-0"
>
diff --git a/frontend/src/features/home/ui/ScriptExtractionModal.tsx b/frontend/src/features/home/ui/ScriptExtractionModal.tsx
index 9daa417..503b00d 100644
--- a/frontend/src/features/home/ui/ScriptExtractionModal.tsx
+++ b/frontend/src/features/home/ui/ScriptExtractionModal.tsx
@@ -310,7 +310,7 @@ export default function ScriptExtractionModal({
📋 复制内容
-
+
{rewrittenScript}
@@ -338,7 +338,7 @@ export default function ScriptExtractionModal({
复制
-
+
{script}
diff --git a/frontend/src/features/home/ui/TimelineEditor.tsx b/frontend/src/features/home/ui/TimelineEditor.tsx
index b2afdbd..6a8ed49 100644
--- a/frontend/src/features/home/ui/TimelineEditor.tsx
+++ b/frontend/src/features/home/ui/TimelineEditor.tsx
@@ -1,9 +1,9 @@
-import { useEffect, useRef, useCallback, useState } from "react";
+import { useEffect, useRef, useCallback, useState, useMemo } from "react";
import WaveSurfer from "wavesurfer.js";
-import { ChevronDown } from "lucide-react";
+import { ChevronDown, GripVertical } from "lucide-react";
import type { TimelineSegment } from "@/features/home/model/useTimelineEditor";
import type { Material } from "@/shared/types/material";
-
+
interface TimelineEditorProps {
audioDuration: number;
audioUrl: string;
@@ -13,14 +13,15 @@ interface TimelineEditorProps {
onOutputAspectRatioChange: (ratio: "9:16" | "16:9") => void;
onReorderSegment: (fromIdx: number, toIdx: number) => void;
onClickSegment: (segment: TimelineSegment) => void;
+ embedded?: boolean;
}
-
-function formatTime(sec: number): string {
- const m = Math.floor(sec / 60);
- const s = sec % 60;
- return `${String(m).padStart(2, "0")}:${s.toFixed(1).padStart(4, "0")}`;
-}
-
+
+function formatTime(sec: number): string {
+ const m = Math.floor(sec / 60);
+ const s = sec % 60;
+ return `${String(m).padStart(2, "0")}:${s.toFixed(1).padStart(4, "0")}`;
+}
+
export function TimelineEditor({
audioDuration,
audioUrl,
@@ -30,12 +31,13 @@ export function TimelineEditor({
onOutputAspectRatioChange,
onReorderSegment,
onClickSegment,
+ embedded = false,
}: TimelineEditorProps) {
- const waveRef = useRef
(null);
- const wsRef = useRef(null);
- const [waveReady, setWaveReady] = useState(false);
- const [isPlaying, setIsPlaying] = useState(false);
-
+ const waveRef = useRef(null);
+ const wsRef = useRef(null);
+ const [waveReady, setWaveReady] = useState(false);
+ const [isPlaying, setIsPlaying] = useState(false);
+
// Refs for high-frequency DOM updates (avoid 60fps re-renders)
const playheadRef = useRef(null);
const timeRef = useRef(null);
@@ -44,7 +46,7 @@ export function TimelineEditor({
useEffect(() => {
audioDurationRef.current = audioDuration;
}, [audioDuration]);
-
+
// Drag-to-reorder state
const [dragFromIdx, setDragFromIdx] = useState(null);
const [dragOverIdx, setDragOverIdx] = useState(null);
@@ -68,57 +70,57 @@ export function TimelineEditor({
if (ratioOpen) document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [ratioOpen]);
-
- // Create / recreate wavesurfer when audioUrl changes
+
+ // Create / recreate wavesurfer when audioUrl changes
useEffect(() => {
if (!waveRef.current || !audioUrl) return;
const playheadEl = playheadRef.current;
const timeEl = timeRef.current;
-
- // Destroy previous instance
- if (wsRef.current) {
- wsRef.current.destroy();
- wsRef.current = null;
- }
-
- const ws = WaveSurfer.create({
- container: waveRef.current,
- height: 56,
- waveColor: "#6d28d9",
- progressColor: "#a855f7",
- barWidth: 2,
- barGap: 1,
- barRadius: 2,
- cursorWidth: 1,
- cursorColor: "#e879f9",
- interact: true,
- normalize: true,
- });
-
- // Click waveform → seek + auto-play
- ws.on("interaction", () => ws.play());
- ws.on("play", () => setIsPlaying(true));
- ws.on("pause", () => setIsPlaying(false));
- ws.on("finish", () => {
- setIsPlaying(false);
- if (playheadRef.current) playheadRef.current.style.display = "none";
- });
- // High-frequency: update playhead + time via refs (no React re-render)
- ws.on("timeupdate", (time: number) => {
- const dur = audioDurationRef.current;
- if (playheadRef.current && dur > 0) {
- playheadRef.current.style.left = `${(time / dur) * 100}%`;
- playheadRef.current.style.display = "block";
- }
- if (timeRef.current) {
- timeRef.current.textContent = formatTime(time);
- }
- });
-
- ws.load(audioUrl);
- wsRef.current = ws;
-
+
+ // Destroy previous instance
+ if (wsRef.current) {
+ wsRef.current.destroy();
+ wsRef.current = null;
+ }
+
+ const ws = WaveSurfer.create({
+ container: waveRef.current,
+ height: 56,
+ waveColor: "#6d28d9",
+ progressColor: "#a855f7",
+ barWidth: 2,
+ barGap: 1,
+ barRadius: 2,
+ cursorWidth: 1,
+ cursorColor: "#e879f9",
+ interact: true,
+ normalize: true,
+ });
+
+ // Click waveform → seek + auto-play
+ ws.on("interaction", () => ws.play());
+ ws.on("play", () => setIsPlaying(true));
+ ws.on("pause", () => setIsPlaying(false));
+ ws.on("finish", () => {
+ setIsPlaying(false);
+ if (playheadRef.current) playheadRef.current.style.display = "none";
+ });
+ // High-frequency: update playhead + time via refs (no React re-render)
+ ws.on("timeupdate", (time: number) => {
+ const dur = audioDurationRef.current;
+ if (playheadRef.current && dur > 0) {
+ playheadRef.current.style.left = `${(time / dur) * 100}%`;
+ playheadRef.current.style.display = "block";
+ }
+ if (timeRef.current) {
+ timeRef.current.textContent = formatTime(time);
+ }
+ });
+
+ ws.load(audioUrl);
+ wsRef.current = ws;
+
return () => {
ws.destroy();
wsRef.current = null;
@@ -127,60 +129,64 @@ export function TimelineEditor({
if (timeEl) timeEl.textContent = formatTime(0);
};
}, [audioUrl, waveReady]);
-
- // Callback ref to detect when waveRef div mounts
- const waveCallbackRef = useCallback((node: HTMLDivElement | null) => {
- (waveRef as React.MutableRefObject).current = node;
- setWaveReady(!!node);
- }, []);
-
- const handlePlayPause = useCallback(() => {
- wsRef.current?.playPause();
- }, []);
-
- // Drag-to-reorder handlers
- const handleDragStart = useCallback((idx: number, e: React.DragEvent) => {
- setDragFromIdx(idx);
- e.dataTransfer.effectAllowed = "move";
- e.dataTransfer.setData("text/plain", String(idx));
- }, []);
-
- const handleDragOver = useCallback((idx: number, e: React.DragEvent) => {
- e.preventDefault();
- e.dataTransfer.dropEffect = "move";
- setDragOverIdx(idx);
- }, []);
-
- const handleDragLeave = useCallback(() => {
- setDragOverIdx(null);
- }, []);
-
- const handleDrop = useCallback((toIdx: number, e: React.DragEvent) => {
- e.preventDefault();
- const fromIdx = parseInt(e.dataTransfer.getData("text/plain"), 10);
- if (!isNaN(fromIdx) && fromIdx !== toIdx) {
- onReorderSegment(fromIdx, toIdx);
- }
- setDragFromIdx(null);
- setDragOverIdx(null);
- }, [onReorderSegment]);
-
- const handleDragEnd = useCallback(() => {
- setDragFromIdx(null);
- setDragOverIdx(null);
- }, []);
-
- // Filter visible vs overflow segments
- const visibleSegments = segments.filter((s) => s.start < audioDuration);
- const overflowSegments = segments.filter((s) => s.start >= audioDuration);
- const hasSegments = visibleSegments.length > 0;
-
- return (
-
+
+ // Callback ref to detect when waveRef div mounts
+ const waveCallbackRef = useCallback((node: HTMLDivElement | null) => {
+ (waveRef as React.MutableRefObject
).current = node;
+ setWaveReady(!!node);
+ }, []);
+
+ const handlePlayPause = useCallback(() => {
+ wsRef.current?.playPause();
+ }, []);
+
+ // Drag-to-reorder handlers
+ const handleDragStart = useCallback((idx: number, e: React.DragEvent) => {
+ setDragFromIdx(idx);
+ e.dataTransfer.effectAllowed = "move";
+ e.dataTransfer.setData("text/plain", String(idx));
+ }, []);
+
+ const handleDragOver = useCallback((idx: number, e: React.DragEvent) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ setDragOverIdx(idx);
+ }, []);
+
+ const handleDragLeave = useCallback(() => {
+ setDragOverIdx(null);
+ }, []);
+
+ const handleDrop = useCallback((toIdx: number, e: React.DragEvent) => {
+ e.preventDefault();
+ const fromIdx = parseInt(e.dataTransfer.getData("text/plain"), 10);
+ if (!isNaN(fromIdx) && fromIdx !== toIdx) {
+ onReorderSegment(fromIdx, toIdx);
+ }
+ setDragFromIdx(null);
+ setDragOverIdx(null);
+ }, [onReorderSegment]);
+
+ const handleDragEnd = useCallback(() => {
+ setDragFromIdx(null);
+ setDragOverIdx(null);
+ }, []);
+
+ // Filter visible vs overflow segments
+ const visibleSegments = useMemo(() => segments.filter((s) => s.start < audioDuration), [segments, audioDuration]);
+ const overflowSegments = useMemo(() => segments.filter((s) => s.start >= audioDuration), [segments, audioDuration]);
+ const hasSegments = visibleSegments.length > 0;
+
+ const content = (
+ <>
-
- 🎞️ 时间轴编辑
-
+ {!embedded ? (
+
+ 时间轴编辑
+
+ ) : (
+
时间轴编辑
+ )}
-
- {/* Waveform — always rendered so ref stays mounted */}
-
-
- {/* Segment blocks or empty placeholder */}
- {hasSegments ? (
- <>
-
- {/* Playhead — syncs with audio playback */}
-
- {visibleSegments.map((seg, i) => {
- const left = (seg.start / audioDuration) * 100;
- const width = ((seg.end - seg.start) / audioDuration) * 100;
- const segDur = seg.end - seg.start;
- const isDragTarget = dragOverIdx === i && dragFromIdx !== i;
-
+
+ {/* Waveform — always rendered so ref stays mounted */}
+
+
+ {/* Segment blocks or empty placeholder */}
+ {hasSegments ? (
+ <>
+
+ {/* Playhead — syncs with audio playback */}
+
+ {visibleSegments.map((seg, i) => {
+ const left = (seg.start / audioDuration) * 100;
+ const width = ((seg.end - seg.start) / audioDuration) * 100;
+ const segDur = seg.end - seg.start;
+ const isDragTarget = dragOverIdx === i && dragFromIdx !== i;
+
// Compute loop portion for the last visible segment
const isLastVisible = i === visibleSegments.length - 1;
let loopPercent = 0;
@@ -266,84 +272,93 @@ export function TimelineEditor({
loopPercent = ((segDur - effDur) / segDur) * 100;
}
}
-
- return (
-
-
handleDragStart(i, e)}
- onDragOver={(e) => handleDragOver(i, e)}
- onDragLeave={handleDragLeave}
- onDrop={(e) => handleDrop(i, e)}
- onDragEnd={handleDragEnd}
- onClick={() => onClickSegment(seg)}
- className={`relative w-full h-full rounded-lg flex flex-col items-center justify-center overflow-hidden cursor-grab active:cursor-grabbing transition-all border ${
- isDragTarget
- ? "ring-2 ring-purple-400 border-purple-400 scale-[1.02]"
- : dragFromIdx === i
- ? "opacity-50 border-white/10"
- : "hover:opacity-90 border-white/10"
- }`}
- style={{ backgroundColor: seg.color + "33", borderColor: isDragTarget ? undefined : seg.color + "66" }}
- title={`拖拽可调换顺序 · 点击设置截取范围\n${seg.materialName}\n${segDur.toFixed(1)}s${loopPercent > 0 ? ` (含循环 ${(segDur * loopPercent / 100).toFixed(1)}s)` : ""}`}
- >
-
- {seg.materialName}
-
-
- {segDur.toFixed(1)}s
-
- {seg.sourceStart > 0 && (
-
- ✂ {seg.sourceStart.toFixed(1)}s
-
- )}
- {/* Loop fill stripe overlay */}
- {loopPercent > 0 && (
-
- 循环
-
- )}
-
-
- );
- })}
-
-
- {/* Overflow segments — shown as gray chips */}
- {overflowSegments.length > 0 && (
-
- 未使用:
- {overflowSegments.map((seg) => (
-
- {seg.materialName}
-
- ))}
-
- )}
-
-
- 点击波形定位播放 · 拖拽色块调换顺序 · 点击色块设置截取范围
-
- >
- ) : (
- <>
-
-
- 选中配音和素材后可编辑时间轴
-
- >
- )}
-
- );
-}
+
+ return (
+
+
handleDragStart(i, e)}
+ onDragOver={(e) => handleDragOver(i, e)}
+ onDragLeave={handleDragLeave}
+ onDrop={(e) => handleDrop(i, e)}
+ onDragEnd={handleDragEnd}
+ onClick={() => onClickSegment(seg)}
+ className={`relative w-full h-full rounded-lg flex flex-col items-center justify-center overflow-hidden cursor-grab active:cursor-grabbing transition-all border ${
+ isDragTarget
+ ? "ring-2 ring-purple-400 border-purple-400 scale-[1.02]"
+ : dragFromIdx === i
+ ? "opacity-50 border-white/10"
+ : "hover:opacity-90 border-white/10"
+ }`}
+ style={{ backgroundColor: seg.color + "33", borderColor: isDragTarget ? undefined : seg.color + "66" }}
+ title={`拖拽可调换顺序 · 点击设置截取范围\n${seg.materialName}\n${segDur.toFixed(1)}s${loopPercent > 0 ? ` (含循环 ${(segDur * loopPercent / 100).toFixed(1)}s)` : ""}`}
+ >
+
+
+ {seg.materialName}
+
+
+ {segDur.toFixed(1)}s
+
+ {seg.sourceStart > 0 && (
+
+ ✂ {seg.sourceStart.toFixed(1)}s
+
+ )}
+ {/* Loop fill stripe overlay */}
+ {loopPercent > 0 && (
+
+ 循环
+
+ )}
+
+
+ );
+ })}
+
+
+ {/* Overflow segments — shown as gray chips */}
+ {overflowSegments.length > 0 && (
+
+ 未使用:
+ {overflowSegments.map((seg) => (
+
+ {seg.materialName}
+
+ ))}
+
+ )}
+
+
+ 点击波形定位播放 · 拖拽色块调换顺序 · 点击色块设置截取范围
+
+ >
+ ) : (
+ <>
+
+
+ 选中配音和素材后可编辑时间轴
+
+ >
+ )}
+ >
+ );
+
+ if (embedded) return content;
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/frontend/src/features/home/ui/TitleSubtitlePanel.tsx b/frontend/src/features/home/ui/TitleSubtitlePanel.tsx
index 7264470..a6c61b5 100644
--- a/frontend/src/features/home/ui/TitleSubtitlePanel.tsx
+++ b/frontend/src/features/home/ui/TitleSubtitlePanel.tsx
@@ -114,15 +114,29 @@ export function TitleSubtitlePanel({
- 🎬 标题与字幕
+ 二、标题与字幕
-
-
- {showStylePreview ? "收起预览" : "预览样式"}
-
+
+
+
+
+
+
+
+ {showStylePreview ? "收起预览" : "预览样式"}
+
+
{showStylePreview && (
@@ -151,20 +165,9 @@ export function TitleSubtitlePanel({
)}
-
-
-
-
-
-
+
+
+ 15 ? "text-red-400" : "text-gray-500"}`}>{videoTitle.length}/15
-
+
+
+ 20 ? "text-red-400" : "text-gray-500"}`}>{videoSecondaryTitle.length}/20
+
{titleStyles.length > 0 && (
-
-
-
- {titleStyles.map((style) => (
-
onSelectTitleStyle(style.id)}
- className={`p-2 rounded-lg border transition-all text-left ${selectedTitleStyleId === style.id
- ? "border-purple-500 bg-purple-500/20"
- : "border-white/10 bg-white/5 hover:border-white/30"
- }`}
+
+
+
+
+
+
+
-
-
-
onTitleFontSizeChange(parseInt(e.target.value, 10))}
- className="w-full accent-purple-500"
- />
+
+
+ onTitleFontSizeChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
-
)}
{titleStyles.length > 0 && (
-
-
-
- {titleStyles.map((style) => (
-
onSelectSecondaryTitleStyle(style.id)}
- className={`p-2 rounded-lg border transition-all text-left ${selectedSecondaryTitleStyleId === style.id
- ? "border-purple-500 bg-purple-500/20"
- : "border-white/10 bg-white/5 hover:border-white/30"
- }`}
+
+
+
+
+
+
+
-
-
-
onSecondaryTitleFontSizeChange(parseInt(e.target.value, 10))}
- className="w-full accent-purple-500"
- />
+
+
+ onSecondaryTitleFontSizeChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
-
)}
{subtitleStyles.length > 0 && (
-
-
-
- {subtitleStyles.map((style) => (
-
onSelectSubtitleStyle(style.id)}
- className={`p-2 rounded-lg border transition-all text-left ${selectedSubtitleStyleId === style.id
- ? "border-purple-500 bg-purple-500/20"
- : "border-white/10 bg-white/5 hover:border-white/30"
- }`}
+
+
+
+
+
+
+
-
-
-
onSubtitleFontSizeChange(parseInt(e.target.value, 10))}
- className="w-full accent-purple-500"
- />
+
+
+ onSubtitleFontSizeChange(parseInt(e.target.value, 10))} className="flex-1 accent-purple-500" />
-
)}
diff --git a/frontend/src/features/home/ui/VoiceSelector.tsx b/frontend/src/features/home/ui/VoiceSelector.tsx
index 9dd5e5e..0a0188c 100644
--- a/frontend/src/features/home/ui/VoiceSelector.tsx
+++ b/frontend/src/features/home/ui/VoiceSelector.tsx
@@ -13,6 +13,7 @@ interface VoiceSelectorProps {
voice: string;
onSelectVoice: (id: string) => void;
voiceCloneSlot: ReactNode;
+ embedded?: boolean;
}
export function VoiceSelector({
@@ -22,32 +23,29 @@ export function VoiceSelector({
voice,
onSelectVoice,
voiceCloneSlot,
+ embedded = false,
}: VoiceSelectorProps) {
- return (
-
-
- 🎙️ 配音方式
-
-
+ const content = (
+ <>
onSelectTtsMode("edgetts")}
- className={`flex-1 py-2 px-4 rounded-lg font-medium transition-all flex items-center justify-center gap-2 ${ttsMode === "edgetts"
+ className={`flex-1 py-2 px-2 sm:px-4 rounded-lg text-sm sm:text-base font-medium transition-all flex items-center justify-center gap-1.5 sm:gap-2 ${ttsMode === "edgetts"
? "bg-purple-600 text-white"
: "bg-white/10 text-gray-300 hover:bg-white/20"
}`}
>
-
+
选择声音
onSelectTtsMode("voiceclone")}
- className={`flex-1 py-2 px-4 rounded-lg font-medium transition-all flex items-center justify-center gap-2 ${ttsMode === "voiceclone"
+ className={`flex-1 py-2 px-2 sm:px-4 rounded-lg text-sm sm:text-base font-medium transition-all flex items-center justify-center gap-1.5 sm:gap-2 ${ttsMode === "voiceclone"
? "bg-purple-600 text-white"
: "bg-white/10 text-gray-300 hover:bg-white/20"
}`}
>
-
+
克隆声音
@@ -70,6 +68,17 @@ export function VoiceSelector({
)}
{ttsMode === "voiceclone" && voiceCloneSlot}
+ >
+ );
+
+ if (embedded) return content;
+
+ return (
+
+
+ 🎙️ 配音方式
+
+ {content}
);
}
diff --git a/frontend/src/features/publish/ui/PublishPage.tsx b/frontend/src/features/publish/ui/PublishPage.tsx
index b5e5230..ec436b4 100644
--- a/frontend/src/features/publish/ui/PublishPage.tsx
+++ b/frontend/src/features/publish/ui/PublishPage.tsx
@@ -135,7 +135,7 @@ export function PublishPage() {
- 👤 平台账号
+ 七、平台账号
{isAccountsLoading ? (
@@ -157,62 +157,60 @@ export function PublishPage() {
))}
) : (
-
+
{accounts.map((account) => (
-
- {platformIcons[account.platform] ? (
-
- ) : (
-
🌐
- )}
-
-
- {account.name}
-
-
- {account.logged_in ? "✓ 已登录" : "未登录"}
-
+ {platformIcons[account.platform] ? (
+
+ ) : (
+
🌐
+ )}
+
+
+ {account.name}
+
+
+ {account.logged_in ? "✓ 已登录" : "未登录"}
-
+
{account.logged_in ? (
<>
handleLogin(account.platform)}
- className="px-3 py-1 bg-white/10 hover:bg-white/20 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
+ className="px-2 py-1 sm:px-3 sm:py-1.5 bg-white/10 hover:bg-white/20 text-white text-xs sm:text-sm rounded-md sm:rounded-lg transition-colors flex items-center gap-1"
>
-
+
重新登录
handleLogout(account.platform)}
- className="px-3 py-1 bg-red-500/80 hover:bg-red-600 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
+ className="px-2 py-1 sm:px-3 sm:py-1.5 bg-red-500/80 hover:bg-red-600 text-white text-xs sm:text-sm rounded-md sm:rounded-lg transition-colors flex items-center gap-1"
>
-
+
注销
>
) : (
handleLogin(account.platform)}
- className="px-3 py-1 bg-purple-500/80 hover:bg-purple-600 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
+ className="px-2 py-1 sm:px-3 sm:py-1.5 bg-purple-500/80 hover:bg-purple-600 text-white text-xs sm:text-sm rounded-md sm:rounded-lg transition-colors flex items-center gap-1"
>
-
+
登录
)}
@@ -228,7 +226,7 @@ export function PublishPage() {
{/* 选择视频 */}
-
📹 选择发布作品
+
八、选择发布作品
@@ -303,7 +301,7 @@ export function PublishPage() {
{/* 填写信息 */}
-
✍️ 发布信息
+
九、发布信息
@@ -337,7 +335,7 @@ export function PublishPage() {
{/* 选择平台 */}
-
📱 选择发布平台
+
十、选择发布平台
{accounts
diff --git a/frontend/src/shared/contexts/AuthContext.tsx b/frontend/src/shared/contexts/AuthContext.tsx
index 5fc2df6..8f40948 100644
--- a/frontend/src/shared/contexts/AuthContext.tsx
+++ b/frontend/src/shared/contexts/AuthContext.tsx
@@ -11,6 +11,7 @@ interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
+ setUser: (user: User | null) => void;
}
const AuthContext = createContext
({
@@ -18,6 +19,7 @@ const AuthContext = createContext({
user: null,
isLoading: true,
isAuthenticated: false,
+ setUser: () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
@@ -63,7 +65,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
userId: user?.id || null,
user,
isLoading,
- isAuthenticated: !!user
+ isAuthenticated: !!user,
+ setUser,
}}>
{children}