界面优化

This commit is contained in:
Kevin Wong
2026-01-22 11:14:42 +08:00
parent c7e2b4d363
commit ad7ff7a385
5 changed files with 324 additions and 41 deletions

View File

@@ -1,19 +1,33 @@
from fastapi import APIRouter, UploadFile, File, HTTPException from fastapi import APIRouter, UploadFile, File, HTTPException
from app.core.config import settings from app.core.config import settings
import shutil import shutil
import uuid import re
import time
from pathlib import Path from pathlib import Path
router = APIRouter() router = APIRouter()
def sanitize_filename(filename: str) -> str:
"""清理文件名,移除不安全字符"""
# 移除路径分隔符和特殊字符
safe_name = re.sub(r'[<>:"/\\|?*]', '_', filename)
# 限制长度
if len(safe_name) > 100:
ext = Path(safe_name).suffix
safe_name = safe_name[:100 - len(ext)] + ext
return safe_name
@router.post("/") @router.post("/")
async def upload_material(file: UploadFile = File(...)): async def upload_material(file: UploadFile = File(...)):
if not file.filename.lower().endswith(('.mp4', '.mov', '.avi')): if not file.filename.lower().endswith(('.mp4', '.mov', '.avi')):
raise HTTPException(400, "Invalid format") raise HTTPException(400, "Invalid format")
file_id = str(uuid.uuid4()) # 使用时间戳+原始文件名(保留原始名称,避免冲突)
ext = Path(file.filename).suffix timestamp = int(time.time())
save_path = settings.UPLOAD_DIR / "materials" / f"{file_id}{ext}" safe_name = sanitize_filename(file.filename)
save_path = settings.UPLOAD_DIR / "materials" / f"{timestamp}_{safe_name}"
# Save file # Save file
with open(save_path, "wb") as buffer: with open(save_path, "wb") as buffer:
@@ -21,11 +35,14 @@ async def upload_material(file: UploadFile = File(...)):
# Calculate size # Calculate size
size_mb = save_path.stat().st_size / (1024 * 1024) size_mb = save_path.stat().st_size / (1024 * 1024)
# 提取显示名称(去掉时间戳前缀)
display_name = safe_name
return { return {
"id": file_id, "id": save_path.stem,
"name": file.filename, "name": display_name,
"path": f"uploads/materials/{file_id}{ext}", "path": f"uploads/materials/{save_path.name}",
"size_mb": size_mb, "size_mb": size_mb,
"type": "video" "type": "video"
} }
@@ -38,9 +55,16 @@ async def list_materials():
for f in materials_dir.glob("*"): for f in materials_dir.glob("*"):
try: try:
stat = f.stat() stat = f.stat()
# 提取显示名称:去掉时间戳前缀 (格式: {timestamp}_{原始文件名})
display_name = f.name
if '_' in f.name:
parts = f.name.split('_', 1)
if parts[0].isdigit():
display_name = parts[1] # 原始文件名
files.append({ files.append({
"id": f.stem, "id": f.stem,
"name": f.name, "name": display_name,
"path": f"uploads/materials/{f.name}", "path": f"uploads/materials/{f.name}",
"size_mb": stat.st_size / (1024 * 1024), "size_mb": stat.st_size / (1024 * 1024),
"type": "video", "type": "video",
@@ -51,3 +75,26 @@ async def list_materials():
# Sort by creation time desc # Sort by creation time desc
files.sort(key=lambda x: x.get("created_at", 0), reverse=True) files.sort(key=lambda x: x.get("created_at", 0), reverse=True)
return {"materials": files} return {"materials": files}
@router.delete("/{material_id}")
async def delete_material(material_id: str):
"""删除素材文件"""
materials_dir = settings.UPLOAD_DIR / "materials"
# 查找匹配的文件ID 是文件名不含扩展名)
found = None
for f in materials_dir.glob("*"):
if f.stem == material_id:
found = f
break
if not found:
raise HTTPException(404, "Material not found")
try:
found.unlink()
return {"success": True, "message": "素材已删除"}
except Exception as e:
raise HTTPException(500, f"删除失败: {str(e)}")

View File

@@ -141,3 +141,58 @@ async def lipsync_health():
"""获取 LipSync 服务健康状态""" """获取 LipSync 服务健康状态"""
lipsync = _get_lipsync_service() lipsync = _get_lipsync_service()
return await lipsync.check_health() return await lipsync.check_health()
@router.get("/generated")
async def list_generated_videos():
"""从文件系统读取生成的视频列表(持久化)"""
output_dir = settings.OUTPUT_DIR
videos = []
if output_dir.exists():
for f in output_dir.glob("*_output.mp4"):
try:
stat = f.stat()
videos.append({
"id": f.stem,
"name": f.name,
"path": f"/outputs/{f.name}",
"size_mb": stat.st_size / (1024 * 1024),
"created_at": stat.st_ctime
})
except Exception:
continue
# Sort by creation time desc (newest first)
videos.sort(key=lambda x: x.get("created_at", 0), reverse=True)
return {"videos": videos}
@router.delete("/generated/{video_id}")
async def delete_generated_video(video_id: str):
"""删除生成的视频"""
output_dir = settings.OUTPUT_DIR
# 查找匹配的文件
found = None
for f in output_dir.glob("*.mp4"):
if f.stem == video_id:
found = f
break
if not found:
raise HTTPException(404, "Video not found")
try:
found.unlink()
# 同时删除相关的临时文件(如果存在)
task_id = video_id.replace("_output", "")
for suffix in ["_audio.mp3", "_lipsync.mp4"]:
temp_file = output_dir / f"{task_id}{suffix}"
if temp_file.exists():
temp_file.unlink()
return {"success": True, "message": "视频已删除"}
except Exception as e:
raise HTTPException(500, f"删除失败: {str(e)}")

View File

@@ -27,10 +27,63 @@ body {
/* 隐藏滚动条但保留滚动功能 */ /* 隐藏滚动条但保留滚动功能 */
html { html {
scrollbar-width: none; /* Firefox */ scrollbar-width: none;
-ms-overflow-style: none; /* IE 和 Edge */ /* Firefox */
-ms-overflow-style: none;
/* IE 和 Edge */
} }
html::-webkit-scrollbar { html::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */ display: none;
/* Chrome, Safari, Opera */
} }
/* 自定义滚动条样式 - 深色主题 */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(147, 51, 234, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(147, 51, 234, 0.5);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(147, 51, 234, 0.8);
}
/* 完全隐藏滚动条 */
.hide-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* 自定义 select 下拉菜单 */
.custom-select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%239ca3af' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.custom-select option {
background: #1a1a2e;
color: white;
padding: 12px;
}

View File

@@ -26,6 +26,14 @@ interface Task {
download_url?: string; download_url?: string;
} }
interface GeneratedVideo {
id: string;
name: string;
path: string;
size_mb: number;
created_at: number;
}
export default function Home() { export default function Home() {
const [materials, setMaterials] = useState<Material[]>([]); const [materials, setMaterials] = useState<Material[]>([]);
const [selectedMaterial, setSelectedMaterial] = useState<string>(""); const [selectedMaterial, setSelectedMaterial] = useState<string>("");
@@ -41,6 +49,8 @@ export default function Home() {
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 [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
// 可选音色 // 可选音色
const voices = [ const voices = [
@@ -51,9 +61,10 @@ export default function Home() {
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" }, { id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
]; ];
// 加载素材列表 // 加载素材列表和历史视频
useEffect(() => { useEffect(() => {
fetchMaterials(); fetchMaterials();
fetchGeneratedVideos();
}, []); }, []);
const fetchMaterials = async () => { const fetchMaterials = async () => {
@@ -87,6 +98,60 @@ export default function Home() {
} }
}; };
// 获取已生成的视频列表(持久化)
const fetchGeneratedVideos = async () => {
try {
const res = await fetch(`${API_BASE}/api/videos/generated`);
if (res.ok) {
const data = await res.json();
setGeneratedVideos(data.videos || []);
}
} catch (error) {
console.error("获取历史视频失败:", error);
}
};
// 删除素材
const deleteMaterial = async (materialId: string) => {
if (!confirm("确定要删除这个素材吗?")) return;
try {
const res = await fetch(`${API_BASE}/api/materials/${materialId}`, {
method: "DELETE",
});
if (res.ok) {
fetchMaterials();
if (selectedMaterial === materialId) {
setSelectedMaterial("");
}
} else {
alert("删除失败");
}
} catch (error) {
alert("删除失败: " + error);
}
};
// 删除生成的视频
const deleteVideo = async (videoId: string) => {
if (!confirm("确定要删除这个视频吗?")) return;
try {
const res = await fetch(`${API_BASE}/api/videos/generated/${videoId}`, {
method: "DELETE",
});
if (res.ok) {
fetchGeneratedVideos();
if (selectedVideoId === videoId) {
setSelectedVideoId(null);
setGeneratedVideo(null);
}
} else {
alert("删除失败");
}
} catch (error) {
alert("删除失败: " + error);
}
};
// 上传视频 // 上传视频
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -181,6 +246,7 @@ export default function Home() {
if (taskData.status === "completed") { if (taskData.status === "completed") {
setGeneratedVideo(`${API_BASE}${taskData.download_url}`); setGeneratedVideo(`${API_BASE}${taskData.download_url}`);
setIsGenerating(false); setIsGenerating(false);
fetchGeneratedVideos(); // 刷新历史视频列表
} else if (taskData.status === "failed") { } else if (taskData.status === "failed") {
alert("视频生成失败: " + taskData.message); alert("视频生成失败: " + taskData.message);
setIsGenerating(false); setIsGenerating(false);
@@ -320,21 +386,35 @@ export default function Home() {
) : ( ) : (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{materials.map((m) => ( {materials.map((m) => (
<button <div
key={m.id} key={m.id}
onClick={() => setSelectedMaterial(m.id)} className={`p-4 rounded-xl border-2 transition-all text-left relative group ${selectedMaterial === m.id
className={`p-4 rounded-xl border-2 transition-all text-left ${selectedMaterial === m.id
? "border-purple-500 bg-purple-500/20" ? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30" : "border-white/10 bg-white/5 hover:border-white/30"
}`} }`}
> >
<div className="text-white font-medium truncate"> <button
{m.scene || m.name} onClick={() => setSelectedMaterial(m.id)}
</div> className="w-full text-left"
<div className="text-gray-400 text-sm mt-1"> >
{m.size_mb.toFixed(1)} MB <div className="text-white font-medium truncate pr-6">
</div> {m.scene || m.name}
</button> </div>
<div className="text-gray-400 text-sm mt-1">
{m.size_mb.toFixed(1)} MB
</div>
</button>
<button
onClick={(e) => {
e.stopPropagation();
deleteMaterial(m.id);
}}
className="absolute top-2 right-2 p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
title="删除素材"
>
🗑
</button>
</div>
))} ))}
</div> </div>
)} )}
@@ -471,16 +551,66 @@ export default function Home() {
</> </>
)} )}
</div> </div>
{/* 历史视频列表 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
📂
</h2>
<button
onClick={fetchGeneratedVideos}
className="px-3 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300"
>
🔄
</button>
</div>
{generatedVideos.length === 0 ? (
<div className="text-center py-4 text-gray-500">
<p></p>
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar">
{generatedVideos.map((v) => (
<div
key={v.id}
className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedVideoId === v.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<button
onClick={() => {
setSelectedVideoId(v.id);
setGeneratedVideo(`${API_BASE}${v.path}`);
}}
className="flex-1 text-left"
>
<div className="text-white text-sm truncate">
{new Date(v.created_at * 1000).toLocaleString('zh-CN')}
</div>
<div className="text-gray-400 text-xs">
{v.size_mb.toFixed(1)} MB
</div>
</button>
<button
onClick={(e) => {
e.stopPropagation();
deleteVideo(v.id);
}}
className="p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
title="删除视频"
>
🗑
</button>
</div>
))}
</div>
)}
</div>
</div> </div>
</div> </div>
</main> </main>
{/* Footer */}
<footer className="border-t border-white/10 mt-12">
<div className="max-w-6xl mx-auto px-6 py-4 text-center text-gray-500 text-sm">
ViGent - MuseTalk + EdgeTTS
</div>
</footer>
</div> </div>
); );
} }

