Initial 智念AIGC platform

This commit is contained in:
inman
2026-05-29 10:26:02 +08:00
commit f9c3393f84
86 changed files with 14741 additions and 0 deletions

View File

@@ -0,0 +1,485 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { 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>
<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>
<button className="icon-button" type="button" title="关闭预览" aria-label="关闭预览" ref={closeButtonRef} onClick={closePreview}>
<X size={18} />
</button>
</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 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}`;
}