feat: add local auth billing and usage management

This commit is contained in:
inman committed 2026-08-12 12:13:06 +08:00
1 parent f642b5e71f
commit 196fdde83f
119 files changed
+15695 -2650

No files matched your search

+500 -122
View File
@@ -1,9 +1,8 @@
"use client";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react";
import { Check, Download, ExternalLink, Film, ImageIcon, ImagePlus, ImageUp, Loader2, Music, Paintbrush, Pencil, Plus, RefreshCw, Save, Send, Upload, X } from "lucide-react";
import { Check, CircleDollarSign, Download, Film, ImageIcon, ImagePlus, Info, Loader2, Music, Pencil, Plus, RefreshCw, Save, Send, Upload, X } from "lucide-react";
import clsx from "clsx";
import { ImageEditor, type ImageEditMode } from "@/components/image-editor";
import { clampPage, pageItems, Pagination } from "@/components/pagination";
import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
import { VIDEO_DURATION_DEFAULT, VIDEO_DURATION_OPTIONS, VIDEO_RATIOS, VIDEO_RESOLUTIONS, clampVideoDuration } from "@/lib/video-settings";
@@ -17,9 +16,10 @@ import {
type MaterialDraftKind
} from "@/lib/prompt/material-draft";
import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders";
import { formatBillingAmount } from "@/lib/billing";
import type { BillingQuote } from "@/lib/types";
type GenerateMode = "image" | "video";
type StudioMode = GenerateMode | ImageEditMode;
type MaterialKind = PromptMaterial["type"];
type ImageGenerateEngine = "jimeng" | "evolink" | "bailian";
type VideoGenerateEngine = "seedance" | "bailian";
@@ -123,8 +123,8 @@ const defaultTemplateForm: TemplateForm = {
sortOrder: "0"
};
export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMode }) {
const [mode, setMode] = useState<StudioMode>(initialMode);
export function CreateStudio({ initialMode = "image" }: { initialMode?: GenerateMode }) {
const [mode, setMode] = useState<GenerateMode>(initialMode);
const [promptByMode, setPromptByMode] = useState<Record<GenerateMode, string>>({
image: "",
video: ""
@@ -143,11 +143,13 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
const [templateEditorOpen, setTemplateEditorOpen] = useState(false);
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
const [previewTemplate, setPreviewTemplate] = useState<ImageTemplate | null>(null);
const [previewTaskAsset, setPreviewTaskAsset] = useState<Asset | null>(null);
const [taskDetailJobId, setTaskDetailJobId] = useState<string | null>(null);
const [durationNow, setDurationNow] = useState(() => Date.now());
const [templateForm, setTemplateForm] = useState<TemplateForm>(defaultTemplateForm);
const [templateRestoreState, setTemplateRestoreState] = useState<TemplateConsoleSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [billingQuote, setBillingQuote] = useState<BillingQuote | null>(null);
const [billingQuoteLoading, setBillingQuoteLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
@@ -155,7 +157,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
const [imageEngine, setImageEngine] = useState<ImageGenerateEngine>("jimeng");
const [jimengInfluence, setJimengInfluence] = useState(jimengInfluenceOptions[1].id);
const [evolinkQuality, setEvolinkQuality] = useState(evolinkQualityOptions[1].id);
const [videoEngine, setVideoEngine] = useState<VideoGenerateEngine>("seedance");
const [videoEngine, setVideoEngine] = useState<VideoGenerateEngine>("bailian");
const [videoRatio, setVideoRatio] = useState("9:16");
const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT);
const [videoResolution, setVideoResolution] = useState("720p");
@@ -173,7 +175,6 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
const templatePromptRef = useRef<HTMLTextAreaElement | null>(null);
const materialDraftInputRef = useRef<HTMLInputElement | null>(null);
const isImageEditMode = mode === "inpaint" || mode === "upscale";
const generateMode: GenerateMode = mode === "video" ? "video" : "image";
const prompt = promptByMode[generateMode];
const selectedJimengInfluence = jimengInfluenceOptions.find((option) => option.id === jimengInfluence) || jimengInfluenceOptions[1];
@@ -212,6 +213,10 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
for (const asset of taskAssets) map.set(asset.id, asset);
return map;
}, [taskAssets]);
const selectedTask = useMemo(
() => recentJobs.find((job) => job.id === taskDetailJobId) || null,
[recentJobs, taskDetailJobId]
);
const hasLiveTasks = useMemo(() => recentJobs.some((job) => !isTerminalStatus(job.status)), [recentJobs]);
useEffect(() => {
@@ -288,6 +293,48 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
return () => window.clearInterval(timer);
}, [hasLiveTasks]);
useEffect(() => {
if (!selectedTask) return undefined;
function handleEscape(event: globalThis.KeyboardEvent) {
if (event.key === "Escape") setTaskDetailJobId(null);
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [selectedTask?.id]);
useEffect(() => {
let active = true;
const timer = window.setTimeout(async () => {
if (!prompt.trim() || missingMaterialPlaceholders.length) {
if (active) setBillingQuote(null);
if (active) setBillingQuoteLoading(false);
return;
}
setBillingQuoteLoading(true);
try {
const response = await fetch("/api/billing/quote", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...buildGenerationBody(), kind: generateMode })
});
const payload = await response.json().catch(() => ({}));
if (active) {
setBillingQuote(response.ok && payload.quote ? payload.quote as BillingQuote : null);
}
} catch {
if (active) {
setBillingQuote(null);
}
} finally {
if (active) setBillingQuoteLoading(false);
}
}, 320);
return () => {
active = false;
window.clearTimeout(timer);
};
}, [generateMode, imageEngine, imageSize.height, imageSize.width, selectedEvolinkQuality.quality, selectedJimengInfluence.scale, videoDuration, videoEngine, videoRatio, videoResolution, materials, missingMaterialPlaceholders.length, prompt]);
async function loadImageTemplates(isActive: () => boolean = () => true) {
setTemplatesLoading(true);
setTemplateError(null);
@@ -734,8 +781,36 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
}
}
function buildGenerationBody(): Record<string, unknown> {
if (generateMode === "image") {
return {
capability: "image.generate",
engine: imageEngine,
prompt,
materials: materials.filter((material) => material.type === "image"),
width: imageSize.width,
height: imageSize.height,
...(imageEngine === "evolink"
? { quality: selectedEvolinkQuality.quality }
: { scale: selectedJimengInfluence.scale }),
force_single: true
};
}
return {
kind: "video",
capability: "video.generate",
engine: videoEngine,
prompt,
materials,
settings: {
...(videoEngine === "seedance" ? { ratio: videoRatio } : {}),
duration: videoDuration,
resolution: videoEngine === "bailian" ? videoResolution.toUpperCase() : videoResolution
}
};
}
async function submit() {
if (isImageEditMode) return;
if (missingMaterialPlaceholders.length) {
setError(`请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}。`);
return;
@@ -751,29 +826,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
throw new Error("百炼图生视频请上传 1 张首帧图,或按顺序上传 2 张首尾帧图。");
}
}
const body = generateMode === "image"
? {
capability: "image.generate",
engine: imageEngine,
prompt,
materials: materials.filter((material) => material.type === "image"),
width: imageSize.width,
height: imageSize.height,
...(imageEngine === "evolink"
? { quality: selectedEvolinkQuality.quality }
: { scale: selectedJimengInfluence.scale }),
force_single: true
}
: {
engine: videoEngine,
prompt,
materials,
settings: {
...(videoEngine === "seedance" ? { ratio: videoRatio } : {}),
duration: videoDuration,
resolution: videoEngine === "bailian" ? videoResolution.toUpperCase() : videoResolution
}
};
const body = buildGenerationBody();
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -786,7 +839,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
setRecentJobs((items) => [submittedJob, ...items.filter((item) => item.id !== submittedJob.id)].sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
}
void loadTaskModuleData(() => true, { silent: true });
setNotice(`${generateMode === "image" ? "图片" : "视频"}生成已提交。任务已进入「结果」,生成完成后结果资产会自动保留。`);
setNotice(`${generateMode === "image" ? "图片" : "视频"}生成已提交。任务已进入任务模块,生成完成后结果可在任务详情中查看和下载。`);
setPrompt("");
setActiveTemplateId(null);
setMaterials([]);
@@ -812,45 +865,27 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
<Film size={17} />
视频
</button>
<button type="button" className={clsx(mode === "inpaint" && "active")} aria-pressed={mode === "inpaint"} onClick={() => setMode("inpaint")}>
<Paintbrush size={17} />
局部重绘
</button>
<button type="button" className={clsx(mode === "upscale" && "active")} aria-pressed={mode === "upscale"} onClick={() => setMode("upscale")}>
<ImageUp size={17} />
智能超清
</div>
<div className="toolbar create-actions">
<button
className="button primary create-submit-button"
type="button"
disabled={submitDisabled}
onClick={submit}
aria-label={generateMode === "image" ? "生成图片" : "生成视频"}
title={submitTitle}
>
{busy ? <Loader2 className="spin" size={18} /> : <Send size={18} />}
<span className="create-submit-label">{generateMode === "image" ? "生成图片" : "生成视频"}</span>
</button>
</div>
{!isImageEditMode ? (
<div className="toolbar create-actions">
<button
className="button primary create-submit-button"
type="button"
disabled={submitDisabled}
onClick={submit}
aria-label={generateMode === "image" ? "生成图片" : "生成视频"}
title={submitTitle}
>
{busy ? <Loader2 className="spin" size={18} /> : <Send size={18} />}
<span className="create-submit-label">{generateMode === "image" ? "生成图片" : "生成视频"}</span>
</button>
</div>
) : null}
</div>
);
}
return (
<div className={clsx("create-studio", isImageEditMode && "enhance-studio")} ref={studioRef}>
{isImageEditMode ? (
<div className="create-workbench-layout" ref={(node) => { modePanelRef.current = node; }} data-animate>
<section className="panel create-workbench-panel">
{renderCreateModeBar()}
<ImageEditor initialMode={mode} hideModeSwitch />
</section>
</div>
) : (
<div className="create-workbench-layout with-template-column" ref={(node) => { modePanelRef.current = node; }} data-animate>
<div className="create-studio" ref={studioRef}>
<div className="create-workbench-layout with-template-column" ref={(node) => { modePanelRef.current = node; }} data-animate>
<aside className="panel image-template-column" aria-label="模板选择">
<div className="image-template-rail-head">
<h2>模板选择</h2>
@@ -1057,7 +1092,8 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
) : null}
</div>
<div className="inline-settings">
<div className="generation-settings-row">
<div className="inline-settings">
{generateMode === "image" ? (
<>
<div className="field inline-field">
@@ -1130,7 +1166,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
</div>
</>
)}
</div>
</div>
<BillingEstimate quote={billingQuote} loading={billingQuoteLoading} />
</div>
</div>
{templateEditorOpen ? (
<div className="template-editor-backdrop" role="dialog" aria-modal="true" aria-label={editingTemplateId ? "编辑模板" : "添加模板"}>
@@ -1179,7 +1217,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
</label>
<div className="template-preview-meta" aria-label="模板参数预览">
<span>{imageEngineLabel(templateForm.engine)}</span>
<span>{templateForm.engine === "evolink" ? selectedTemplateEvolinkQuality.label : selectedTemplateJimengInfluence.label}</span>
<span>{templateForm.engine === "evolink" ? selectedTemplateEvolinkQuality.label : templateForm.engine === "bailian" ? "智能推理" : selectedTemplateJimengInfluence.label}</span>
<span>{templateForm.size}</span>
</div>
</aside>
@@ -1207,14 +1245,26 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
>
Image2
</button>
<button
type="button"
className={clsx(templateForm.engine === "bailian" && "active")}
aria-pressed={templateForm.engine === "bailian"}
onClick={() => setTemplateForm((form) => ({ ...form, engine: "bailian" }))}
>
阿里云百炼
</button>
</div>
</div>
<label className="field">
<span>{templateForm.engine === "evolink" ? "生成质量" : "文本影响"}</span>
<span>{templateForm.engine === "evolink" ? "生成质量" : templateForm.engine === "bailian" ? "生成模式" : "文本影响"}</span>
{templateForm.engine === "evolink" ? (
<select value={templateForm.evolinkQuality} onChange={(event) => setTemplateForm((form) => ({ ...form, evolinkQuality: event.target.value }))}>
{evolinkQualityOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
) : templateForm.engine === "bailian" ? (
<select value="thinking" disabled>
<option value="thinking">智能推理</option>
</select>
) : (
<select value={templateForm.jimengInfluence} onChange={(event) => setTemplateForm((form) => ({ ...form, jimengInfluence: event.target.value }))}>
{jimengInfluenceOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
@@ -1331,29 +1381,6 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
</div>
</div>
</div>
) : null}
{previewTaskAsset ? (
<div className="asset-preview-backdrop" role="presentation" onMouseDown={() => setPreviewTaskAsset(null)}>
<div className="asset-preview-dialog task-preview-dialog" role="dialog" aria-modal="true" aria-label={`预览 ${previewTaskAsset.name}`} onMouseDown={(event) => event.stopPropagation()}>
<div className="asset-preview-head">
<div>
<strong>{previewTaskAsset.name}</strong>
<span className="muted compact-hint">任务生成资产</span>
</div>
<div className="asset-preview-actions">
<a className="icon-button" href={assetDownloadUrl(previewTaskAsset)} title="下载" aria-label={`下载 ${previewTaskAsset.name}`}>
<Download size={18} />
</a>
<button className="icon-button" type="button" title="关闭预览" aria-label="关闭预览" onClick={() => setPreviewTaskAsset(null)}>
<X size={18} />
</button>
</div>
</div>
<div className="asset-preview-media">
{renderTaskAssetPreviewLarge(previewTaskAsset)}
</div>
</div>
</div>
) : null}
</div>
</section>
@@ -1377,19 +1404,56 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
{tasksError ? <div className="callout compact-callout" role="alert">{tasksError}</div> : null}
<div className="create-task-list">
{recentJobs.map((job) => (
<article className="create-task-card" key={job.id}>
{renderTaskThumbnail(job, taskAssetById, setPreviewTaskAsset)}
<article
className="create-task-card"
key={job.id}
role="button"
tabIndex={0}
aria-label={`查看任务详情:${taskName(job)}`}
onClick={(event) => {
if ((event.target as HTMLElement).closest("a,button")) return;
setTaskDetailJobId(job.id);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setTaskDetailJobId(job.id);
}
}}
>
{renderTaskThumbnail(job, taskAssetById)}
<div className="create-task-body">
<h3 title={taskName(job)}>{taskName(job)}</h3>
<div className="create-task-meta">
<span className={`status ${job.status}`}>{statusLabel(job.status)}</span>
<span>{durationLabel(job, durationNow)}</span>
<span className="billing-task-cost">{billingLabel(job)}</span>
</div>
</div>
<a className="button mini-button create-task-link" href={taskDetailHref(job)}>
<ExternalLink size={14} />
查看详情
</a>
<div className="create-task-actions">
{firstOutputAsset(job, taskAssetById) ? (
<a
className="icon-button"
href={assetDownloadUrl(firstOutputAsset(job, taskAssetById)!)}
title="下载结果"
aria-label={`下载 ${taskName(job)} 的结果`}
onClick={(event) => event.stopPropagation()}
>
<Download size={16} />
</a>
) : null}
<button
className="button mini-button create-task-link"
type="button"
onClick={(event) => {
event.stopPropagation();
setTaskDetailJobId(job.id);
}}
>
<Info size={14} />
详情
</button>
</div>
</article>
))}
{!recentJobs.length && !tasksLoading && !tasksError ? (
@@ -1397,13 +1461,20 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
) : null}
</div>
</aside>
</div>
)}
{selectedTask ? (
<TaskDetailModal
job={selectedTask}
assets={taskAssetById}
now={durationNow}
onClose={() => setTaskDetailJobId(null)}
/>
) : null}
</div>
</div>
);
}
function renderTaskThumbnail(job: GenerationJob, assetById: Map<string, Asset>, onPreview: (asset: Asset) => void) {
function renderTaskThumbnail(job: GenerationJob, assetById: Map<string, Asset>) {
const asset = job.outputAssetIds.map((assetId) => assetById.get(assetId)).find((item): item is Asset => Boolean(item));
if (!asset) {
return (
@@ -1414,31 +1485,317 @@ function renderTaskThumbnail(job: GenerationJob, assetById: Map<string, Asset>,
);
}
const isVideo = asset.kind === "video" || materialTypeForAsset(asset) === "video";
const isPreviewable = !isVideo;
const media = isVideo
? <video src={asset.url} muted playsInline preload="metadata" />
: <img src={asset.url} alt="" />;
if (isPreviewable) {
return (
<button className="create-task-thumb create-task-thumb-button" type="button" onClick={() => onPreview(asset)} aria-label={`查看大图 ${asset.name}`}>
{media}
</button>
);
}
return <div className="create-task-thumb" aria-hidden="true">{media}</div>;
}
function renderTaskAssetPreviewLarge(asset: Asset) {
if (asset.kind === "video" || materialTypeForAsset(asset) === "video") {
return <video src={asset.url} controls playsInline />;
}
return <img src={asset.url} alt={asset.name} />;
}
function assetDownloadUrl(asset: Asset) {
return `/api/assets/${encodeURIComponent(asset.id)}/download`;
}
type TaskMaterialDetail = {
id?: string;
url?: string;
label?: string;
name?: string;
type: MaterialKind;
asset?: Asset;
};
function TaskDetailModal({
job,
assets,
now,
onClose
}: {
job: GenerationJob;
assets: Map<string, Asset>;
now: number;
onClose: () => void;
}) {
const outputAssets = job.outputAssetIds
.map((assetId) => assets.get(assetId))
.filter((asset): asset is Asset => Boolean(asset));
const inputMaterials = taskMaterialDetails(job, assets);
const parameterEntries = taskParameterEntries(job);
const inputCount = job.inputAssetIds.length || job.inputUrls.length;
return (
<div className="task-detail-backdrop" role="presentation" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}>
<div className="task-detail-dialog" role="dialog" aria-modal="true" aria-labelledby="task-detail-title" onMouseDown={(event) => event.stopPropagation()}>
<header className="task-detail-head">
<div className="task-detail-title-wrap">
<span className="task-detail-kicker">任务详情</span>
<h2 id="task-detail-title" title={taskName(job)}>{taskName(job)}</h2>
<div className="task-detail-subline">
<span className={`status ${job.status}`}>{statusLabel(job.status)}</span>
<span>{capabilityLabel(job.capability)}</span>
<span>{durationLabel(job, now)}</span>
</div>
</div>
<button className="icon-button" type="button" title="关闭详情" aria-label="关闭详情" onClick={onClose}>
<X size={18} />
</button>
</header>
<div className="task-detail-scroll">
<section className="task-detail-section task-detail-output-section">
<div className="task-detail-section-head">
<div>
<h3>生成结果</h3>
<span>{outputAssets.length ? `${outputAssets.length} 个结果` : isPendingTask(job) ? "结果生成中" : "暂无结果"}</span>
</div>
</div>
{outputAssets.length ? (
<div className="task-detail-output-grid">
{outputAssets.map((asset) => (
<article className="task-detail-output-card" key={asset.id}>
<div className="task-detail-output-media">{renderTaskAssetMedia(asset)}</div>
<div className="task-detail-output-footer">
<span title={asset.name}>{asset.name}</span>
<a className="button mini-button" href={assetDownloadUrl(asset)} title={`下载 ${asset.name}`}>
<Download size={14} />
下载
</a>
</div>
</article>
))}
</div>
) : (
<div className={clsx("task-detail-empty", isPendingTask(job) && "pending")}>
{isPendingTask(job) ? <Loader2 className="spin" size={18} /> : <ImageIcon size={18} />}
<span>{isPendingTask(job) ? "任务完成后,结果会显示在这里" : job.error?.message || "该任务没有可下载的结果"}</span>
</div>
)}
</section>
<section className="task-detail-section">
<div className="task-detail-section-head">
<div>
<h3>提示词</h3>
<span>本次任务实际提交的内容</span>
</div>
</div>
<p className="task-detail-prompt">{job.prompt?.trim() || "未记录提示词"}</p>
</section>
<section className="task-detail-section">
<div className="task-detail-section-head">
<div>
<h3>输入要素</h3>
<span>{inputMaterials.length ? `${inputMaterials.length} 个要素` : inputCount ? `${inputCount} 个引用` : "未使用上传要素"}</span>
</div>
</div>
{inputMaterials.length ? (
<div className="task-detail-material-grid">
{inputMaterials.map((material, index) => (
<article className="task-detail-material-card" key={`${material.id || material.url || "material"}-${index}`}>
<div className="task-detail-material-media">
{material.asset ? renderTaskAssetMedia(material.asset, "thumb") : material.url ? renderMaterialUrlPreview(material.url, material.type) : <ImageIcon size={18} />}
</div>
<div className="task-detail-material-copy">
<strong>{material.label || `${shortTypeName(material.type)}${index + 1}`}</strong>
<span title={material.name || material.url}>{material.name || material.url || "未命名要素"}</span>
</div>
</article>
))}
</div>
) : (
<div className="task-detail-empty">该任务未绑定图片、视频或音频要素。</div>
)}
</section>
<div className="task-detail-columns">
<section className="task-detail-section">
<div className="task-detail-section-head">
<div>
<h3>生成参数</h3>
<span>提交时使用的参数</span>
</div>
</div>
{parameterEntries.length ? (
<dl className="task-detail-value-grid">
{parameterEntries.map((entry) => (
<div key={entry.label}>
<dt>{entry.label}</dt>
<dd>{entry.value}</dd>
</div>
))}
</dl>
) : <div className="task-detail-empty">暂无额外参数。</div>}
</section>
<section className="task-detail-section">
<div className="task-detail-section-head">
<div>
<h3>任务信息</h3>
<span>状态与处理记录</span>
</div>
</div>
<dl className="task-detail-value-grid">
<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>{inputCount} / {job.outputAssetIds.length}</dd></div>
<div><dt>提交时间</dt><dd>{formatDateTime(job.createdAt)}</dd></div>
<div><dt>更新时间</dt><dd>{formatDateTime(job.updatedAt)}</dd></div>
<div><dt>计费</dt><dd>{billingLabel(job)}</dd></div>
</dl>
</section>
</div>
{job.error ? (
<section className="task-detail-error" role="alert">
<strong>任务信息</strong>
<span>{job.error.message}</span>
</section>
) : null}
</div>
</div>
</div>
);
}
function firstOutputAsset(job: GenerationJob, assets: Map<string, Asset>) {
return job.outputAssetIds.map((assetId) => assets.get(assetId)).find((asset): asset is Asset => Boolean(asset));
}
function BillingEstimate({
quote,
loading
}: {
quote: BillingQuote | null;
loading: boolean;
}) {
return (
<aside className={clsx("billing-estimate", loading && "is-loading")} aria-live="polite" aria-label={quote?.quotaExempt ? "本次预计费用(超级管理员不计额度)" : "本次预计消耗额度"}>
<div className="billing-estimate-head">
<span className="billing-estimate-icon" aria-hidden="true"><CircleDollarSign size={16} /></span>
<span>{quote?.quotaExempt ? "本次预计费用(不计额度)" : "本次预计消耗额度"}</span>
</div>
<strong className="billing-estimate-amount">
{loading ? "…" : quote ? formatBillingAmount(quote.amountFen) : "—"}
</strong>
</aside>
);
}
function renderTaskAssetMedia(asset: Asset, size: "thumb" | "large" = "large") {
const type = materialTypeForAsset(asset);
if (type === "video") return <video className={`task-detail-media ${size}`} src={asset.url} controls={size === "large"} muted={size === "thumb"} playsInline preload="metadata" />;
if (type === "audio") return <audio className="task-detail-audio" src={asset.url} controls />;
return <img className={`task-detail-media ${size}`} src={asset.url} alt={asset.name} />;
}
function renderMaterialUrlPreview(url: string, type: MaterialKind) {
if (type === "video") return <video className="task-detail-media thumb" src={url} muted playsInline preload="metadata" />;
if (type === "audio") return <Music size={18} />;
return <img className="task-detail-media thumb" src={url} alt="" />;
}
function taskMaterialDetails(job: GenerationJob, assets: Map<string, Asset>): TaskMaterialDetail[] {
const input = asRecord(job.requestPayload.input);
const assembled = asRecord(job.requestPayload.assembled);
const rawMaterials = Array.isArray(assembled.materials)
? assembled.materials
: Array.isArray(input.materials)
? input.materials
: [];
if (rawMaterials.length) {
return rawMaterials.map((value, index) => {
const material = asRecord(value);
const id = stringValue(material.id);
const asset = id ? assets.get(id) : undefined;
const type = materialKindValue(material.type) || (asset ? materialTypeForAsset(asset) : "image");
return {
id,
url: stringValue(material.url) || asset?.url,
label: stringValue(material.label) || `@${shortTypeName(type)}${index + 1}`,
name: stringValue(material.name) || asset?.name,
type,
asset
};
}).filter((material) => material.url || material.asset);
}
return job.inputAssetIds.map((id, index) => {
const asset = assets.get(id);
return {
id,
url: asset?.url,
label: `@${shortTypeName(asset ? materialTypeForAsset(asset) : "image")}${index + 1}`,
name: asset?.name,
type: asset ? materialTypeForAsset(asset) : "image",
asset
};
}).filter((material) => material.asset || material.url);
}
function taskParameterEntries(job: GenerationJob) {
const input = asRecord(job.requestPayload.input);
const inputSettings = asRecord(input.settings);
const requestSettings = asRecord(job.requestPayload.settings);
const values = { ...input, ...inputSettings, ...requestSettings };
const entries: Array<{ label: string; value: string }> = [];
const engine = stringValue(values.engine);
if (engine) entries.push({ label: "引擎", value: taskEngineLabel(engine) });
if (job.capability === "image.generate") {
const width = scalarValue(values.width);
const height = scalarValue(values.height);
if (width && height) entries.push({ label: "画幅", value: `${width} × ${height}` });
const scale = scalarValue(values.scale);
if (scale) entries.push({ label: "文本影响", value: scale });
const quality = stringValue(values.quality);
if (quality) entries.push({ label: "生成质量", value: qualityLabel(quality) });
if (values.force_single !== undefined) entries.push({ label: "输出模式", value: values.force_single === true ? "单图" : "多图" });
} else {
const ratio = stringValue(values.ratio);
if (ratio) entries.push({ label: "比例", value: ratio === "adaptive" ? "自适应" : ratio });
const duration = scalarValue(values.duration);
if (duration) entries.push({ label: "时长", value: `${duration} 秒` });
const resolution = stringValue(values.resolution);
if (resolution) entries.push({ label: "清晰度", value: resolution });
}
return entries;
}
function taskEngineLabel(value: string) {
if (value === "jimeng") return "即梦";
if (value === "evolink") return "Image2";
if (value === "bailian") return "阿里云百炼";
if (value === "seedance") return "Seedance";
if (value === "mock") return "系统生成";
return value;
}
function qualityLabel(value: string) {
if (value === "low") return "快速";
if (value === "medium") return "标准";
if (value === "high") return "精细";
return value;
}
function materialKindValue(value: unknown): MaterialKind | undefined {
return value === "image" || value === "video" || value === "audio" ? value : undefined;
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function stringValue(value: unknown): string | undefined {
if (typeof value === "string" && value.trim()) return value.trim();
if (typeof value === "number" || typeof value === "boolean") return String(value);
return undefined;
}
function scalarValue(value: unknown): string | undefined {
return typeof value === "number" || typeof value === "string" ? String(value) : undefined;
}
function isPendingTask(job: GenerationJob) {
return job.status === "queued" || job.status === "running";
}
@@ -1448,10 +1805,6 @@ function taskName(job: GenerationJob) {
return text ? text : capabilityLabel(job.capability);
}
function taskDetailHref(job: GenerationJob) {
return `/assets?view=tasks&taskId=${encodeURIComponent(job.id)}`;
}
function statusLabel(status: GenerationJob["status"]) {
if (status === "queued") return "排队中";
if (status === "running") return "生成中";
@@ -1468,12 +1821,37 @@ function isTerminalStatus(status: GenerationJob["status"]) {
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 providerLabel(provider: GenerationJob["provider"]) {
if (provider === "volcengine-visual") return "火山视觉";
if (provider === "evolink") return "EvoLink";
if (provider === "seedance") return "Seedance";
if (provider === "bailian") return "阿里云百炼";
return "系统生成";
}
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 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 durationLabel(job: GenerationJob, now: number) {
const terminal = isTerminalStatus(job.status);
return `${terminal ? "耗时" : "已耗时"} ${durationValue(job, now)}`;