feat: add local auth billing and usage management
This commit is contained in:
1 parent
f642b5e71f
commit
196fdde83f
119 files changed
+15695
-2650
No files matched your search
+500
-122
@@ -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)}`;
|
||||
|
||||
Reference in new issue
Block a user