914 行
40 KiB
TypeScript
914 行
40 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { useAuth } from "@/_core/hooks/useAuth";
|
||
import { trpc } from "@/lib/trpc";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { Slider } from "@/components/ui/slider";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Input } from "@/components/ui/input";
|
||
import { formatDateShanghai, formatDateTimeShanghai } from "@/lib/time";
|
||
import { toast } from "sonner";
|
||
import {
|
||
BarChart3,
|
||
Clock,
|
||
Copy,
|
||
Download,
|
||
FileVideo,
|
||
Pencil,
|
||
Play,
|
||
PlayCircle,
|
||
Plus,
|
||
Scissors,
|
||
Sparkles,
|
||
Trash2,
|
||
Video,
|
||
Zap,
|
||
} from "lucide-react";
|
||
import { useLocation } from "wouter";
|
||
|
||
type ClipDraft = {
|
||
id: string;
|
||
startSec: number;
|
||
endSec: number;
|
||
label: string;
|
||
notes: string;
|
||
source: "manual" | "suggested";
|
||
};
|
||
|
||
type VideoCreateDraft = {
|
||
title: string;
|
||
url: string;
|
||
format: string;
|
||
exerciseType: string;
|
||
fileSizeMb: string;
|
||
durationSec: string;
|
||
};
|
||
|
||
type VideoEditDraft = {
|
||
videoId: number | null;
|
||
title: string;
|
||
exerciseType: string;
|
||
};
|
||
|
||
const statusMap: Record<string, { label: string; color: string }> = {
|
||
pending: { label: "待分析", color: "bg-yellow-100 text-yellow-700" },
|
||
analyzing: { label: "分析中", color: "bg-blue-100 text-blue-700" },
|
||
completed: { label: "已完成", color: "bg-green-100 text-green-700" },
|
||
failed: { label: "失败", color: "bg-red-100 text-red-700" },
|
||
};
|
||
|
||
const exerciseTypeMap: Record<string, string> = {
|
||
forehand: "正手挥拍",
|
||
backhand: "反手挥拍",
|
||
serve: "发球",
|
||
volley: "截击",
|
||
footwork: "脚步移动",
|
||
shadow: "影子挥拍",
|
||
wall: "墙壁练习",
|
||
recording: "录制归档",
|
||
live_analysis: "实时分析",
|
||
};
|
||
|
||
function formatSeconds(totalSeconds: number) {
|
||
const seconds = Math.max(0, Math.floor(totalSeconds));
|
||
const minutes = Math.floor(seconds / 60);
|
||
const rest = seconds % 60;
|
||
return `${minutes.toString().padStart(2, "0")}:${rest.toString().padStart(2, "0")}`;
|
||
}
|
||
|
||
function clamp(value: number, min: number, max: number) {
|
||
return Math.max(min, Math.min(max, value));
|
||
}
|
||
|
||
function localStorageKey(videoId: number) {
|
||
return `clip-plan:${videoId}`;
|
||
}
|
||
|
||
function resolveTimelineDurationSec(analysis: any, durationSec: number) {
|
||
if (durationSec > 0) return durationSec;
|
||
if (typeof analysis?.durationSec === "number" && analysis.durationSec > 0) return analysis.durationSec;
|
||
if (typeof analysis?.durationMs === "number" && analysis.durationMs > 0) return analysis.durationMs / 1000;
|
||
if (typeof analysis?.framesAnalyzed === "number" && analysis.framesAnalyzed > 0) {
|
||
return Math.max(5, Math.round((analysis.framesAnalyzed / 30) * 10) / 10);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function buildSuggestedClips(analysis: any, durationSec: number) {
|
||
const timelineDurationSec = resolveTimelineDurationSec(analysis, durationSec);
|
||
if (!analysis?.keyMoments || !Array.isArray(analysis.keyMoments) || timelineDurationSec <= 0) {
|
||
return [] as ClipDraft[];
|
||
}
|
||
|
||
const framesAnalyzed = Math.max(analysis.framesAnalyzed || 0, 1);
|
||
return analysis.keyMoments.slice(0, 6).map((moment: any, index: number) => {
|
||
const centerSec = clamp(((moment.frame || 0) / framesAnalyzed) * timelineDurationSec, 0, timelineDurationSec);
|
||
const startSec = clamp(centerSec - 1.5, 0, Math.max(0, timelineDurationSec - 0.5));
|
||
const endSec = clamp(centerSec + 2.5, startSec + 0.5, timelineDurationSec);
|
||
return {
|
||
id: `suggested-${index}-${moment.frame || index}`,
|
||
startSec,
|
||
endSec,
|
||
label: moment.description || `建议片段 ${index + 1}`,
|
||
notes: moment.type ? `来源于分析事件:${moment.type}` : "来源于分析关键时刻",
|
||
source: "suggested" as const,
|
||
};
|
||
});
|
||
}
|
||
|
||
function downloadJson(filename: string, data: unknown) {
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = filename;
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function downloadText(filename: string, data: string) {
|
||
const blob = new Blob([data], { type: "text/plain;charset=utf-8" });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = filename;
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function buildClipCueSheet(title: string, clips: ClipDraft[]) {
|
||
return clips.map((clip, index) => (
|
||
`${index + 1}. ${clip.label}\n` +
|
||
` 区间: ${formatSeconds(clip.startSec)} - ${formatSeconds(clip.endSec)}\n` +
|
||
` 时长: ${formatSeconds(Math.max(0, clip.endSec - clip.startSec))}\n` +
|
||
` 来源: ${clip.source === "manual" ? "手动" : "分析建议"}\n` +
|
||
` 备注: ${clip.notes || "无"}`
|
||
)).join("\n\n") + `\n\n视频: ${title}\n导出时间: ${formatDateTimeShanghai(new Date())}\n`;
|
||
}
|
||
|
||
function createEmptyVideoDraft(): VideoCreateDraft {
|
||
return {
|
||
title: "",
|
||
url: "",
|
||
format: "mp4",
|
||
exerciseType: "recording",
|
||
fileSizeMb: "",
|
||
durationSec: "",
|
||
};
|
||
}
|
||
|
||
export default function Videos() {
|
||
useAuth();
|
||
const utils = trpc.useUtils();
|
||
const { data: videos, isLoading } = trpc.video.list.useQuery();
|
||
const { data: analyses } = trpc.analysis.list.useQuery();
|
||
const [, setLocation] = useLocation();
|
||
const registerExternalMutation = trpc.video.registerExternal.useMutation({
|
||
onSuccess: async () => {
|
||
await utils.video.list.invalidate();
|
||
toast.success("视频记录已新增");
|
||
},
|
||
onError: (error) => toast.error(`新增失败: ${error.message}`),
|
||
});
|
||
const updateVideoMutation = trpc.video.update.useMutation({
|
||
onSuccess: async () => {
|
||
await utils.video.list.invalidate();
|
||
toast.success("视频信息已更新");
|
||
},
|
||
onError: (error) => toast.error(`更新失败: ${error.message}`),
|
||
});
|
||
const deleteVideoMutation = trpc.video.delete.useMutation({
|
||
onSuccess: async () => {
|
||
await Promise.all([
|
||
utils.video.list.invalidate(),
|
||
utils.analysis.list.invalidate(),
|
||
]);
|
||
toast.success("视频记录已删除");
|
||
},
|
||
onError: (error) => toast.error(`删除失败: ${error.message}`),
|
||
});
|
||
|
||
const previewRef = useRef<HTMLVideoElement>(null);
|
||
const [editorOpen, setEditorOpen] = useState(false);
|
||
const [selectedVideo, setSelectedVideo] = useState<any | null>(null);
|
||
const [videoDurationSec, setVideoDurationSec] = useState(0);
|
||
const [playbackSec, setPlaybackSec] = useState(0);
|
||
const [clipRange, setClipRange] = useState<[number, number]>([0, 5]);
|
||
const [clipLabel, setClipLabel] = useState("");
|
||
const [clipNotes, setClipNotes] = useState("");
|
||
const [clipDrafts, setClipDrafts] = useState<ClipDraft[]>([]);
|
||
const [activePreviewRange, setActivePreviewRange] = useState<[number, number] | null>(null);
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [editOpen, setEditOpen] = useState(false);
|
||
const [createDraft, setCreateDraft] = useState<VideoCreateDraft>(() => createEmptyVideoDraft());
|
||
const [editDraft, setEditDraft] = useState<VideoEditDraft>({ videoId: null, title: "", exerciseType: "" });
|
||
|
||
const getAnalysis = useCallback((videoId: number) => {
|
||
return analyses?.find((analysis: any) => analysis.videoId === videoId);
|
||
}, [analyses]);
|
||
|
||
const activeAnalysis = selectedVideo ? getAnalysis(selectedVideo.id) : null;
|
||
const timelineDurationSec = useMemo(
|
||
() => resolveTimelineDurationSec(activeAnalysis, videoDurationSec),
|
||
[activeAnalysis, videoDurationSec],
|
||
);
|
||
const suggestedClips = useMemo(
|
||
() => buildSuggestedClips(activeAnalysis, timelineDurationSec),
|
||
[activeAnalysis, timelineDurationSec],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!editorOpen || timelineDurationSec <= 0) return;
|
||
setClipRange((current) => {
|
||
const start = clamp(current[0] ?? 0, 0, Math.max(0, timelineDurationSec - 0.5));
|
||
const minEnd = clamp(start + 0.5, 0.5, timelineDurationSec);
|
||
const end = clamp(current[1] ?? Math.min(timelineDurationSec, 5), minEnd, timelineDurationSec);
|
||
if (start === current[0] && end === current[1]) {
|
||
return current;
|
||
}
|
||
return [start, end];
|
||
});
|
||
}, [editorOpen, timelineDurationSec]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedVideo) return;
|
||
try {
|
||
const saved = localStorage.getItem(localStorageKey(selectedVideo.id));
|
||
if (saved) {
|
||
const parsed = JSON.parse(saved) as ClipDraft[];
|
||
setClipDrafts(parsed);
|
||
return;
|
||
}
|
||
} catch {
|
||
// Ignore corrupted local clip drafts and fall back to suggested clips.
|
||
}
|
||
setClipDrafts(suggestedClips);
|
||
}, [selectedVideo, suggestedClips]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedVideo) return;
|
||
localStorage.setItem(localStorageKey(selectedVideo.id), JSON.stringify(clipDrafts));
|
||
}, [clipDrafts, selectedVideo]);
|
||
|
||
useEffect(() => {
|
||
const video = previewRef.current;
|
||
if (!video || !activePreviewRange) return;
|
||
|
||
const handleTimeUpdate = () => {
|
||
if (video.currentTime >= activePreviewRange[1]) {
|
||
video.currentTime = activePreviewRange[0];
|
||
}
|
||
};
|
||
|
||
video.addEventListener("timeupdate", handleTimeUpdate);
|
||
return () => {
|
||
video.removeEventListener("timeupdate", handleTimeUpdate);
|
||
};
|
||
}, [activePreviewRange]);
|
||
|
||
const openEditor = useCallback((video: any) => {
|
||
setSelectedVideo(video);
|
||
setEditorOpen(true);
|
||
setVideoDurationSec(0);
|
||
setPlaybackSec(0);
|
||
setClipLabel("");
|
||
setClipNotes("");
|
||
setClipRange([0, 5]);
|
||
setActivePreviewRange(null);
|
||
}, []);
|
||
|
||
const openEditDialog = useCallback((video: any) => {
|
||
setEditDraft({
|
||
videoId: video.id,
|
||
title: video.title || "",
|
||
exerciseType: video.exerciseType || "",
|
||
});
|
||
setEditOpen(true);
|
||
}, []);
|
||
|
||
const handleCreateVideo = useCallback(async () => {
|
||
if (!createDraft.title.trim() || !createDraft.url.trim() || !createDraft.format.trim()) {
|
||
toast.error("请填写标题、视频地址和格式");
|
||
return;
|
||
}
|
||
|
||
const fileKey = `external/manual/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${createDraft.format}`;
|
||
await registerExternalMutation.mutateAsync({
|
||
title: createDraft.title.trim(),
|
||
url: createDraft.url.trim(),
|
||
fileKey,
|
||
format: createDraft.format.trim(),
|
||
fileSize: createDraft.fileSizeMb.trim() ? Math.round(Number(createDraft.fileSizeMb) * 1024 * 1024) : undefined,
|
||
duration: createDraft.durationSec.trim() ? Number(createDraft.durationSec) : undefined,
|
||
exerciseType: createDraft.exerciseType.trim() || undefined,
|
||
});
|
||
setCreateOpen(false);
|
||
setCreateDraft(createEmptyVideoDraft());
|
||
}, [createDraft, registerExternalMutation]);
|
||
|
||
const handleUpdateVideo = useCallback(async () => {
|
||
if (!editDraft.videoId || !editDraft.title.trim()) {
|
||
toast.error("请填写视频标题");
|
||
return;
|
||
}
|
||
|
||
await updateVideoMutation.mutateAsync({
|
||
videoId: editDraft.videoId,
|
||
title: editDraft.title.trim(),
|
||
exerciseType: editDraft.exerciseType.trim() || undefined,
|
||
});
|
||
setEditOpen(false);
|
||
setEditDraft({ videoId: null, title: "", exerciseType: "" });
|
||
}, [editDraft, updateVideoMutation]);
|
||
|
||
const handleDeleteVideo = useCallback(async (video: any) => {
|
||
if (!window.confirm(`确认删除视频“${video.title}”?该视频的分析结果和视频索引会一并移除。`)) {
|
||
return;
|
||
}
|
||
|
||
await deleteVideoMutation.mutateAsync({ videoId: video.id });
|
||
|
||
if (selectedVideo?.id === video.id) {
|
||
setEditorOpen(false);
|
||
setSelectedVideo(null);
|
||
}
|
||
}, [deleteVideoMutation, selectedVideo]);
|
||
|
||
const addClip = useCallback((source: "manual" | "suggested", preset?: ClipDraft) => {
|
||
const nextStart = preset?.startSec ?? clipRange[0];
|
||
const nextEnd = preset?.endSec ?? clipRange[1];
|
||
const clip: ClipDraft = {
|
||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
startSec: nextStart,
|
||
endSec: nextEnd,
|
||
label: preset?.label || clipLabel || `片段 ${clipDrafts.length + 1}`,
|
||
notes: preset?.notes || clipNotes,
|
||
source,
|
||
};
|
||
|
||
setClipDrafts((current) => [...current, clip].sort((a, b) => a.startSec - b.startSec));
|
||
setClipLabel("");
|
||
setClipNotes("");
|
||
toast.success("片段已加入轻剪辑草稿");
|
||
}, [clipDrafts.length, clipLabel, clipNotes, clipRange]);
|
||
|
||
const totalClipDurationSec = useMemo(
|
||
() => clipDrafts.reduce((sum, clip) => sum + Math.max(0, clip.endSec - clip.startSec), 0),
|
||
[clipDrafts],
|
||
);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<Skeleton className="h-20 w-full" />
|
||
{[1, 2, 3].map((index) => <Skeleton key={index} className="h-32 w-full" />)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.12),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-semibold tracking-tight" data-testid="videos-title">训练视频库</h1>
|
||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||
集中管理录制归档、上传分析和实时分析视频。桌面端已提供轻剪辑工作台,可按建议片段或手动入点/出点生成剪辑草稿。
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" onClick={() => setCreateOpen(true)} className="gap-2">
|
||
<Plus className="h-4 w-4" />
|
||
新增视频记录
|
||
</Button>
|
||
<Button data-testid="videos-upload-button" onClick={() => setLocation("/analysis")} className="gap-2">
|
||
<Video className="h-4 w-4" />
|
||
上传新视频
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{(!videos || videos.length === 0) ? (
|
||
<Card className="border-0 shadow-sm">
|
||
<CardContent className="py-16 text-center">
|
||
<FileVideo className="mx-auto mb-4 h-12 w-12 text-muted-foreground/30" />
|
||
<h3 className="mb-2 text-lg font-semibold">还没有训练视频</h3>
|
||
<p className="mb-4 text-sm text-muted-foreground">上传训练视频后,这里会自动汇总分析结果,并提供轻剪辑入口。</p>
|
||
<div className="flex justify-center gap-2">
|
||
<Button variant="outline" onClick={() => setCreateOpen(true)} className="gap-2">
|
||
<Plus className="h-4 w-4" />
|
||
新增视频记录
|
||
</Button>
|
||
<Button onClick={() => setLocation("/analysis")} className="gap-2">
|
||
<Video className="h-4 w-4" />
|
||
上传第一个视频
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{videos.map((video: any) => {
|
||
const analysis = getAnalysis(video.id);
|
||
const status = statusMap[video.analysisStatus] || statusMap.pending;
|
||
|
||
return (
|
||
<Card key={video.id} className="border-0 shadow-sm transition-shadow hover:shadow-md" data-testid="video-card">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-start gap-4">
|
||
<div className="flex h-20 w-28 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-black/5">
|
||
{video.url ? (
|
||
<video src={video.url} className="h-full w-full object-cover" muted preload="metadata" />
|
||
) : (
|
||
<Play className="h-6 w-6 text-muted-foreground/40" />
|
||
)}
|
||
</div>
|
||
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||
<div>
|
||
<h3 className="truncate text-sm font-medium">{video.title}</h3>
|
||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||
<Badge className={`${status.color} border text-xs`}>{status.label}</Badge>
|
||
{video.exerciseType ? (
|
||
<Badge variant="outline" className="text-xs">
|
||
{exerciseTypeMap[video.exerciseType] || video.exerciseType}
|
||
</Badge>
|
||
) : null}
|
||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||
<Clock className="h-3 w-3" />
|
||
{formatDateShanghai(video.createdAt)}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{((video.fileSize || 0) / 1024 / 1024).toFixed(1)}MB
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{video.url ? (
|
||
<Button variant="outline" size="sm" className="gap-2" onClick={() => window.open(video.url, "_blank", "noopener,noreferrer")}>
|
||
<PlayCircle className="h-4 w-4" />
|
||
播放
|
||
</Button>
|
||
) : null}
|
||
<Button variant="outline" size="sm" className="gap-2" onClick={() => openEditDialog(video)}>
|
||
<Pencil className="h-4 w-4" />
|
||
编辑
|
||
</Button>
|
||
<Button variant="outline" size="sm" className="gap-2" onClick={() => openEditor(video)}>
|
||
<Scissors className="h-4 w-4" />
|
||
轻剪辑
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="gap-2 text-red-600 hover:text-red-700"
|
||
onClick={() => void handleDeleteVideo(video)}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
删除
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{analysis ? (
|
||
<div className="mt-3 flex flex-wrap items-center gap-4 text-xs">
|
||
<div className="flex items-center gap-1">
|
||
<BarChart3 className="h-3 w-3 text-primary" />
|
||
<span className="font-medium">{Math.round(analysis.overallScore || 0)}分</span>
|
||
</div>
|
||
{(analysis.shotCount ?? 0) > 0 ? (
|
||
<div className="flex items-center gap-1">
|
||
<Zap className="h-3 w-3 text-orange-500" />
|
||
<span>{analysis.shotCount} 次击球</span>
|
||
</div>
|
||
) : null}
|
||
{(analysis.strokeConsistency ?? 0) > 0 ? (
|
||
<div className="text-muted-foreground">
|
||
一致性 {Math.round(analysis.strokeConsistency ?? 0)}%
|
||
</div>
|
||
) : null}
|
||
{Array.isArray(analysis.keyMoments) && analysis.keyMoments.length > 0 ? (
|
||
<Badge variant="outline" className="gap-1 text-xs">
|
||
<Sparkles className="h-3 w-3" />
|
||
{analysis.keyMoments.length} 个建议片段
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
||
<DialogContent className="max-h-[92vh] max-w-5xl overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Scissors className="h-5 w-5 text-primary" />
|
||
PC 轻剪辑工作台
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
支持手动设置入点/出点、按分析关键时刻生成建议片段,并把剪辑草稿导出为 JSON。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{selectedVideo ? (
|
||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.9fr)]">
|
||
<section className="space-y-4">
|
||
<div className="overflow-hidden rounded-3xl border border-border/60 bg-black">
|
||
<video
|
||
ref={previewRef}
|
||
src={selectedVideo.url}
|
||
className="aspect-video w-full object-contain"
|
||
controls
|
||
playsInline
|
||
onLoadedMetadata={(event) => {
|
||
const duration = event.currentTarget.duration || 0;
|
||
setVideoDurationSec(duration);
|
||
setClipRange([0, Math.min(duration, 5)]);
|
||
}}
|
||
onTimeUpdate={(event) => setPlaybackSec(event.currentTarget.currentTime || 0)}
|
||
/>
|
||
</div>
|
||
|
||
<Card className="border-0 shadow-sm">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-base">片段设置</CardTitle>
|
||
<CardDescription>建议先在播放器中定位,再设置入点和出点。</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-3 md:grid-cols-4">
|
||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">草稿片段</div>
|
||
<div className="mt-2 text-lg font-semibold">{clipDrafts.length}</div>
|
||
</div>
|
||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">总剪辑时长</div>
|
||
<div className="mt-2 text-lg font-semibold">{formatSeconds(totalClipDurationSec)}</div>
|
||
</div>
|
||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">建议片段</div>
|
||
<div className="mt-2 text-lg font-semibold">{suggestedClips.length}</div>
|
||
</div>
|
||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">当前区间时长</div>
|
||
<div className="mt-2 text-lg font-semibold">{formatSeconds(Math.max(0, clipRange[1] - clipRange[0]))}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-3">
|
||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">当前播放</div>
|
||
<div className="mt-2 text-lg font-semibold">{formatSeconds(playbackSec)}</div>
|
||
</div>
|
||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">入点</div>
|
||
<div className="mt-2 text-lg font-semibold">{formatSeconds(clipRange[0])}</div>
|
||
</div>
|
||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">出点</div>
|
||
<div className="mt-2 text-lg font-semibold">{formatSeconds(clipRange[1])}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{timelineDurationSec > 0 ? (
|
||
<Slider
|
||
value={clipRange}
|
||
min={0}
|
||
max={timelineDurationSec}
|
||
step={0.1}
|
||
onValueChange={(value) => {
|
||
if (value.length === 2) {
|
||
setClipRange([value[0] || 0, value[1] || Math.max(0.5, timelineDurationSec)]);
|
||
}
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setClipRange(([_, end]) => [clamp(playbackSec, 0, Math.max(0, end - 0.5)), end])}
|
||
>
|
||
设为入点
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setClipRange(([start]) => [start, clamp(playbackSec, start + 0.5, timelineDurationSec || playbackSec + 0.5)])}
|
||
>
|
||
设为出点
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
if (previewRef.current) previewRef.current.currentTime = clipRange[0];
|
||
}}
|
||
>
|
||
跳到入点
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={async () => {
|
||
if (!previewRef.current) return;
|
||
setActivePreviewRange([clipRange[0], clipRange[1]]);
|
||
previewRef.current.currentTime = clipRange[0];
|
||
await previewRef.current.play().catch(() => undefined);
|
||
}}
|
||
>
|
||
循环预览当前区间
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setActivePreviewRange(null);
|
||
previewRef.current?.pause();
|
||
}}
|
||
>
|
||
停止循环
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<Input
|
||
value={clipLabel}
|
||
onChange={(event) => setClipLabel(event.target.value)}
|
||
placeholder="片段名称,例如:正手节奏稳定段"
|
||
className="h-11 rounded-2xl"
|
||
/>
|
||
<Button onClick={() => addClip("manual")} className="h-11 rounded-2xl gap-2">
|
||
<Scissors className="h-4 w-4" />
|
||
加入剪辑草稿
|
||
</Button>
|
||
</div>
|
||
<Textarea
|
||
value={clipNotes}
|
||
onChange={(event) => setClipNotes(event.target.value)}
|
||
placeholder="记录这个片段为什么要保留,或后续想怎么讲解"
|
||
className="min-h-24 rounded-2xl"
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
</section>
|
||
|
||
<aside className="space-y-4">
|
||
<Card className="border-0 shadow-sm">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-base">建议片段</CardTitle>
|
||
<CardDescription>来自视频分析关键时刻,可一键加入剪辑草稿。</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{suggestedClips.length === 0 ? (
|
||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
|
||
当前视频暂无自动建议片段。
|
||
</div>
|
||
) : (
|
||
suggestedClips.map((clip: ClipDraft) => (
|
||
<div key={clip.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||
<div className="font-medium">{clip.label}</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
{formatSeconds(clip.startSec)} - {formatSeconds(clip.endSec)}
|
||
</div>
|
||
<div className="mt-2 text-sm text-muted-foreground">{clip.notes}</div>
|
||
<div className="mt-3 flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setClipRange([clip.startSec, clip.endSec]);
|
||
if (previewRef.current) previewRef.current.currentTime = clip.startSec;
|
||
}}
|
||
>
|
||
载入区间
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={async () => {
|
||
setActivePreviewRange([clip.startSec, clip.endSec]);
|
||
if (previewRef.current) {
|
||
previewRef.current.currentTime = clip.startSec;
|
||
await previewRef.current.play().catch(() => undefined);
|
||
}
|
||
}}
|
||
>
|
||
循环预览
|
||
</Button>
|
||
<Button size="sm" onClick={() => addClip("suggested", clip)}>加入草稿</Button>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-0 shadow-sm">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-base">剪辑草稿</CardTitle>
|
||
<CardDescription>草稿保存在浏览器本地,可随时导出给后续后台剪辑任务使用。</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{clipDrafts.length === 0 ? (
|
||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
|
||
还没有片段草稿。
|
||
</div>
|
||
) : (
|
||
clipDrafts.map((clip: ClipDraft) => (
|
||
<div key={clip.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium">{clip.label}</span>
|
||
<Badge variant="outline">{clip.source === "manual" ? "手动" : "建议"}</Badge>
|
||
<Badge variant="secondary">{formatSeconds(Math.max(0, clip.endSec - clip.startSec))}</Badge>
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
{formatSeconds(clip.startSec)} - {formatSeconds(clip.endSec)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => {
|
||
setClipRange([clip.startSec, clip.endSec]);
|
||
setClipLabel(clip.label);
|
||
setClipNotes(clip.notes);
|
||
if (previewRef.current) {
|
||
previewRef.current.currentTime = clip.startSec;
|
||
}
|
||
}}
|
||
>
|
||
<Play className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => {
|
||
const value = `${clip.label} ${formatSeconds(clip.startSec)}-${formatSeconds(clip.endSec)} ${clip.notes || ""}`.trim();
|
||
if (!navigator.clipboard) {
|
||
toast.error("当前浏览器不支持剪贴板复制");
|
||
return;
|
||
}
|
||
void navigator.clipboard.writeText(value).then(
|
||
() => toast.success("片段信息已复制"),
|
||
() => toast.error("片段复制失败"),
|
||
);
|
||
}}
|
||
>
|
||
<Copy className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => setClipDrafts((current) => current.filter((item) => item.id !== clip.id))}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{clip.notes ? <div className="mt-2 text-sm text-muted-foreground">{clip.notes}</div> : null}
|
||
</div>
|
||
))
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</aside>
|
||
</div>
|
||
) : null}
|
||
|
||
<DialogFooter className="flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
if (!selectedVideo) return;
|
||
const payload = {
|
||
videoId: selectedVideo.id,
|
||
title: selectedVideo.title,
|
||
url: selectedVideo.url,
|
||
clipDrafts,
|
||
exportedAt: new Date().toISOString(),
|
||
};
|
||
downloadJson(`${selectedVideo.title}-clip-plan.json`, payload);
|
||
}}
|
||
className="gap-2"
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
导出草稿
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
if (!selectedVideo) return;
|
||
downloadText(`${selectedVideo.title}-clip-cuesheet.txt`, buildClipCueSheet(selectedVideo.title, clipDrafts));
|
||
}}
|
||
className="gap-2"
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
导出清单
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setEditorOpen(false)}>关闭</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||
<DialogContent className="max-w-xl">
|
||
<DialogHeader>
|
||
<DialogTitle>新增视频记录</DialogTitle>
|
||
<DialogDescription>
|
||
可录入已有外部视频地址或历史归档链接,纳入当前视频库统一管理。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<Input
|
||
value={createDraft.title}
|
||
onChange={(event) => setCreateDraft((current) => ({ ...current, title: event.target.value }))}
|
||
placeholder="视频标题"
|
||
/>
|
||
<Input
|
||
value={createDraft.url}
|
||
onChange={(event) => setCreateDraft((current) => ({ ...current, url: event.target.value }))}
|
||
placeholder="视频地址,例如 https://... 或 /uploads/..."
|
||
/>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<Input
|
||
value={createDraft.format}
|
||
onChange={(event) => setCreateDraft((current) => ({ ...current, format: event.target.value }))}
|
||
placeholder="格式,例如 mp4 / webm"
|
||
/>
|
||
<Input
|
||
value={createDraft.exerciseType}
|
||
onChange={(event) => setCreateDraft((current) => ({ ...current, exerciseType: event.target.value }))}
|
||
placeholder="动作类型,例如 forehand / recording"
|
||
/>
|
||
</div>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<Input
|
||
value={createDraft.fileSizeMb}
|
||
onChange={(event) => setCreateDraft((current) => ({ ...current, fileSizeMb: event.target.value }))}
|
||
placeholder="文件大小(MB,可选)"
|
||
inputMode="decimal"
|
||
/>
|
||
<Input
|
||
value={createDraft.durationSec}
|
||
onChange={(event) => setCreateDraft((current) => ({ ...current, durationSec: event.target.value }))}
|
||
placeholder="时长(秒,可选)"
|
||
inputMode="decimal"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||
<Button onClick={() => void handleCreateVideo()} disabled={registerExternalMutation.isPending}>
|
||
{registerExternalMutation.isPending ? "新增中..." : "新增记录"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||
<DialogContent className="max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>编辑视频信息</DialogTitle>
|
||
<DialogDescription>
|
||
可调整视频标题和动作类型,列表与分析归档会同步显示最新信息。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<Input
|
||
value={editDraft.title}
|
||
onChange={(event) => setEditDraft((current) => ({ ...current, title: event.target.value }))}
|
||
placeholder="视频标题"
|
||
/>
|
||
<Input
|
||
value={editDraft.exerciseType}
|
||
onChange={(event) => setEditDraft((current) => ({ ...current, exerciseType: event.target.value }))}
|
||
placeholder="动作类型,例如 forehand / recording"
|
||
/>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setEditOpen(false)}>取消</Button>
|
||
<Button onClick={() => void handleUpdateVideo()} disabled={updateVideoMutation.isPending}>
|
||
{updateVideoMutation.isPending ? "保存中..." : "保存修改"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|