Add CRUD support for training videos

这个提交包含在:
cryptocommuniums-afk
2026-03-15 14:17:59 +08:00
父节点 143c60a054
当前提交 bd8998166b
修改 5 个文件,包含 877 行新增71 行删除

查看文件

@@ -16,8 +16,10 @@ import {
Copy,
Download,
FileVideo,
Pencil,
Play,
PlayCircle,
Plus,
Scissors,
Sparkles,
Trash2,
@@ -35,6 +37,21 @@ type ClipDraft = {
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" },
@@ -131,11 +148,47 @@ function buildClipCueSheet(title: string, clips: ClipDraft[]) {
)).join("\n\n") + `\n\n视频: ${title}\n导出时间: ${new Date().toLocaleString("zh-CN")}\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);
@@ -147,6 +200,10 @@ export default function Videos() {
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);
@@ -222,6 +279,63 @@ export default function Videos() {
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];
@@ -265,6 +379,10 @@ export default function Videos() {
</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" />
@@ -279,10 +397,16 @@ export default function Videos() {
<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>
<Button onClick={() => setLocation("/analysis")} className="gap-2">
<Video className="h-4 w-4" />
</Button>
<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>
) : (
@@ -330,10 +454,23 @@ export default function Videos() {
</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>
@@ -686,6 +823,90 @@ export default function Videos() {
</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>
);
}