界面优化

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 app.core.config import settings
import shutil
import uuid
import re
import time
from pathlib import Path
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("/")
async def upload_material(file: UploadFile = File(...)):
if not file.filename.lower().endswith(('.mp4', '.mov', '.avi')):
raise HTTPException(400, "Invalid format")
file_id = str(uuid.uuid4())
ext = Path(file.filename).suffix
save_path = settings.UPLOAD_DIR / "materials" / f"{file_id}{ext}"
# 使用时间戳+原始文件名(保留原始名称,避免冲突)
timestamp = int(time.time())
safe_name = sanitize_filename(file.filename)
save_path = settings.UPLOAD_DIR / "materials" / f"{timestamp}_{safe_name}"
# Save file
with open(save_path, "wb") as buffer:
@@ -21,11 +35,14 @@ async def upload_material(file: UploadFile = File(...)):
# Calculate size
size_mb = save_path.stat().st_size / (1024 * 1024)
# 提取显示名称(去掉时间戳前缀)
display_name = safe_name
return {
"id": file_id,
"name": file.filename,
"path": f"uploads/materials/{file_id}{ext}",
"id": save_path.stem,
"name": display_name,
"path": f"uploads/materials/{save_path.name}",
"size_mb": size_mb,
"type": "video"
}
@@ -38,9 +55,16 @@ async def list_materials():
for f in materials_dir.glob("*"):
try:
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({
"id": f.stem,
"name": f.name,
"name": display_name,
"path": f"uploads/materials/{f.name}",
"size_mb": stat.st_size / (1024 * 1024),
"type": "video",
@@ -51,3 +75,26 @@ async def list_materials():
# Sort by creation time desc
files.sort(key=lambda x: x.get("created_at", 0), reverse=True)
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 = _get_lipsync_service()
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 {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 和 Edge */
scrollbar-width: none;
/* Firefox */
-ms-overflow-style: none;
/* IE 和 Edge */
}
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;
}
interface GeneratedVideo {
id: string;
name: string;
path: string;
size_mb: number;
created_at: number;
}
export default function Home() {
const [materials, setMaterials] = useState<Material[]>([]);
const [selectedMaterial, setSelectedMaterial] = useState<string>("");
@@ -41,6 +49,8 @@ export default function Home() {
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
// 可选音色
const voices = [
@@ -51,9 +61,10 @@ export default function Home() {
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
];
// 加载素材列表
// 加载素材列表和历史视频
useEffect(() => {
fetchMaterials();
fetchGeneratedVideos();
}, []);
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 file = e.target.files?.[0];
@@ -181,6 +246,7 @@ export default function Home() {
if (taskData.status === "completed") {
setGeneratedVideo(`${API_BASE}${taskData.download_url}`);
setIsGenerating(false);
fetchGeneratedVideos(); // 刷新历史视频列表
} else if (taskData.status === "failed") {
alert("视频生成失败: " + taskData.message);
setIsGenerating(false);
@@ -320,21 +386,35 @@ export default function Home() {
) : (
<div className="grid grid-cols-2 gap-3">
{materials.map((m) => (
<button
<div
key={m.id}
onClick={() => setSelectedMaterial(m.id)}
className={`p-4 rounded-xl border-2 transition-all text-left ${selectedMaterial === m.id
className={`p-4 rounded-xl border-2 transition-all text-left relative group ${selectedMaterial === m.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<div className="text-white font-medium truncate">
{m.scene || m.name}
</div>
<div className="text-gray-400 text-sm mt-1">
{m.size_mb.toFixed(1)} MB
</div>
</button>
<button
onClick={() => setSelectedMaterial(m.id)}
className="w-full text-left"
>
<div className="text-white font-medium truncate pr-6">
{m.scene || m.name}
</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>
)}
@@ -471,16 +551,66 @@ export default function Home() {
</>
)}
</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>
</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>
);
}

View File

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