Add CRUD support for training videos
这个提交包含在:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户