View File

@@ -52,20 +52,18 @@ export default function PublishPage() {
const fetchVideos = async () => { const fetchVideos = async () => {
try { try {
// 获取已生成的视频列表 (从 outputs 目录) // 使用持久化的视频列表 API从文件系统读取
const res = await fetch(`${API_BASE}/api/videos/tasks`); const res = await fetch(`${API_BASE}/api/videos/generated`);
const data = await res.json(); const data = await res.json();
const completedVideos = data.tasks const videos = (data.videos || []).map((v: any) => ({
?.filter((t: any) => t.status === "completed") name: new Date(v.created_at * 1000).toLocaleString('zh-CN') + ` (${v.size_mb.toFixed(1)}MB)`,
.map((t: any) => ({ path: v.path.startsWith('/') ? v.path.slice(1) : v.path, // 移除开头的 /
name: `${t.task_id}_output.mp4`, }));
path: `outputs/${t.task_id}_output.mp4`,
})) || [];
setVideos(completedVideos); setVideos(videos);
if (completedVideos.length > 0) { if (videos.length > 0) {
setSelectedVideo(completedVideos[0].path); setSelectedVideo(videos[0].path);
} }
} catch (error) { } catch (error) {
console.error("获取视频失败:", error); console.error("获取视频失败:", error);
@@ -281,7 +279,7 @@ export default function PublishPage() {
<select <select
value={selectedVideo} value={selectedVideo}
onChange={(e) => setSelectedVideo(e.target.value)} onChange={(e) => setSelectedVideo(e.target.value)}
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white" className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white custom-select cursor-pointer hover:border-purple-500/50 transition-colors"
> >
{videos.map((v) => ( {videos.map((v) => (
<option key={v.path} value={v.path}> <option key={v.path} value={v.path}>