Files
NianAIGC/components/asset-manager.tsx
T
2026-09-11 15:33:18 +08:00

557 lines
23 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 { formatBillingAmount } from "@/lib/billing";
import { assetPreviewUrl } from "@/lib/client/asset-urls";
import type { Asset, GenerationJob } from "@/lib/types";
type AssetView = "assets" | "tasks";
const ASSET_PAGE_SIZE = 8;
const TASK_PAGE_SIZE = 8;
type AssetManagerProps = {
initialView?: AssetView;
initialTaskId?: string;
};
export function AssetManager({ initialView = "assets", initialTaskId }: AssetManagerProps = {}) {
const [assets, setAssets] = useState<Asset[]>([]);
const [jobs, setJobs] = useState<GenerationJob[]>([]);
const [view, setView] = useState<AssetView>(initialView === "tasks" ? "tasks" : "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>(initialTaskId || null);
const [durationNow, setDurationNow] = useState(() => Date.now());
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);
}, []);
useEffect(() => {
if (initialView === "tasks" || initialTaskId) setView("tasks");
if (initialTaskId) setExpandedJobId(initialTaskId);
}, [initialTaskId, initialView]);
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 outputAssetIds = useMemo(() => new Set(assets.map((asset) => asset.id)), [assets]);
const visibleAssets = pageItems(assets, assetPage, ASSET_PAGE_SIZE);
const visibleJobs = pageItems(jobs, jobPage, TASK_PAGE_SIZE);
const hasLiveJobs = useMemo(() => jobs.some((job) => !isTerminalStatus(job.status)), [jobs]);
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(() => {
if (!expandedJobId) return;
const index = jobs.findIndex((job) => job.id === expandedJobId);
if (index >= 0) setJobPage(Math.floor(index / TASK_PAGE_SIZE) + 1);
}, [expandedJobId, jobs]);
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 (!hasLiveJobs) return undefined;
setDurationNow(Date.now());
const timer = window.setInterval(() => {
setDurationNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, [hasLiveJobs]);
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, durationNow)} / 输入 {job.inputAssetIds.length || job.inputUrls.length} 个,输出 {resolvedOutputCount(job, outputAssetIds)} 个 / {billingLabel(job)}</p>
</div>
<div className="toolbar job-actions">
<span className={`status ${taskStatusClass(job, outputAssetIds)}`}>{statusLabel(job.status, hasResolvedOutput(job, outputAssetIds))}</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} now={durationNow} outputAssetIds={outputAssetIds} /> : 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, now, outputAssetIds }: { job: GenerationJob; now: number; outputAssetIds: Set<string> }) {
return (
<div className="job-detail-panel">
<dl className="job-detail-grid">
<div>
<dt>状态</dt>
<dd><span className={`status ${taskStatusClass(job, outputAssetIds)}`}>{statusLabel(job.status, hasResolvedOutput(job, outputAssetIds))}</span></dd>
</div>
<div>
<dt>耗时</dt>
<dd>{durationValue(job, now)}</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} / {resolvedOutputCount(job, outputAssetIds)}</dd>
</div>
<div>
<dt>扣费</dt>
<dd>{billingLabel(job)}</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={assetPreviewUrl(asset)} muted playsInline preload="metadata" />;
}
if (asset.kind === "image" || asset.kind === "mask" || asset.kind === "reference" || isImage(asset)) {
return <img className="asset-thumb" src={assetPreviewUrl(asset)} 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={assetPreviewUrl(asset)} controls />;
if (asset.kind === "video" || isVideo(asset)) return <video src={assetPreviewUrl(asset)} controls playsInline />;
if (asset.kind === "image" || asset.kind === "mask" || asset.kind === "reference" || isImage(asset)) {
return <img src={assetPreviewUrl(asset)} 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" || source === "upscaled") return "历史结果";
if (source === "seed") return "素材";
return "外部";
}
function hasResolvedOutput(job: GenerationJob, outputAssetIds: Set<string>) {
return job.outputAssetIds.some((assetId) => outputAssetIds.has(assetId));
}
function resolvedOutputCount(job: GenerationJob, outputAssetIds: Set<string>) {
return job.outputAssetIds.filter((assetId) => outputAssetIds.has(assetId)).length;
}
function taskStatusClass(job: GenerationJob, outputAssetIds: Set<string>) {
return job.status === "succeeded" && !hasResolvedOutput(job, outputAssetIds) ? "syncing" : job.status;
}
function statusLabel(status: GenerationJob["status"], hasOutput = true) {
if (status === "queued") return "排队中";
if (status === "running") return "生成中";
if (status === "succeeded") return hasOutput ? "已完成" : "结果同步中";
if (status === "failed") return "失败";
if (status === "expired") return "已过期";
if (status === "cancelled") return "已取消";
return status;
}
function isTerminalStatus(status: GenerationJob["status"]) {
return status === "succeeded" || status === "failed" || status === "expired" || status === "cancelled";
}
function providerLabel(provider: GenerationJob["provider"]) {
if (provider === "volcengine-visual") return "火山视觉";
if (provider === "evolink") return "EvoLink";
if (provider === "seedance") return "Seedance";
if (provider === "seedream") return "Seedream 5.0 Pro";
if (provider === "bailian") return "阿里云百炼";
if (provider === "minimax") return "MiniMax H3";
return "系统生成";
}
function capabilityLabel(capability?: GenerationJob["capability"]) {
if (capability === "image.generate") return "图片生成";
if (capability === "video.generate") return "视频生成";
return "生成任务";
}
function billingLabel(job: GenerationJob) {
if (!job.billing) return job.provider === "mock" ? "历史任务(未计费)" : "未计费";
if (job.billing.quotaExempt) return `超管费用 ${formatBillingAmount(job.billing.amountFen)}(不计额度)`;
if (job.billing.status === "refunded") return `已退款 ${formatBillingAmount(job.billing.amountFen)}`;
if (job.billing.status === "pending") return `预计 ${formatBillingAmount(job.billing.amountFen)}`;
if (job.billing.status === "not_charged") return "未扣费";
return `扣费 ${formatBillingAmount(job.billing.amountFen)}`;
}
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, now: number) {
const terminal = isTerminalStatus(job.status);
return `${terminal ? "耗时" : "已耗时"} ${durationValue(job, now)}`;
}
function durationValue(job: GenerationJob, now: number) {
const start = new Date(job.createdAt).getTime();
const terminal = isTerminalStatus(job.status);
const end = terminal ? new Date(job.updatedAt).getTime() : 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}秒`;
}