Files
NianAIGC/components/asset-manager.tsx
2026-05-29 12:32:02 +08:00

498 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Download, Eye, ImageIcon, Info, Loader2, Music, RefreshCw, Trash2, X } from "lucide-react";
import { clampPage, pageItems, Pagination } from "@/components/pagination";
import { modalEnter, modalExit, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
import type { Asset, GenerationJob } from "@/lib/types";
type AssetView = "assets" | "tasks";
const ASSET_PAGE_SIZE = 8;
const TASK_PAGE_SIZE = 8;
export function AssetManager() {
const [assets, setAssets] = useState<Asset[]>([]);
const [jobs, setJobs] = useState<GenerationJob[]>([]);
const [view, setView] = useState<AssetView>("assets");
const [loading, setLoading] = useState(false);
const [syncingJobId, setSyncingJobId] = useState<string | null>(null);
const [deletingAssetId, setDeletingAssetId] = useState<string | null>(null);
const [deletingJobId, setDeletingJobId] = useState<string | null>(null);
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
const [assetPage, setAssetPage] = useState(1);
const [jobPage, setJobPage] = useState(1);
const [error, setError] = useState<string | null>(null);
const managerRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const feedbackRef = useRef<HTMLDivElement | null>(null);
const previewDialogRef = useRef<HTMLDivElement | null>(null);
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
refresh().catch(() => undefined);
}, []);
const jobByOutputAssetId = useMemo(() => {
const map = new Map<string, GenerationJob>();
for (const job of jobs) {
for (const assetId of job.outputAssetIds) map.set(assetId, job);
}
return map;
}, [jobs]);
const visibleAssets = pageItems(assets, assetPage, ASSET_PAGE_SIZE);
const visibleJobs = pageItems(jobs, jobPage, TASK_PAGE_SIZE);
useEffect(() => {
setAssetPage((page) => clampPage(page, assets.length, ASSET_PAGE_SIZE));
}, [assets.length]);
useEffect(() => {
setJobPage((page) => clampPage(page, jobs.length, TASK_PAGE_SIZE));
}, [jobs.length]);
useEffect(() => {
if (view === "assets") setAssetPage((page) => clampPage(page, assets.length, ASSET_PAGE_SIZE));
if (view === "tasks") setJobPage((page) => clampPage(page, jobs.length, TASK_PAGE_SIZE));
}, [view, assets.length, jobs.length]);
useEffect(() => {
return runScopedMotion(managerRef, (scope) => revealChildren(scope));
}, []);
useEffect(() => {
if (listRef.current) revealChildren(listRef.current, ".asset-history-card, .compact-job, .job-detail-panel");
}, [view, assetPage, jobPage, visibleAssets.length, visibleJobs.length, expandedJobId]);
useEffect(() => {
pulseFeedback(feedbackRef.current);
}, [error]);
useEffect(() => {
if (!previewAsset) return undefined;
modalEnter(previewDialogRef.current);
window.requestAnimationFrame(() => closeButtonRef.current?.focus());
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closePreview();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [previewAsset]);
async function refresh() {
setLoading(true);
setError(null);
try {
const [assetResponse, imageResponse, videoResponse] = await Promise.all([
fetch("/api/assets", { cache: "no-store" }),
fetch("/api/generations/image", { cache: "no-store" }),
fetch("/api/generations/video", { cache: "no-store" })
]);
const [assetPayload, imagePayload, videoPayload] = await Promise.all([
assetResponse.json(),
imageResponse.json(),
videoResponse.json()
]);
if (!assetResponse.ok) throw new Error(assetPayload.error || "读取资产失败");
if (!imageResponse.ok) throw new Error(imagePayload.error || "读取图片任务失败");
if (!videoResponse.ok) throw new Error(videoPayload.error || "读取视频任务失败");
const dedupedJobs = new Map<string, GenerationJob>();
for (const job of [...(imagePayload.jobs || []), ...(videoPayload.jobs || [])] as GenerationJob[]) {
dedupedJobs.set(job.id, job);
}
const nextJobs = [...dedupedJobs.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const outputAssetIds = new Set(nextJobs.flatMap((job) => job.outputAssetIds));
setAssets((assetPayload.assets || []).filter((asset: Asset) => isResultAsset(asset, outputAssetIds)));
setJobs(nextJobs);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
async function syncJob(job: GenerationJob) {
setSyncingJobId(job.id);
setError(null);
try {
const response = await fetch(jobPath(job), { cache: "no-store" });
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "查询任务失败");
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSyncingJobId(null);
}
}
async function removeAsset(asset: Asset) {
if (!window.confirm(`删除「${asset.name}」?对应 OSS 对象会一并删除。`)) return;
setDeletingAssetId(asset.id);
setError(null);
try {
const response = await fetch(`/api/assets/${asset.id}`, { method: "DELETE" });
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "删除资产失败");
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setDeletingAssetId(null);
}
}
async function removeJob(job: GenerationJob) {
if (!window.confirm(`删除「${capabilityLabel(job.capability)}」任务?任务输出结果和对应 OSS 对象会一并删除。`)) return;
setDeletingJobId(job.id);
setError(null);
try {
const response = await fetch(jobPath(job), { method: "DELETE" });
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "删除任务失败");
if (expandedJobId === job.id) setExpandedJobId(null);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setDeletingJobId(null);
}
}
function closePreview() {
modalExit(previewDialogRef.current, () => setPreviewAsset(null));
}
return (
<div className="asset-manager grid" ref={managerRef}>
<div className="workspace-head" data-animate>
<div>
<h1 className="workspace-title"></h1>
</div>
</div>
<section className="panel" data-animate>
<div className="asset-manager-toolbar">
<div className="segmented asset-view-switch" aria-label="结果视图">
<button type="button" className={view === "assets" ? "active" : ""} aria-pressed={view === "assets"} onClick={() => setView("assets")}>
</button>
<button type="button" className={view === "tasks" ? "active" : ""} aria-pressed={view === "tasks"} onClick={() => setView("tasks")}>
</button>
</div>
<button className="button" type="button" onClick={refresh} disabled={loading}>
{loading ? <Loader2 className="spin" size={18} /> : <RefreshCw size={18} />}
</button>
</div>
{error ? <div className="callout asset-error" ref={feedbackRef} role="alert">{error}</div> : null}
{view === "assets" ? (
<>
<div className="asset-grid asset-history-grid" ref={listRef}>
{visibleAssets.map((asset) => (
<article className="card asset-history-card" key={asset.id}>
<button className="asset-preview-button" type="button" onClick={() => setPreviewAsset(asset)} aria-label={`预览 ${asset.name}`}>
{renderAssetPreview(asset)}
</button>
<div className="card-body asset-history-body">
<div className="toolbar asset-card-title">
<h3>{asset.name}</h3>
<span className="status">{sourceLabel(asset.source)}</span>
</div>
<p className="muted asset-card-meta">{kindLabel(asset)} / {formatDate(asset.createdAt)}</p>
<div className="toolbar asset-card-actions">
<button className="button mini-button" type="button" title="预览" aria-label={`预览 ${asset.name}`} onClick={() => setPreviewAsset(asset)}>
<Eye size={15} />
</button>
<a className="button mini-button" href={assetDownloadUrl(asset)} title="下载" aria-label={`下载 ${asset.name}`}>
<Download size={15} />
</a>
<button className="button danger mini-button" type="button" title="删除" aria-label={`删除 ${asset.name}`} onClick={() => removeAsset(asset)} disabled={deletingAssetId === asset.id}>
{deletingAssetId === asset.id ? <Loader2 className="spin" size={15} /> : <Trash2 size={15} />}
</button>
{jobByOutputAssetId.get(asset.id) ? (
<span className="muted compact-hint">{capabilityLabel(jobByOutputAssetId.get(asset.id)?.capability)}</span>
) : null}
</div>
</div>
</article>
))}
{!assets.length ? <div className="callout"></div> : null}
</div>
<Pagination page={assetPage} pageSize={ASSET_PAGE_SIZE} total={assets.length} label="资产分页" onPageChange={setAssetPage} />
</>
) : (
<>
<div className="grid task-history-list" ref={listRef}>
{visibleJobs.map((job) => (
<article className="card compact-job" key={job.id}>
<div className="card-body">
<div className="job-summary">
<h3>{job.prompt?.slice(0, 90) || capabilityLabel(job.capability)}</h3>
<p className="muted">{capabilityLabel(job.capability)} / {job.reqKey}</p>
<p className="muted compact-hint">{durationLabel(job)} / {job.inputAssetIds.length || job.inputUrls.length} {job.outputAssetIds.length} </p>
</div>
<div className="toolbar job-actions">
<span className={`status ${job.status}`}>{statusLabel(job.status)}</span>
<button className="button mini-button" type="button" aria-expanded={expandedJobId === job.id} onClick={() => setExpandedJobId(expandedJobId === job.id ? null : job.id)}>
<Info size={15} />
{expandedJobId === job.id ? "收起" : "详情"}
</button>
<button className="icon-button" title="查询任务" aria-label="查询任务" type="button" onClick={() => syncJob(job)} disabled={syncingJobId === job.id || deletingJobId === job.id}>
{syncingJobId === job.id ? <Loader2 className="spin" size={17} /> : <RefreshCw size={17} />}
</button>
<button className="icon-button" title="删除任务" aria-label="删除任务" type="button" onClick={() => removeJob(job)} disabled={deletingJobId === job.id}>
{deletingJobId === job.id ? <Loader2 className="spin" size={17} /> : <Trash2 size={17} />}
</button>
</div>
</div>
{expandedJobId === job.id ? <JobDetails job={job} /> : null}
</article>
))}
{!jobs.length ? <div className="callout"></div> : null}
</div>
<Pagination page={jobPage} pageSize={TASK_PAGE_SIZE} total={jobs.length} label="任务分页" onPageChange={setJobPage} />
</>
)}
</section>
{previewAsset ? (
<div className="asset-preview-backdrop" role="presentation" onMouseDown={closePreview}>
<div className="asset-preview-dialog" role="dialog" aria-modal="true" aria-label={`预览 ${previewAsset.name}`} ref={previewDialogRef} onMouseDown={(event) => event.stopPropagation()}>
<div className="asset-preview-head">
<div>
<strong>{previewAsset.name}</strong>
<span className="muted compact-hint">{kindLabel(previewAsset)} / {sourceLabel(previewAsset.source)} / {formatDate(previewAsset.createdAt)}</span>
</div>
<div className="asset-preview-actions">
<a className="icon-button" href={assetDownloadUrl(previewAsset)} title="下载" aria-label={`下载 ${previewAsset.name}`}>
<Download size={18} />
</a>
<button className="icon-button" type="button" title="关闭预览" aria-label="关闭预览" ref={closeButtonRef} onClick={closePreview}>
<X size={18} />
</button>
</div>
</div>
<div className="asset-preview-media">
{renderAssetPreviewLarge(previewAsset)}
</div>
</div>
</div>
) : null}
</div>
);
}
function JobDetails({ job }: { job: GenerationJob }) {
return (
<div className="job-detail-panel">
<dl className="job-detail-grid">
<div>
<dt></dt>
<dd><span className={`status ${job.status}`}>{statusLabel(job.status)}</span></dd>
</div>
<div>
<dt></dt>
<dd>{durationValue(job)}</dd>
</div>
<div>
<dt></dt>
<dd>{formatDateTime(job.createdAt)}</dd>
</div>
<div>
<dt></dt>
<dd>{formatDateTime(job.updatedAt)}</dd>
</div>
<div>
<dt></dt>
<dd>{capabilityLabel(job.capability)}</dd>
</div>
<div>
<dt></dt>
<dd>{providerLabel(job.provider)}</dd>
</div>
<div>
<dt>Req Key</dt>
<dd>{job.reqKey}</dd>
</div>
<div>
<dt> ID</dt>
<dd>{job.providerTaskId || job.id}</dd>
</div>
<div>
<dt>/</dt>
<dd>{job.inputAssetIds.length || job.inputUrls.length} / {job.outputAssetIds.length}</dd>
</div>
{job.error ? (
<div>
<dt></dt>
<dd>{job.error.message}</dd>
</div>
) : null}
</dl>
{job.prompt ? (
<div className="job-prompt-detail">
<span className="muted compact-hint"></span>
<p>{job.prompt}</p>
</div>
) : null}
</div>
);
}
function renderAssetPreview(asset: Asset) {
if (isAudio(asset)) {
return (
<div className="asset-placeholder asset-kind-placeholder">
<Music size={24} />
</div>
);
}
if (asset.kind === "video" || isVideo(asset)) {
return <video className="asset-thumb" src={asset.url} muted playsInline preload="metadata" />;
}
if (asset.kind === "image" || asset.kind === "mask" || asset.kind === "reference" || isImage(asset)) {
return <img className="asset-thumb" src={asset.url} alt={asset.name} />;
}
return (
<div className="asset-placeholder asset-kind-placeholder">
<ImageIcon size={24} />
</div>
);
}
function renderAssetPreviewLarge(asset: Asset) {
if (isAudio(asset)) return <audio src={asset.url} controls />;
if (asset.kind === "video" || isVideo(asset)) return <video src={asset.url} controls playsInline />;
if (asset.kind === "image" || asset.kind === "mask" || asset.kind === "reference" || isImage(asset)) {
return <img src={asset.url} alt={asset.name} />;
}
return (
<div className="asset-placeholder asset-kind-placeholder">
<ImageIcon size={28} />
</div>
);
}
function assetDownloadUrl(asset: Asset) {
return `/api/assets/${encodeURIComponent(asset.id)}/download`;
}
function isResultAsset(asset: Asset, outputAssetIds: Set<string>) {
if (!outputAssetIds.has(asset.id)) return false;
if (asset.kind === "mask" || asset.tags.includes("mask") || typeof asset.metadata.maskRule === "string") return false;
return asset.source === "generated" || asset.source === "edited" || asset.source === "upscaled";
}
function isImage(asset: Asset) {
const contentType = getContentType(asset);
return contentType.startsWith("image/");
}
function isVideo(asset: Asset) {
const contentType = getContentType(asset);
return contentType.startsWith("video/") || /\.(mp4|mov|webm)(\?|$)/.test(asset.url.toLowerCase());
}
function isAudio(asset: Asset) {
const contentType = getContentType(asset);
return contentType.startsWith("audio/") || /\.(mp3|wav|m4a|aac|flac)(\?|$)/.test(asset.url.toLowerCase());
}
function getContentType(asset: Asset) {
return typeof asset.metadata.contentType === "string" ? asset.metadata.contentType.toLowerCase() : "";
}
function kindLabel(asset: Asset) {
if (isAudio(asset)) return "音频";
if (asset.kind === "video" || isVideo(asset)) return "视频";
if (asset.kind === "mask") return "Mask";
return "图片";
}
function sourceLabel(source: Asset["source"]) {
if (source === "upload") return "上传";
if (source === "generated") return "生成";
if (source === "edited") return "重绘";
if (source === "upscaled") return "超清";
if (source === "seed") return "示例";
return "外部";
}
function statusLabel(status: GenerationJob["status"]) {
if (status === "queued") return "排队中";
if (status === "running") return "生成中";
if (status === "succeeded") return "已完成";
if (status === "failed") return "失败";
if (status === "expired") return "已过期";
if (status === "cancelled") return "已取消";
return status;
}
function providerLabel(provider: GenerationJob["provider"]) {
if (provider === "volcengine-visual") return "火山视觉";
if (provider === "evolink") return "EvoLink";
if (provider === "seedance") return "Seedance";
return "Mock";
}
function capabilityLabel(capability?: GenerationJob["capability"]) {
if (capability === "image.generate") return "图片生成";
if (capability === "image.inpaint") return "局部重绘";
if (capability === "image.upscale") return "智能超清";
if (capability === "video.generate") return "视频生成";
return "生成任务";
}
function jobPath(job: GenerationJob) {
return job.capability === "video.generate" ? `/api/generations/video/${job.id}` : `/api/generations/image/${job.id}`;
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
}
function formatDateTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
}).format(new Date(value));
}
function durationLabel(job: GenerationJob) {
const terminal = ["succeeded", "failed", "expired", "cancelled"].includes(job.status);
return `${terminal ? "耗时" : "已等待"} ${durationValue(job)}`;
}
function durationValue(job: GenerationJob) {
const start = new Date(job.createdAt).getTime();
const terminal = ["succeeded", "failed", "expired", "cancelled"].includes(job.status);
const end = terminal ? new Date(job.updatedAt).getTime() : Date.now();
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return "--";
return formatDuration(end - start);
}
function formatDuration(ms: number) {
const totalSeconds = Math.max(0, Math.round(ms / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours) return `${hours}小时${minutes}`;
if (minutes) return `${minutes}${seconds}`;
return `${seconds}`;
}