498 lines
20 KiB
TypeScript
498 lines
20 KiB
TypeScript
"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}秒`;
|
||
}
|