Harden async task flows and enhance analysis tooling

这个提交包含在:
cryptocommuniums-afk
2026-03-15 08:05:37 +08:00
父节点 585fd5773d
当前提交 cb643ac154
修改 14 个文件,包含 566 行新增33 行删除

查看文件

@@ -13,6 +13,7 @@ import { toast } from "sonner";
import {
BarChart3,
Clock,
Copy,
Download,
FileVideo,
Play,
@@ -110,6 +111,26 @@ function downloadJson(filename: string, data: unknown) {
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导出时间: ${new Date().toLocaleString("zh-CN")}\n`;
}
export default function Videos() {
useAuth();
const { data: videos, isLoading } = trpc.video.list.useQuery();
@@ -125,6 +146,7 @@ export default function Videos() {
const [clipLabel, setClipLabel] = useState("");
const [clipNotes, setClipNotes] = useState("");
const [clipDrafts, setClipDrafts] = useState<ClipDraft[]>([]);
const [activePreviewRange, setActivePreviewRange] = useState<[number, number] | null>(null);
const getAnalysis = useCallback((videoId: number) => {
return analyses?.find((analysis: any) => analysis.videoId === videoId);
@@ -173,6 +195,22 @@ export default function Videos() {
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);
@@ -181,6 +219,7 @@ export default function Videos() {
setClipLabel("");
setClipNotes("");
setClipRange([0, 5]);
setActivePreviewRange(null);
}, []);
const addClip = useCallback((source: "manual" | "suggested", preset?: ClipDraft) => {
@@ -201,6 +240,11 @@ export default function Videos() {
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">
@@ -358,12 +402,31 @@ export default function Videos() {
/>
</div>
<Card className="border-0 shadow-sm">
<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>
@@ -417,6 +480,28 @@ export default function Videos() {
>
</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">
@@ -469,7 +554,20 @@ export default function Videos() {
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>
@@ -497,18 +595,52 @@ export default function Videos() {
<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>
<Button
variant="ghost"
size="icon"
onClick={() => setClipDrafts((current) => current.filter((item) => item.id !== clip.id))}
>
<Trash2 className="h-4 w-4" />
</Button>
<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>
@@ -525,19 +657,31 @@ export default function Videos() {
variant="outline"
onClick={() => {
if (!selectedVideo) return;
downloadJson(`${selectedVideo.title}-clip-plan.json`, {
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>