2176 lines
94 KiB
TypeScript
2176 lines
94 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "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 { 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";
|
||
import type { Asset, GenerationJob } from "@/lib/types";
|
||
import type { PromptMaterial } from "@/lib/prompt/assembler";
|
||
import {
|
||
detectMaterialDraftStart,
|
||
insertMaterialDraftToken,
|
||
nextMaterialDraftToken,
|
||
normalizeMaterialDraftToken,
|
||
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 MaterialKind = PromptMaterial["type"];
|
||
type ImageGenerateEngine = "jimeng" | "evolink" | "bailian";
|
||
type VideoGenerateEngine = "seedance" | "bailian";
|
||
|
||
type MentionState = {
|
||
start: number;
|
||
query: string;
|
||
};
|
||
|
||
type MaterialDraftTarget = "prompt" | "template";
|
||
|
||
type MaterialDraftState = {
|
||
target: MaterialDraftTarget;
|
||
index: number;
|
||
query: string;
|
||
activeIndex: number | null;
|
||
};
|
||
|
||
type MaterialDraftOption = {
|
||
token: string;
|
||
label: string;
|
||
caption: string;
|
||
type: MaterialKind;
|
||
};
|
||
|
||
type HealthCapability = {
|
||
id: string;
|
||
engine?: string;
|
||
};
|
||
|
||
type ImageTemplate = {
|
||
id: string;
|
||
name: string;
|
||
prompt: string;
|
||
previewImageUrl?: string;
|
||
settings: {
|
||
engine?: ImageGenerateEngine;
|
||
width?: number;
|
||
height?: number;
|
||
forceSingle?: boolean;
|
||
scale?: number;
|
||
quality?: string;
|
||
};
|
||
sortOrder: number;
|
||
updatedAt?: string;
|
||
};
|
||
|
||
type TemplateForm = {
|
||
name: string;
|
||
prompt: string;
|
||
previewImageUrl: string;
|
||
engine: ImageGenerateEngine;
|
||
jimengInfluence: string;
|
||
evolinkQuality: string;
|
||
size: string;
|
||
sortOrder: string;
|
||
};
|
||
|
||
const jimengInfluenceOptions = [
|
||
{ id: "creative", label: "创意 35", scale: 35 },
|
||
{ id: "balanced", label: "均衡 50", scale: 50 },
|
||
{ id: "precise", label: "贴合 70", scale: 70 },
|
||
{ id: "strict", label: "严格 85", scale: 85 }
|
||
];
|
||
|
||
const evolinkQualityOptions = [
|
||
{ id: "low", label: "快速", quality: "low" },
|
||
{ id: "medium", label: "标准", quality: "medium" },
|
||
{ id: "high", label: "精细", quality: "high" }
|
||
];
|
||
|
||
const imageSizePresets = [
|
||
{ label: "1:1", width: 2048, height: 2048 },
|
||
{ label: "4:3", width: 2304, height: 1728 },
|
||
{ label: "16:9", width: 2560, height: 1440 },
|
||
{ label: "9:16", width: 1440, height: 2560 }
|
||
];
|
||
|
||
type ImageSizePreset = (typeof imageSizePresets)[number];
|
||
|
||
type TemplateConsoleSnapshot = {
|
||
mode: GenerateMode;
|
||
imagePrompt: string;
|
||
imageSize: ImageSizePreset;
|
||
imageEngine: ImageGenerateEngine;
|
||
jimengInfluence: string;
|
||
evolinkQuality: string;
|
||
};
|
||
|
||
const materialUploadAccept = "image/*,video/*,audio/*";
|
||
const templatePreviewAccept = "image/*";
|
||
const MATERIAL_PAGE_SIZE = 6;
|
||
const defaultTemplateForm: TemplateForm = {
|
||
name: "",
|
||
prompt: "",
|
||
previewImageUrl: "",
|
||
engine: "jimeng",
|
||
jimengInfluence: jimengInfluenceOptions[1].id,
|
||
evolinkQuality: evolinkQualityOptions[1].id,
|
||
size: imageSizePresets[0].label,
|
||
sortOrder: "0"
|
||
};
|
||
|
||
export function CreateStudio({ initialMode = "image" }: { initialMode?: GenerateMode }) {
|
||
const [mode, setMode] = useState<GenerateMode>(initialMode);
|
||
const [promptByMode, setPromptByMode] = useState<Record<GenerateMode, string>>({
|
||
image: "",
|
||
video: ""
|
||
});
|
||
const [materials, setMaterials] = useState<PromptMaterial[]>([]);
|
||
const [imageTemplates, setImageTemplates] = useState<ImageTemplate[]>([]);
|
||
const [taskAssets, setTaskAssets] = useState<Asset[]>([]);
|
||
const [recentJobs, setRecentJobs] = useState<GenerationJob[]>([]);
|
||
const [templatesLoading, setTemplatesLoading] = useState(false);
|
||
const [tasksLoading, setTasksLoading] = useState(false);
|
||
const [templateSaving, setTemplateSaving] = useState(false);
|
||
const [templatePreviewUploading, setTemplatePreviewUploading] = useState(false);
|
||
const [templateError, setTemplateError] = useState<string | null>(null);
|
||
const [tasksError, setTasksError] = useState<string | null>(null);
|
||
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(null);
|
||
const [templateEditorOpen, setTemplateEditorOpen] = useState(false);
|
||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
||
const [previewTemplate, setPreviewTemplate] = useState<ImageTemplate | 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 [billingQuoteError, setBillingQuoteError] = useState<string | null>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [notice, setNotice] = useState<string | null>(null);
|
||
const [imageSize, setImageSize] = useState(imageSizePresets[0]);
|
||
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>("bailian");
|
||
const [videoRatio, setVideoRatio] = useState("9:16");
|
||
const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT);
|
||
const [videoResolution, setVideoResolution] = useState("720p");
|
||
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
||
const [materialDraft, setMaterialDraft] = useState<MaterialDraftState | null>(null);
|
||
const [activeMentionIndex, setActiveMentionIndex] = useState<number | null>(null);
|
||
const [promptScrollTop, setPromptScrollTop] = useState(0);
|
||
const [templatePromptScrollTop, setTemplatePromptScrollTop] = useState(0);
|
||
const [materialPage, setMaterialPage] = useState(1);
|
||
const studioRef = useRef<HTMLDivElement | null>(null);
|
||
const modePanelRef = useRef<HTMLDivElement | HTMLElement | null>(null);
|
||
const feedbackRef = useRef<HTMLDivElement | null>(null);
|
||
const materialBoardRef = useRef<HTMLDivElement | null>(null);
|
||
const promptRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const templatePromptRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const materialDraftInputRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
const generateMode: GenerateMode = mode === "video" ? "video" : "image";
|
||
const prompt = promptByMode[generateMode];
|
||
const selectedJimengInfluence = jimengInfluenceOptions.find((option) => option.id === jimengInfluence) || jimengInfluenceOptions[1];
|
||
const selectedEvolinkQuality = evolinkQualityOptions.find((option) => option.id === evolinkQuality) || evolinkQualityOptions[1];
|
||
const selectedTemplateJimengInfluence = jimengInfluenceOptions.find((option) => option.id === templateForm.jimengInfluence) || jimengInfluenceOptions[1];
|
||
const selectedTemplateEvolinkQuality = evolinkQualityOptions.find((option) => option.id === templateForm.evolinkQuality) || evolinkQualityOptions[1];
|
||
const templateFormReady = Boolean(templateForm.name.trim() && templateForm.prompt.trim() && templateForm.previewImageUrl.trim());
|
||
const templateFormPlaceholders = useMemo(() => extractMaterialPlaceholders(templateForm.prompt), [templateForm.prompt]);
|
||
const materialPlaceholders = useMemo(() => extractMaterialPlaceholders(prompt), [prompt]);
|
||
const referencedMaterialLabels = useMemo(() => new Set(materialPlaceholders.map((placeholder) => placeholder.token)), [materialPlaceholders]);
|
||
const missingMaterialPlaceholders = useMemo(() => {
|
||
const existing = new Set(materials.map((material) => normalizeMaterialLabel(material.label)).filter((label): label is string => Boolean(label)));
|
||
return materialPlaceholders.filter((placeholder) => !existing.has(placeholder.token));
|
||
}, [materialPlaceholders, materials]);
|
||
const submitDisabled = busy || !prompt.trim() || missingMaterialPlaceholders.length > 0;
|
||
const submitTitle = missingMaterialPlaceholders.length
|
||
? `请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}`
|
||
: generateMode === "image" ? "生成图片" : "生成视频";
|
||
const visibleMaterials = pageItems(materials, materialPage, MATERIAL_PAGE_SIZE);
|
||
const materialPageOffset = (clampPage(materialPage, materials.length, MATERIAL_PAGE_SIZE) - 1) * MATERIAL_PAGE_SIZE;
|
||
const mentionSuggestions = useMemo(() => {
|
||
if (!mentionState) return [];
|
||
return materials.filter((material) => materialMatchesMention(material, mentionState.query)).slice(0, 8);
|
||
}, [materials, mentionState]);
|
||
const materialDraftOptions = useMemo(() => {
|
||
if (!materialDraft) return [];
|
||
return buildMaterialDraftOptions({
|
||
target: materialDraft.target,
|
||
query: materialDraft.query,
|
||
prompt: materialDraft.target === "template" ? templateForm.prompt : prompt,
|
||
materials
|
||
});
|
||
}, [materialDraft, materials, prompt, templateForm.prompt]);
|
||
const taskAssetById = useMemo(() => {
|
||
const map = new Map<string, Asset>();
|
||
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(() => {
|
||
setActiveMentionIndex(null);
|
||
}, [mentionState?.query, mentionSuggestions.length]);
|
||
|
||
useEffect(() => {
|
||
if (!materialDraft) return;
|
||
window.requestAnimationFrame(() => materialDraftInputRef.current?.focus());
|
||
}, [materialDraft?.target, materialDraft?.index]);
|
||
|
||
useEffect(() => {
|
||
setMaterialPage((page) => clampPage(page, materials.length, MATERIAL_PAGE_SIZE));
|
||
}, [materials.length]);
|
||
|
||
useEffect(() => {
|
||
return runScopedMotion(studioRef, (scope) => revealChildren(scope));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
crossfadeIn(modePanelRef.current);
|
||
}, [mode]);
|
||
|
||
useEffect(() => {
|
||
pulseFeedback(feedbackRef.current);
|
||
}, [error, notice]);
|
||
|
||
useEffect(() => {
|
||
if (materialBoardRef.current) revealChildren(materialBoardRef.current, ".material-card");
|
||
}, [materials.length, materialPage]);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
void fetch("/api/health")
|
||
.then((response) => response.ok ? response.json() : null)
|
||
.then((payload: { capabilities?: HealthCapability[] } | null) => {
|
||
const engine = payload?.capabilities?.find((capability) => capability.id === "image.generate")?.engine;
|
||
if (active && (engine === "evolink" || engine === "jimeng" || engine === "bailian")) setImageEngine(engine);
|
||
})
|
||
.catch(() => undefined);
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
void loadImageTemplates(() => active);
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
void loadTaskModuleData(() => active);
|
||
const timer = window.setInterval(() => {
|
||
if (document.visibilityState === "visible") {
|
||
void loadTaskModuleData(() => active, { silent: true });
|
||
}
|
||
}, 15000);
|
||
return () => {
|
||
active = false;
|
||
window.clearInterval(timer);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!hasLiveTasks) return undefined;
|
||
setDurationNow(Date.now());
|
||
const timer = window.setInterval(() => {
|
||
setDurationNow(Date.now());
|
||
}, 1000);
|
||
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) setBillingQuoteError(null);
|
||
if (active) setBillingQuoteLoading(false);
|
||
return;
|
||
}
|
||
setBillingQuoteLoading(true);
|
||
setBillingQuoteError(null);
|
||
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) {
|
||
if (response.ok && payload.quote) {
|
||
setBillingQuote(payload.quote as BillingQuote);
|
||
setBillingQuoteError(null);
|
||
} else {
|
||
setBillingQuote(null);
|
||
setBillingQuoteError(typeof payload.error === "string" ? payload.error : "计费估算暂不可用,请检查服务配置");
|
||
}
|
||
}
|
||
} catch {
|
||
if (active) {
|
||
setBillingQuote(null);
|
||
setBillingQuoteError("计费估算暂不可用,请检查后端服务配置");
|
||
}
|
||
} 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);
|
||
try {
|
||
const response = await fetch("/api/image-templates", { cache: "no-store" });
|
||
const payload = await response.json();
|
||
if (!response.ok) throw new Error(payload.error || "读取模板失败");
|
||
const nextTemplates = ((payload.templates || []) as ImageTemplate[]).sort(sortTemplates);
|
||
if (isActive()) {
|
||
setImageTemplates(nextTemplates);
|
||
}
|
||
} catch (err) {
|
||
if (isActive()) setTemplateError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (isActive()) setTemplatesLoading(false);
|
||
}
|
||
}
|
||
|
||
async function loadTaskModuleData(isActive: () => boolean = () => true, options: { silent?: boolean } = {}) {
|
||
if (!options.silent) setTasksLoading(true);
|
||
setTasksError(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));
|
||
if (isActive()) {
|
||
setTaskAssets((assetPayload.assets || []) as Asset[]);
|
||
setRecentJobs(nextJobs);
|
||
}
|
||
} catch (err) {
|
||
if (isActive()) setTasksError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (isActive() && !options.silent) setTasksLoading(false);
|
||
}
|
||
}
|
||
|
||
function setPrompt(value: string) {
|
||
setPromptByMode((items) => ({ ...items, [generateMode]: value }));
|
||
}
|
||
|
||
function handlePromptInput(value: string, cursor: number) {
|
||
const draft = detectMaterialDraftStart(prompt, value, cursor);
|
||
if (draft) {
|
||
setPrompt(draft.text);
|
||
startMaterialDraft("prompt", draft.index);
|
||
return;
|
||
}
|
||
setPrompt(value);
|
||
updateMention(value, cursor);
|
||
}
|
||
|
||
function updateMention(value: string, cursor: number) {
|
||
setMentionState(findMentionAtCursor(value, cursor));
|
||
}
|
||
|
||
async function uploadAssetFiles(files: File[]): Promise<Asset[]> {
|
||
const formData = new FormData();
|
||
for (const file of files) formData.append("files", file);
|
||
const response = await fetch("/api/assets/upload", {
|
||
method: "POST",
|
||
body: formData
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok) throw new Error(payload.error || "上传失败");
|
||
return payload.assets as Asset[];
|
||
}
|
||
|
||
async function uploadFiles(files: FileList | null, options: { targetLabel?: string; expectedType?: MaterialKind } = {}) {
|
||
const selectedFiles = Array.from(files || []);
|
||
const expectedType = options.expectedType;
|
||
if (!selectedFiles.length) return;
|
||
if (expectedType && selectedFiles.some((file) => !fileMatchesMaterialKind(file, expectedType))) {
|
||
setError(`请上传${shortTypeName(expectedType)}素材。`);
|
||
return;
|
||
}
|
||
setUploading(true);
|
||
setError(null);
|
||
setNotice(null);
|
||
try {
|
||
const uploaded = await uploadAssetFiles(selectedFiles);
|
||
if (expectedType && uploaded.some((asset) => materialTypeForAsset(asset) !== expectedType)) {
|
||
throw new Error(`请上传${shortTypeName(expectedType)}素材。`);
|
||
}
|
||
setMaterials((items) => {
|
||
const next = [...items];
|
||
uploaded.forEach((asset, index) => {
|
||
const targetLabel = index === 0 ? normalizeMaterialLabel(options.targetLabel) : undefined;
|
||
const material = materialFromAsset(asset, next, targetLabel);
|
||
const existingIndex = targetLabel ? next.findIndex((item) => normalizeMaterialLabel(item.label) === targetLabel) : -1;
|
||
if (existingIndex >= 0) {
|
||
next[existingIndex] = material;
|
||
return;
|
||
}
|
||
next.push(material);
|
||
});
|
||
return next;
|
||
});
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
}
|
||
|
||
async function uploadTemplatePreview(files: FileList | null) {
|
||
const file = files?.[0];
|
||
if (!file) return;
|
||
if (!fileMatchesMaterialKind(file, "image")) {
|
||
setTemplateError("模板预览图只能上传图片。");
|
||
return;
|
||
}
|
||
setTemplatePreviewUploading(true);
|
||
setTemplateError(null);
|
||
try {
|
||
const [asset] = await uploadAssetFiles([file]);
|
||
setTemplateForm((form) => ({
|
||
...form,
|
||
previewImageUrl: asset.url,
|
||
name: form.name || asset.name.replace(/\.[^.]+$/, "")
|
||
}));
|
||
} catch (err) {
|
||
setTemplateError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setTemplatePreviewUploading(false);
|
||
}
|
||
}
|
||
|
||
function materialFromAsset(asset: Asset, existing: PromptMaterial[], preferredLabel?: string): PromptMaterial {
|
||
const type = materialTypeForAsset(asset);
|
||
const normalizedPreferredLabel = normalizeMaterialLabel(preferredLabel);
|
||
return {
|
||
id: asset.id,
|
||
url: asset.url,
|
||
type,
|
||
role: "upload",
|
||
label: normalizedPreferredLabel && materialTypeForLabel(normalizedPreferredLabel) === type
|
||
? normalizedPreferredLabel
|
||
: nextMaterialLabel(type, existing),
|
||
name: asset.name
|
||
};
|
||
}
|
||
|
||
function removeMaterial(index: number) {
|
||
setMaterials((items) => items.filter((_, itemIndex) => itemIndex !== index));
|
||
}
|
||
|
||
function insertToken(token?: string) {
|
||
if (!token) return;
|
||
insertAtCursor(token);
|
||
}
|
||
|
||
function insertAtCursor(text: string) {
|
||
const textarea = promptRef.current;
|
||
if (!textarea) {
|
||
const joiner = prompt.endsWith("\n") || !prompt.trim() ? "" : "\n";
|
||
setPrompt(`${prompt}${joiner}${text}`);
|
||
return;
|
||
}
|
||
const start = textarea.selectionStart ?? prompt.length;
|
||
const end = textarea.selectionEnd ?? prompt.length;
|
||
const before = prompt.slice(0, start);
|
||
const after = prompt.slice(end);
|
||
const needsLeadingSpace = before.length > 0 && !/[\s,。;:、((【\[]$/.test(before);
|
||
const needsTrailingSpace = after.length > 0 && !/^[\s,。;:、))】\]]/.test(after);
|
||
const insertion = `${needsLeadingSpace ? " " : ""}${text}${needsTrailingSpace ? " " : ""}`;
|
||
const nextPrompt = `${before}${insertion}${after}`;
|
||
const nextCursor = before.length + insertion.length;
|
||
setPrompt(nextPrompt);
|
||
window.requestAnimationFrame(() => {
|
||
textarea.focus();
|
||
textarea.setSelectionRange(nextCursor, nextCursor);
|
||
});
|
||
}
|
||
|
||
function applyImageTemplate(template: ImageTemplate) {
|
||
if (activeTemplateId === template.id) {
|
||
clearImageTemplateSelection();
|
||
return;
|
||
}
|
||
if (!templateRestoreState) {
|
||
setTemplateRestoreState({
|
||
mode: generateMode,
|
||
imagePrompt: promptByMode.image,
|
||
imageSize,
|
||
imageEngine,
|
||
jimengInfluence,
|
||
evolinkQuality
|
||
});
|
||
}
|
||
applyTemplateToConsole(template, `已应用模板:${template.name}`);
|
||
}
|
||
|
||
function clearImageTemplateSelection() {
|
||
const snapshot = templateRestoreState;
|
||
setActiveTemplateId(null);
|
||
setTemplateRestoreState(null);
|
||
setMaterialDraft(null);
|
||
setMentionState(null);
|
||
setPromptScrollTop(0);
|
||
setError(null);
|
||
if (snapshot) {
|
||
setMode(snapshot.mode);
|
||
setPromptByMode((items) => ({ ...items, image: snapshot.imagePrompt }));
|
||
setImageSize(snapshot.imageSize);
|
||
setImageEngine(snapshot.imageEngine);
|
||
setJimengInfluence(snapshot.jimengInfluence);
|
||
setEvolinkQuality(snapshot.evolinkQuality);
|
||
} else {
|
||
setPromptByMode((items) => ({ ...items, image: "" }));
|
||
setImageSize(imageSizePresets[0]);
|
||
setImageEngine("jimeng");
|
||
setJimengInfluence(jimengInfluenceOptions[1].id);
|
||
setEvolinkQuality(evolinkQualityOptions[1].id);
|
||
}
|
||
setNotice("已取消模板选择");
|
||
}
|
||
|
||
function applyTemplateToConsole(template: ImageTemplate, message: string) {
|
||
setMode("image");
|
||
setPromptByMode((items) => ({ ...items, image: template.prompt }));
|
||
setActiveTemplateId(template.id);
|
||
setMaterialDraft(null);
|
||
const templateEngine = normalizeImageEngine(template.settings.engine);
|
||
if (templateEngine) setImageEngine(templateEngine);
|
||
const preset = imageSizePresets.find((item) => item.width === template.settings.width && item.height === template.settings.height);
|
||
if (preset) setImageSize(preset);
|
||
const templateInfluence = jimengInfluenceOptions.find((option) => option.scale === template.settings.scale);
|
||
if (templateInfluence) setJimengInfluence(templateInfluence.id);
|
||
const templateQuality = evolinkQualityOptions.find((option) => option.quality === template.settings.quality);
|
||
if (templateQuality) setEvolinkQuality(templateQuality.id);
|
||
setMentionState(null);
|
||
setPromptScrollTop(0);
|
||
setNotice(message);
|
||
setError(null);
|
||
}
|
||
|
||
function resetTemplateForm() {
|
||
setTemplateForm(defaultTemplateFormForEngine(imageEngine));
|
||
setEditingTemplateId(null);
|
||
setTemplateError(null);
|
||
setMaterialDraft(null);
|
||
setTemplatePromptScrollTop(0);
|
||
}
|
||
|
||
function openTemplateCreator() {
|
||
resetTemplateForm();
|
||
setTemplateEditorOpen(true);
|
||
}
|
||
|
||
function openTemplateEditor(template: ImageTemplate) {
|
||
setTemplateForm(formFromTemplate(template));
|
||
setEditingTemplateId(template.id);
|
||
setTemplateError(null);
|
||
setMaterialDraft(null);
|
||
setTemplatePromptScrollTop(0);
|
||
setTemplateEditorOpen(true);
|
||
}
|
||
|
||
function closeTemplateEditor() {
|
||
setTemplateEditorOpen(false);
|
||
setEditingTemplateId(null);
|
||
setTemplateError(null);
|
||
setMaterialDraft(null);
|
||
setTemplatePromptScrollTop(0);
|
||
}
|
||
|
||
async function saveTemplate() {
|
||
const isEditing = Boolean(editingTemplateId);
|
||
const endpoint = editingTemplateId ? `/api/image-templates/${encodeURIComponent(editingTemplateId)}` : "/api/image-templates";
|
||
setTemplateSaving(true);
|
||
setTemplateError(null);
|
||
try {
|
||
const response = await fetch(endpoint, {
|
||
method: isEditing ? "PATCH" : "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(templatePayloadFromForm(templateForm))
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok) throw new Error(payload.error || "保存模板失败");
|
||
const saved = payload.template as ImageTemplate;
|
||
setImageTemplates((items) => {
|
||
const rest = items.filter((item) => item.id !== saved.id);
|
||
return [...rest, saved].sort(sortTemplates);
|
||
});
|
||
setTemplateForm(formFromTemplate(saved));
|
||
setEditingTemplateId(null);
|
||
setTemplateEditorOpen(false);
|
||
applyTemplateToConsole(saved, isEditing ? "模板已更新并应用。" : "模板已保存并应用。");
|
||
} catch (err) {
|
||
setTemplateError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setTemplateSaving(false);
|
||
}
|
||
}
|
||
|
||
function insertTemplatePlaceholder(type: MaterialKind) {
|
||
const placeholders = extractMaterialPlaceholders(templateForm.prompt).filter((placeholder) => placeholder.type === type);
|
||
const nextIndex = placeholders.reduce((max, placeholder) => Math.max(max, placeholder.index), 0) + 1;
|
||
const token = labelFor(type, nextIndex);
|
||
const textarea = templatePromptRef.current;
|
||
const value = templateForm.prompt;
|
||
if (!textarea) {
|
||
const joiner = value.endsWith("\n") || !value.trim() ? "" : " ";
|
||
setTemplateForm((form) => ({ ...form, prompt: `${value}${joiner}${token} ` }));
|
||
return;
|
||
}
|
||
const start = textarea.selectionStart ?? value.length;
|
||
const end = textarea.selectionEnd ?? value.length;
|
||
const before = value.slice(0, start);
|
||
const after = value.slice(end);
|
||
const needsLeadingSpace = before.length > 0 && !/[\s,。;:、((【\[]$/.test(before);
|
||
const insertion = `${needsLeadingSpace ? " " : ""}${token} `;
|
||
const nextPrompt = `${before}${insertion}${after}`;
|
||
const nextCursor = before.length + insertion.length;
|
||
setTemplateForm((form) => ({ ...form, prompt: nextPrompt }));
|
||
window.requestAnimationFrame(() => {
|
||
textarea.focus();
|
||
textarea.setSelectionRange(nextCursor, nextCursor);
|
||
});
|
||
}
|
||
|
||
function startMaterialDraft(target: MaterialDraftTarget, index: number) {
|
||
setMentionState(null);
|
||
setMaterialDraft({ target, index, query: "", activeIndex: null });
|
||
}
|
||
|
||
function updateMaterialDraftQuery(value: string) {
|
||
setMaterialDraft((draft) => draft ? { ...draft, query: value.replace(/^@+/, ""), activeIndex: null } : draft);
|
||
}
|
||
|
||
function confirmMaterialDraft(option?: MaterialDraftOption) {
|
||
if (!materialDraft) return;
|
||
const sourcePrompt = materialDraft.target === "template" ? templateForm.prompt : prompt;
|
||
const token = option?.token || normalizeMaterialDraftToken(materialDraft.query, sourcePrompt);
|
||
if (!token) return;
|
||
const next = insertMaterialDraftToken(sourcePrompt, materialDraft.index, token);
|
||
const target = materialDraft.target;
|
||
setMaterialDraft(null);
|
||
setMentionState(null);
|
||
if (target === "template") {
|
||
setTemplateForm((form) => ({ ...form, prompt: next.text }));
|
||
window.requestAnimationFrame(() => {
|
||
templatePromptRef.current?.focus();
|
||
templatePromptRef.current?.setSelectionRange(next.cursor, next.cursor);
|
||
});
|
||
return;
|
||
}
|
||
setPrompt(next.text);
|
||
window.requestAnimationFrame(() => {
|
||
promptRef.current?.focus();
|
||
promptRef.current?.setSelectionRange(next.cursor, next.cursor);
|
||
});
|
||
}
|
||
|
||
function handleMaterialDraftKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||
if (!materialDraft) return;
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
const target = materialDraft.target;
|
||
setMaterialDraft(null);
|
||
window.requestAnimationFrame(() => {
|
||
(target === "template" ? templatePromptRef.current : promptRef.current)?.focus();
|
||
});
|
||
return;
|
||
}
|
||
if (event.key === "ArrowDown" && materialDraftOptions.length) {
|
||
event.preventDefault();
|
||
setMaterialDraft((draft) => draft ? { ...draft, activeIndex: draft.activeIndex === null ? 0 : (draft.activeIndex + 1) % materialDraftOptions.length } : draft);
|
||
return;
|
||
}
|
||
if (event.key === "ArrowUp" && materialDraftOptions.length) {
|
||
event.preventDefault();
|
||
setMaterialDraft((draft) => draft ? { ...draft, activeIndex: draft.activeIndex === null ? materialDraftOptions.length - 1 : (draft.activeIndex - 1 + materialDraftOptions.length) % materialDraftOptions.length } : draft);
|
||
return;
|
||
}
|
||
if (event.key === "Enter" || event.key === "Tab") {
|
||
event.preventDefault();
|
||
confirmMaterialDraft(materialDraftOptions[materialDraft.activeIndex ?? 0]);
|
||
}
|
||
}
|
||
|
||
function handleTemplatePromptInput(value: string, cursor: number) {
|
||
const draft = detectMaterialDraftStart(templateForm.prompt, value, cursor);
|
||
if (draft) {
|
||
setTemplateForm((form) => ({ ...form, prompt: draft.text }));
|
||
startMaterialDraft("template", draft.index);
|
||
return;
|
||
}
|
||
setTemplateForm((form) => ({ ...form, prompt: value }));
|
||
}
|
||
|
||
function selectMention(material: PromptMaterial) {
|
||
if (!material.label) return;
|
||
const textarea = promptRef.current;
|
||
const end = textarea?.selectionStart ?? prompt.length;
|
||
const start = mentionState?.start ?? end;
|
||
const before = prompt.slice(0, start);
|
||
const after = prompt.slice(end);
|
||
const needsTrailingSpace = after.length > 0 && !/^[\s,。;:、))】\]]/.test(after);
|
||
const insertion = `${material.label}${needsTrailingSpace ? " " : ""}`;
|
||
const nextPrompt = `${before}${insertion}${after}`;
|
||
const nextCursor = before.length + insertion.length;
|
||
setPrompt(nextPrompt);
|
||
setMentionState(null);
|
||
window.requestAnimationFrame(() => {
|
||
textarea?.focus();
|
||
textarea?.setSelectionRange(nextCursor, nextCursor);
|
||
});
|
||
}
|
||
|
||
function handlePromptKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||
if (!mentionState) return;
|
||
if (event.key === "Escape") {
|
||
setMentionState(null);
|
||
return;
|
||
}
|
||
if (!mentionSuggestions.length) return;
|
||
if (event.key === "ArrowDown") {
|
||
event.preventDefault();
|
||
setActiveMentionIndex((index) => index === null ? 0 : (index + 1) % mentionSuggestions.length);
|
||
}
|
||
if (event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
setActiveMentionIndex((index) => index === null ? mentionSuggestions.length - 1 : (index - 1 + mentionSuggestions.length) % mentionSuggestions.length);
|
||
}
|
||
if (event.key === "Enter" || event.key === "Tab") {
|
||
event.preventDefault();
|
||
selectMention(mentionSuggestions[activeMentionIndex ?? 0] || mentionSuggestions[0]);
|
||
}
|
||
}
|
||
|
||
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 (missingMaterialPlaceholders.length) {
|
||
setError(`请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}。`);
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setError(null);
|
||
setNotice(null);
|
||
try {
|
||
const endpoint = generateMode === "image" ? "/api/generations/image" : "/api/generations/video";
|
||
if (generateMode === "video" && videoEngine === "bailian") {
|
||
const imageCount = materials.filter((material) => material.type === "image").length;
|
||
if (imageCount < 1 || imageCount > 2 || materials.some((material) => material.type !== "image")) {
|
||
throw new Error("百炼图生视频请上传 1 张首帧图,或按顺序上传 2 张首尾帧图。");
|
||
}
|
||
}
|
||
const body = buildGenerationBody();
|
||
const response = await fetch(endpoint, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body)
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok) throw new Error(payload.error || "提交生成失败");
|
||
const submittedJob = payload.job as GenerationJob | undefined;
|
||
if (submittedJob?.id) {
|
||
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" ? "图片" : "视频"}生成已提交。任务已进入任务模块,生成完成后结果可在任务详情中查看和下载。`);
|
||
setPrompt("");
|
||
setActiveTemplateId(null);
|
||
setMaterials([]);
|
||
setMaterialPage(1);
|
||
setMentionState(null);
|
||
setPromptScrollTop(0);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function renderCreateModeBar() {
|
||
return (
|
||
<div className="create-mode-bar">
|
||
<div className="segmented mode-switch" aria-label="创作类型">
|
||
<button type="button" className={clsx(mode === "image" && "active")} aria-pressed={mode === "image"} onClick={() => setMode("image")}>
|
||
<ImagePlus size={17} />
|
||
图片
|
||
</button>
|
||
<button type="button" className={clsx(mode === "video" && "active")} aria-pressed={mode === "video"} onClick={() => setMode("video")}>
|
||
<Film size={17} />
|
||
视频
|
||
</button>
|
||
</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>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<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>
|
||
{templatesLoading ? <Loader2 className="spin" size={16} /> : null}
|
||
</div>
|
||
<button className="button template-add-button" type="button" onClick={openTemplateCreator} disabled={templateSaving}>
|
||
<Plus size={16} />
|
||
添加模板
|
||
</button>
|
||
{templateError && !templateEditorOpen ? <div className="callout compact-callout" role="alert">{templateError}</div> : null}
|
||
<div className="image-template-rail-list">
|
||
{imageTemplates.length ? (
|
||
imageTemplates.map((template) => (
|
||
<article
|
||
className={clsx("image-template-rail-card", activeTemplateId === template.id && "active")}
|
||
key={template.id}
|
||
title={template.name}
|
||
>
|
||
<button
|
||
className="image-template-preview-button"
|
||
type="button"
|
||
title="放大预览"
|
||
aria-label={`放大预览 ${template.name}`}
|
||
onClick={() => setPreviewTemplate(template)}
|
||
>
|
||
{renderImageTemplatePreview(template)}
|
||
</button>
|
||
<div className="image-template-card-footer">
|
||
<div className="image-template-card-copy">
|
||
<strong>{template.name}</strong>
|
||
</div>
|
||
<button
|
||
className="image-template-edit-button"
|
||
type="button"
|
||
title="编辑模板"
|
||
aria-label={`编辑模板 ${template.name}`}
|
||
onClick={() => openTemplateEditor(template)}
|
||
>
|
||
<Pencil size={14} />
|
||
</button>
|
||
<button
|
||
className="image-template-select-button"
|
||
type="button"
|
||
onClick={() => applyImageTemplate(template)}
|
||
title={`选择模板 ${template.name}`}
|
||
aria-pressed={activeTemplateId === template.id}
|
||
>
|
||
{activeTemplateId === template.id ? <Check size={14} /> : <Plus size={14} />}
|
||
{activeTemplateId === template.id ? "取消选择" : "选择模板"}
|
||
</button>
|
||
</div>
|
||
</article>
|
||
))
|
||
) : !templatesLoading && !templateError ? (
|
||
<div className="template-empty compact">暂无模板</div>
|
||
) : null}
|
||
</div>
|
||
</aside>
|
||
<section className="panel create-workbench-panel create-center-panel">
|
||
<div className="create-center-head">
|
||
<h2>制作中心</h2>
|
||
{renderCreateModeBar()}
|
||
</div>
|
||
<div className="create-workbench-grid">
|
||
<div className="create-main-column">
|
||
|
||
{error || notice ? (
|
||
<div className="studio-messages top-studio-messages" ref={feedbackRef}>
|
||
{error ? <div className="callout" role="alert">{error}</div> : null}
|
||
{notice ? <div className="callout success-callout" role="status" aria-live="polite">{notice}</div> : null}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="field prompt-field">
|
||
<div className="prompt-label-row">
|
||
<label htmlFor="createPrompt">{generateMode === "image" ? "图片提示词" : "视频提示词"}</label>
|
||
<label className="icon-button prompt-upload-button" title="上传素材" aria-label="上传素材">
|
||
{uploading ? <Loader2 className="spin" size={18} /> : <Upload size={18} />}
|
||
<input
|
||
type="file"
|
||
multiple
|
||
accept={materialUploadAccept}
|
||
onChange={(event) => {
|
||
void uploadFiles(event.target.files);
|
||
event.currentTarget.value = "";
|
||
}}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="prompt-editor-wrap">
|
||
<div className="prompt-token-layer" aria-hidden="true">
|
||
<div style={{ transform: `translateY(-${promptScrollTop}px)` }}>
|
||
{renderPromptTokenLayer(prompt, materials)}
|
||
</div>
|
||
</div>
|
||
<textarea
|
||
id="createPrompt"
|
||
ref={promptRef}
|
||
value={prompt}
|
||
onChange={(event) => {
|
||
handlePromptInput(event.target.value, event.target.selectionStart);
|
||
}}
|
||
onInput={(event) => handlePromptInput(event.currentTarget.value, event.currentTarget.selectionStart)}
|
||
onClick={(event) => updateMention(event.currentTarget.value, event.currentTarget.selectionStart)}
|
||
onKeyDown={handlePromptKeyDown}
|
||
onKeyUp={(event) => updateMention(event.currentTarget.value, event.currentTarget.selectionStart)}
|
||
onScroll={(event) => setPromptScrollTop(event.currentTarget.scrollTop)}
|
||
placeholder="输入营销内容提示词,键入 @ 选择已上传素材"
|
||
/>
|
||
{materialDraft?.target === "prompt" ? renderMaterialDraftPanel(
|
||
materialDraft,
|
||
materialDraftOptions,
|
||
materialDraftInputRef,
|
||
updateMaterialDraftQuery,
|
||
handleMaterialDraftKeyDown,
|
||
confirmMaterialDraft
|
||
) : null}
|
||
{!materialDraft && mentionState && mentionSuggestions.length ? (
|
||
<div className="mention-popover">
|
||
{mentionSuggestions.map((material, index) => (
|
||
<button
|
||
className={clsx("mention-option", index === activeMentionIndex && "active")}
|
||
type="button"
|
||
key={`${material.label}-${material.url}`}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
selectMention(material);
|
||
}}
|
||
>
|
||
{renderMaterialPreview(material, "tiny")}
|
||
<span className="chip-token">{material.label}</span>
|
||
<span className="chip-name">{material.name || material.url}</span>
|
||
<span className="mention-kind">{shortTypeName(material.type)}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{missingMaterialPlaceholders.length ? (
|
||
<div className="material-placeholder-board" aria-label="待上传模板素材">
|
||
{missingMaterialPlaceholders.map((placeholder) => (
|
||
<label className={clsx("material-placeholder-card", placeholder.type, uploading && "uploading")} key={placeholder.token} title={`上传${placeholder.token}`}>
|
||
<span className="material-placeholder-icon" aria-hidden="true">
|
||
{uploading ? <Loader2 className="spin" size={18} /> : <Upload size={18} />}
|
||
</span>
|
||
<span className="material-placeholder-copy">
|
||
<span className="chip-token">{placeholder.token}</span>
|
||
<span className="material-placeholder-state">待上传{shortTypeName(placeholder.type)}</span>
|
||
</span>
|
||
<input
|
||
type="file"
|
||
accept={acceptForMaterialKind(placeholder.type)}
|
||
disabled={uploading}
|
||
onChange={(event) => {
|
||
void uploadFiles(event.target.files, { targetLabel: placeholder.token, expectedType: placeholder.type });
|
||
event.currentTarget.value = "";
|
||
}}
|
||
/>
|
||
</label>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{materials.length ? (
|
||
<>
|
||
<div className="material-board prompt-material-board" aria-label="已上传素材" ref={materialBoardRef}>
|
||
{visibleMaterials.map((material, index) => (
|
||
<article
|
||
className={clsx("material-card", material.type, materialIsReferenced(material, referencedMaterialLabels) ? "referenced" : "missing")}
|
||
key={`${material.url}-${materialPageOffset + index}`}
|
||
>
|
||
<button
|
||
className="material-card-main"
|
||
type="button"
|
||
title={`${material.label || ""} ${material.name || material.url}`}
|
||
onClick={() => insertToken(material.label)}
|
||
>
|
||
{renderMaterialPreview(material, "large")}
|
||
<span className="material-card-copy">
|
||
<span className="material-card-head">
|
||
<span className="chip-token">{material.label}</span>
|
||
<span className="material-state">{materialIsReferenced(material, referencedMaterialLabels) ? "已引用" : "未引用"}</span>
|
||
</span>
|
||
<span className="chip-name">{material.name || material.url}</span>
|
||
</span>
|
||
</button>
|
||
<button
|
||
className="material-remove"
|
||
type="button"
|
||
title="移除素材"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
removeMaterial(materialPageOffset + index);
|
||
}}
|
||
>
|
||
<X size={14} aria-hidden="true" />
|
||
</button>
|
||
</article>
|
||
))}
|
||
</div>
|
||
<Pagination page={materialPage} pageSize={MATERIAL_PAGE_SIZE} total={materials.length} label="素材分页" onPageChange={setMaterialPage} />
|
||
</>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="generation-settings-row">
|
||
<div className="inline-settings">
|
||
{generateMode === "image" ? (
|
||
<>
|
||
<div className="field inline-field">
|
||
<label htmlFor="imageEngine">生图引擎</label>
|
||
<select id="imageEngine" value={imageEngine} onChange={(event) => setImageEngine(normalizeImageEngine(event.target.value) || "jimeng")}>
|
||
<option value="jimeng">即梦</option>
|
||
<option value="evolink">Image2</option>
|
||
<option value="bailian">阿里云百炼</option>
|
||
</select>
|
||
</div>
|
||
<div className="field inline-field">
|
||
<label htmlFor="imageSize">画幅</label>
|
||
<select
|
||
id="imageSize"
|
||
value={imageSize.label}
|
||
onChange={(event) => setImageSize(imageSizePresets.find((item) => item.label === event.target.value) || imageSizePresets[0])}
|
||
>
|
||
{imageSizePresets.map((preset) => <option key={preset.label}>{preset.label}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="field inline-field">
|
||
<label htmlFor="imageEngineTuning">{imageEngine === "evolink" ? "生成质量" : imageEngine === "bailian" ? "生成模式" : "文本影响"}</label>
|
||
{imageEngine === "evolink" ? (
|
||
<select id="imageEngineTuning" value={evolinkQuality} onChange={(event) => setEvolinkQuality(event.target.value)}>
|
||
{evolinkQualityOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
|
||
</select>
|
||
) : imageEngine === "bailian" ? (
|
||
<select id="imageEngineTuning" value="thinking" disabled><option value="thinking">智能推理</option></select>
|
||
) : (
|
||
<select id="imageEngineTuning" value={jimengInfluence} onChange={(event) => setJimengInfluence(event.target.value)}>
|
||
{jimengInfluenceOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
|
||
</select>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="field inline-field">
|
||
<label htmlFor="videoEngine">视频引擎</label>
|
||
<select id="videoEngine" value={videoEngine} onChange={(event) => setVideoEngine(event.target.value === "bailian" ? "bailian" : "seedance")}>
|
||
<option value="seedance">Seedance</option>
|
||
<option value="bailian">阿里云百炼</option>
|
||
</select>
|
||
</div>
|
||
{videoEngine === "seedance" ? (
|
||
<div className="field inline-field">
|
||
<label htmlFor="videoRatio">比例</label>
|
||
<select id="videoRatio" value={videoRatio} onChange={(event) => setVideoRatio(event.target.value)}>
|
||
{VIDEO_RATIOS.map((ratio) => (
|
||
<option key={ratio} value={ratio}>{ratio === "adaptive" ? "自适应" : ratio}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
) : null}
|
||
<div className="field inline-field">
|
||
<label htmlFor="videoDuration">时长</label>
|
||
<select
|
||
id="videoDuration"
|
||
value={videoDuration}
|
||
onChange={(event) => setVideoDuration(clampVideoDuration(event.target.value, videoDuration, { allowAuto: false }))}
|
||
>
|
||
{(videoEngine === "bailian" ? Array.from({ length: 14 }, (_, index) => index + 2) : VIDEO_DURATION_OPTIONS).map((seconds) => <option key={seconds} value={seconds}>{seconds} 秒</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="field inline-field">
|
||
<label htmlFor="videoResolution">清晰度</label>
|
||
<select id="videoResolution" value={videoResolution} onChange={(event) => setVideoResolution(event.target.value)}>
|
||
{(videoEngine === "bailian" ? ["720p", "1080p"] : VIDEO_RESOLUTIONS).map((resolution) => <option key={resolution} value={resolution}>{resolution}</option>)}
|
||
</select>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
<BillingEstimate quote={billingQuote} loading={billingQuoteLoading} error={billingQuoteError} />
|
||
</div>
|
||
</div>
|
||
{templateEditorOpen ? (
|
||
<div className="template-editor-backdrop" role="dialog" aria-modal="true" aria-label={editingTemplateId ? "编辑模板" : "添加模板"}>
|
||
<div className="template-editor-dialog">
|
||
<div className="template-editor-head">
|
||
<div className="template-editor-title">
|
||
<span className="template-editor-mark" aria-hidden="true">
|
||
<ImagePlus size={18} />
|
||
</span>
|
||
<h2>{editingTemplateId ? "编辑模板" : "添加模板"}</h2>
|
||
</div>
|
||
<button
|
||
className="icon-button"
|
||
type="button"
|
||
title="关闭"
|
||
aria-label="关闭"
|
||
onClick={closeTemplateEditor}
|
||
disabled={templateSaving}
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
{templateError ? <div className="callout compact-callout" role="alert">{templateError}</div> : null}
|
||
<div className="template-editor-body">
|
||
<aside className="template-editor-preview-pane">
|
||
<div className="template-preview-pane-head">
|
||
<span>效果预览图</span>
|
||
<span className={clsx("template-required-pill", templateForm.previewImageUrl && "complete")}>
|
||
{templateForm.previewImageUrl ? "已上传" : "必填"}
|
||
</span>
|
||
</div>
|
||
<label className={clsx("template-preview-uploader", templateForm.previewImageUrl && "has-image", templatePreviewUploading && "uploading")}>
|
||
<span className="template-preview-uploader-media" aria-hidden="true">
|
||
{templatePreviewUploading ? <Loader2 className="spin" size={22} /> : templateForm.previewImageUrl ? <img src={templateForm.previewImageUrl} alt="" /> : <Upload size={22} />}
|
||
</span>
|
||
<span>{templateForm.previewImageUrl ? "重新上传" : "点击上传"}</span>
|
||
<input
|
||
type="file"
|
||
accept={templatePreviewAccept}
|
||
disabled={templatePreviewUploading}
|
||
onChange={(event) => {
|
||
void uploadTemplatePreview(event.target.files);
|
||
event.currentTarget.value = "";
|
||
}}
|
||
/>
|
||
</label>
|
||
<div className="template-preview-meta" aria-label="模板参数预览">
|
||
<span>{imageEngineLabel(templateForm.engine)}</span>
|
||
<span>{templateForm.engine === "evolink" ? selectedTemplateEvolinkQuality.label : templateForm.engine === "bailian" ? "智能推理" : selectedTemplateJimengInfluence.label}</span>
|
||
<span>{templateForm.size}</span>
|
||
</div>
|
||
</aside>
|
||
<div className="template-inline-form template-editor-form">
|
||
<label className="field">
|
||
<span>模板名称</span>
|
||
<input value={templateForm.name} onChange={(event) => setTemplateForm((form) => ({ ...form, name: event.target.value }))} />
|
||
</label>
|
||
<div className="field template-engine-field">
|
||
<span>生图引擎</span>
|
||
<div className="segmented template-engine-choice" aria-label="生图引擎">
|
||
<button
|
||
type="button"
|
||
className={clsx(templateForm.engine === "jimeng" && "active")}
|
||
aria-pressed={templateForm.engine === "jimeng"}
|
||
onClick={() => setTemplateForm((form) => ({ ...form, engine: "jimeng" }))}
|
||
>
|
||
即梦
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={clsx(templateForm.engine === "evolink" && "active")}
|
||
aria-pressed={templateForm.engine === "evolink"}
|
||
onClick={() => setTemplateForm((form) => ({ ...form, engine: "evolink" }))}
|
||
>
|
||
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" ? "生成质量" : 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>)}
|
||
</select>
|
||
)}
|
||
</label>
|
||
<label className="field">
|
||
<span>画幅</span>
|
||
<select value={templateForm.size} onChange={(event) => setTemplateForm((form) => ({ ...form, size: event.target.value }))}>
|
||
{imageSizePresets.map((preset) => <option value={preset.label} key={preset.label}>{preset.label}</option>)}
|
||
</select>
|
||
</label>
|
||
<label className="field">
|
||
<span>排序</span>
|
||
<input type="number" value={templateForm.sortOrder} onChange={(event) => setTemplateForm((form) => ({ ...form, sortOrder: event.target.value }))} />
|
||
</label>
|
||
<label className="field template-prompt-field">
|
||
<span>预设提示词</span>
|
||
<div className="template-placeholder-tools" aria-label="插入占位符">
|
||
<button type="button" onClick={() => insertTemplatePlaceholder("image")}>
|
||
<Plus size={13} />
|
||
图片
|
||
</button>
|
||
<button type="button" onClick={() => insertTemplatePlaceholder("video")}>
|
||
<Plus size={13} />
|
||
视频
|
||
</button>
|
||
<button type="button" onClick={() => insertTemplatePlaceholder("audio")}>
|
||
<Plus size={13} />
|
||
音频
|
||
</button>
|
||
</div>
|
||
<div className="prompt-editor-wrap template-prompt-editor-wrap">
|
||
<div className="prompt-token-layer" aria-hidden="true">
|
||
<div style={{ transform: `translateY(-${templatePromptScrollTop}px)` }}>
|
||
{renderPromptTokenLayer(templateForm.prompt, [])}
|
||
</div>
|
||
</div>
|
||
<textarea
|
||
ref={templatePromptRef}
|
||
value={templateForm.prompt}
|
||
onChange={(event) => handleTemplatePromptInput(event.target.value, event.target.selectionStart)}
|
||
onInput={(event) => handleTemplatePromptInput(event.currentTarget.value, event.currentTarget.selectionStart)}
|
||
onScroll={(event) => setTemplatePromptScrollTop(event.currentTarget.scrollTop)}
|
||
rows={7}
|
||
placeholder="例如:以 @图片1 为主体,生成干净高级的商品主视觉"
|
||
/>
|
||
{materialDraft?.target === "template" ? renderMaterialDraftPanel(
|
||
materialDraft,
|
||
materialDraftOptions,
|
||
materialDraftInputRef,
|
||
updateMaterialDraftQuery,
|
||
handleMaterialDraftKeyDown,
|
||
confirmMaterialDraft
|
||
) : null}
|
||
</div>
|
||
</label>
|
||
{templateFormPlaceholders.length ? (
|
||
<div className="template-placeholder-strip" aria-label="模板占位符">
|
||
{templateFormPlaceholders.map((placeholder) => (
|
||
<span className="template-placeholder-chip" key={placeholder.token}>
|
||
{placeholder.token}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="template-editor-actions">
|
||
<button className="button" type="button" onClick={closeTemplateEditor} disabled={templateSaving}>
|
||
取消
|
||
</button>
|
||
<button className="button primary" type="button" onClick={() => void saveTemplate()} disabled={templateSaving || templatePreviewUploading || !templateFormReady}>
|
||
{templateSaving ? <Loader2 className="spin" size={18} /> : <Save size={18} />}
|
||
{editingTemplateId ? "保存修改" : "保存模板"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{previewTemplate ? (
|
||
<div className="template-preview-backdrop" role="dialog" aria-modal="true" aria-label="模板预览">
|
||
<div className="template-preview-dialog">
|
||
<div className="template-preview-dialog-head">
|
||
<h2>{previewTemplate.name}</h2>
|
||
<button className="icon-button" type="button" title="关闭" aria-label="关闭" onClick={() => setPreviewTemplate(null)}>
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
<div className="template-preview-dialog-media">
|
||
{previewTemplate.previewImageUrl ? <img src={previewTemplate.previewImageUrl} alt="" /> : <ImagePlus size={40} />}
|
||
</div>
|
||
<div className="template-preview-dialog-actions">
|
||
{previewTemplate.previewImageUrl ? (
|
||
<a className="button" href={previewTemplate.previewImageUrl} download>
|
||
<Download size={16} />
|
||
下载图片
|
||
</a>
|
||
) : null}
|
||
<button className="button" type="button" onClick={() => {
|
||
const template = previewTemplate;
|
||
setPreviewTemplate(null);
|
||
openTemplateEditor(template);
|
||
}}>
|
||
<Pencil size={16} />
|
||
编辑模板
|
||
</button>
|
||
<button className="button primary" type="button" onClick={() => {
|
||
applyImageTemplate(previewTemplate);
|
||
setPreviewTemplate(null);
|
||
}}>
|
||
选择模板
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
<aside className="panel create-task-column" aria-label="任务模块">
|
||
<div className="create-task-head">
|
||
<div>
|
||
<h2>任务模块</h2>
|
||
<span>{recentJobs.length ? `${recentJobs.length} 个任务` : "任务列表"}</span>
|
||
</div>
|
||
<button
|
||
className="icon-button"
|
||
type="button"
|
||
title="刷新任务"
|
||
aria-label="刷新任务"
|
||
onClick={() => void loadTaskModuleData()}
|
||
disabled={tasksLoading}
|
||
>
|
||
{tasksLoading ? <Loader2 className="spin" size={16} /> : <RefreshCw size={16} />}
|
||
</button>
|
||
</div>
|
||
{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}
|
||
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 ${taskStatusClass(job, taskAssetById)}`}>{statusLabel(job.status, Boolean(firstOutputAsset(job, taskAssetById)))}</span>
|
||
<span>{durationLabel(job, durationNow)}</span>
|
||
<span className="billing-task-cost">{billingLabel(job)}</span>
|
||
</div>
|
||
</div>
|
||
<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 ? (
|
||
<div className="template-empty compact">暂无任务</div>
|
||
) : null}
|
||
</div>
|
||
</aside>
|
||
{selectedTask ? (
|
||
<TaskDetailModal
|
||
job={selectedTask}
|
||
assets={taskAssetById}
|
||
now={durationNow}
|
||
onClose={() => setTaskDetailJobId(null)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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) {
|
||
const awaitingOutput = job.status === "succeeded";
|
||
return (
|
||
<div className={clsx("create-task-thumb create-task-thumb-placeholder", (isPendingTask(job) || awaitingOutput) && "generating")} aria-hidden="true">
|
||
{isPendingTask(job) ? <Loader2 className="spin" size={18} /> : <ImageIcon size={18} />}
|
||
<span>{isPendingTask(job) ? "生成中" : awaitingOutput ? "结果同步中" : "无结果"}</span>
|
||
</div>
|
||
);
|
||
}
|
||
const isVideo = asset.kind === "video" || materialTypeForAsset(asset) === "video";
|
||
const media = isVideo
|
||
? <video src={asset.url} muted playsInline preload="metadata" />
|
||
: <img src={asset.url} alt="" />;
|
||
return <div className="create-task-thumb" aria-hidden="true">{media}</div>;
|
||
}
|
||
|
||
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 ${taskStatusClass(job, assets)}`}>{statusLabel(job.status, outputAssets.length > 0)}</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,
|
||
error
|
||
}: {
|
||
quote: BillingQuote | null;
|
||
loading: boolean;
|
||
error: string | null;
|
||
}) {
|
||
return (
|
||
<aside className={clsx("billing-estimate", loading && "is-loading", error && "has-error")} 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 ? "…" : error ? "暂不可用" : quote ? formatBillingAmount(quote.amountFen) : "—"}
|
||
</strong>
|
||
{error ? <span className="billing-estimate-error">{error}</span> : null}
|
||
</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";
|
||
}
|
||
|
||
function taskName(job: GenerationJob) {
|
||
const text = job.prompt?.trim();
|
||
return text ? text : capabilityLabel(job.capability);
|
||
}
|
||
|
||
function taskStatusClass(job: GenerationJob, assets: Map<string, Asset>) {
|
||
return job.status === "succeeded" && !firstOutputAsset(job, assets) ? "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 capabilityLabel(capability?: GenerationJob["capability"]) {
|
||
if (capability === "image.generate") 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)}`;
|
||
}
|
||
|
||
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}秒`;
|
||
}
|
||
|
||
function renderMaterialPreview(material: PromptMaterial, size: "normal" | "tiny" | "large" = "normal") {
|
||
return (
|
||
<span className={clsx("material-preview", size, material.type)} aria-hidden="true">
|
||
{material.type === "image" ? <img src={material.url} alt="" /> : null}
|
||
{material.type === "video" ? <video src={material.url} muted playsInline preload="metadata" /> : null}
|
||
{material.type === "audio" ? <Music size={size === "tiny" ? 13 : size === "large" ? 20 : 15} /> : null}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function formFromTemplate(template: ImageTemplate): TemplateForm {
|
||
const engine = normalizeImageEngine(template.settings.engine) || "jimeng";
|
||
const jimengInfluence = jimengInfluenceOptions.find((option) => option.scale === template.settings.scale)?.id || jimengInfluenceOptions[1].id;
|
||
const evolinkQuality = evolinkQualityOptions.find((option) => option.quality === template.settings.quality)?.id || evolinkQualityOptions[1].id;
|
||
return {
|
||
name: template.name,
|
||
prompt: template.prompt,
|
||
previewImageUrl: template.previewImageUrl || "",
|
||
engine,
|
||
jimengInfluence,
|
||
evolinkQuality,
|
||
size: sizeLabelFromSettings(template.settings.width, template.settings.height),
|
||
sortOrder: String(template.sortOrder ?? 0)
|
||
};
|
||
}
|
||
|
||
function templatePayloadFromForm(form: TemplateForm) {
|
||
const size = imageSizePresets.find((preset) => preset.label === form.size) || imageSizePresets[0];
|
||
const selectedInfluence = jimengInfluenceOptions.find((option) => option.id === form.jimengInfluence) || jimengInfluenceOptions[1];
|
||
const selectedQuality = evolinkQualityOptions.find((option) => option.id === form.evolinkQuality) || evolinkQualityOptions[1];
|
||
return {
|
||
name: form.name,
|
||
prompt: form.prompt,
|
||
previewImageUrl: form.previewImageUrl,
|
||
settings: {
|
||
engine: form.engine,
|
||
width: size.width,
|
||
height: size.height,
|
||
forceSingle: true,
|
||
...(form.engine === "evolink"
|
||
? { quality: selectedQuality.quality }
|
||
: { scale: selectedInfluence.scale })
|
||
},
|
||
sortOrder: Number(form.sortOrder || 0)
|
||
};
|
||
}
|
||
|
||
function defaultTemplateFormForEngine(engine: ImageGenerateEngine): TemplateForm {
|
||
return {
|
||
...defaultTemplateForm,
|
||
engine,
|
||
jimengInfluence: jimengInfluenceOptions[1].id,
|
||
evolinkQuality: evolinkQualityOptions[1].id
|
||
};
|
||
}
|
||
|
||
function sizeLabelFromSettings(width?: number, height?: number) {
|
||
return imageSizePresets.find((preset) => preset.width === width && preset.height === height)?.label || imageSizePresets[0].label;
|
||
}
|
||
|
||
function normalizeImageEngine(value?: string): ImageGenerateEngine | undefined {
|
||
if (value === "jimeng" || value === "evolink" || value === "bailian") return value;
|
||
return undefined;
|
||
}
|
||
|
||
function imageEngineLabel(engine: ImageGenerateEngine) {
|
||
return engine === "evolink" ? "Image2" : engine === "bailian" ? "百炼" : "即梦";
|
||
}
|
||
|
||
function sortTemplates(a: ImageTemplate, b: ImageTemplate) {
|
||
const order = (a.sortOrder || 0) - (b.sortOrder || 0);
|
||
if (order !== 0) return order;
|
||
return (b.updatedAt || "").localeCompare(a.updatedAt || "");
|
||
}
|
||
|
||
function renderImageTemplatePreview(template: ImageTemplate) {
|
||
return (
|
||
<span className="image-template-preview" aria-hidden="true">
|
||
{template.previewImageUrl ? <img src={template.previewImageUrl} alt="" /> : <ImagePlus size={22} />}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function renderMaterialDraftPanel(
|
||
draft: MaterialDraftState,
|
||
options: MaterialDraftOption[],
|
||
inputRef: RefObject<HTMLInputElement | null>,
|
||
onQueryChange: (value: string) => void,
|
||
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void,
|
||
onConfirm: (option?: MaterialDraftOption) => void
|
||
) {
|
||
return (
|
||
<div className="material-draft-panel" role="group" aria-label="素材占位输入">
|
||
<div className="material-draft-entry">
|
||
<span className="material-draft-prefix" aria-hidden="true">@</span>
|
||
<input
|
||
ref={inputRef}
|
||
value={draft.query}
|
||
onChange={(event) => onQueryChange(event.target.value)}
|
||
onKeyDown={onKeyDown}
|
||
placeholder="图片1 / 视频1 / 音频1"
|
||
aria-label="素材占位名称"
|
||
/>
|
||
<button className="material-draft-confirm" type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => onConfirm(options[draft.activeIndex ?? 0])}>
|
||
确定
|
||
</button>
|
||
</div>
|
||
{options.length ? (
|
||
<div className="material-draft-options" aria-label="素材占位候选">
|
||
{options.map((option, index) => (
|
||
<button
|
||
className={clsx("material-draft-option", index === (draft.activeIndex ?? 0) && "active")}
|
||
type="button"
|
||
key={`${option.token}-${option.caption}`}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => onConfirm(option)}
|
||
>
|
||
<span className="chip-token">{option.token}</span>
|
||
<span>{option.label}</span>
|
||
<span>{option.caption}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function materialTypeForAsset(asset: Asset): MaterialKind {
|
||
const contentType = typeof asset.metadata.contentType === "string" ? asset.metadata.contentType.toLowerCase() : "";
|
||
const url = asset.url.toLowerCase();
|
||
if (asset.kind === "video" || contentType.startsWith("video/") || /\.(mp4|mov|webm)(\?|$)/.test(url)) return "video";
|
||
if (contentType.startsWith("audio/") || /\.(mp3|wav|m4a|aac|flac)(\?|$)/.test(url)) return "audio";
|
||
return "image";
|
||
}
|
||
|
||
function fileMatchesMaterialKind(file: File, type: MaterialKind): boolean {
|
||
const contentType = file.type.toLowerCase();
|
||
const name = file.name.toLowerCase();
|
||
if (type === "image") return contentType.startsWith("image/") || /\.(png|jpe?g|webp|gif|avif)(\?|$)/.test(name);
|
||
if (type === "video") return contentType.startsWith("video/") || /\.(mp4|mov|webm)(\?|$)/.test(name);
|
||
return contentType.startsWith("audio/") || /\.(mp3|wav|m4a|aac|flac)(\?|$)/.test(name);
|
||
}
|
||
|
||
function acceptForMaterialKind(type: MaterialKind): string {
|
||
if (type === "video") return "video/*";
|
||
if (type === "audio") return "audio/*";
|
||
return "image/*";
|
||
}
|
||
|
||
function labelFor(type: MaterialKind, index: number): string {
|
||
if (type === "video") return `@视频${index}`;
|
||
if (type === "audio") return `@音频${index}`;
|
||
return `@图片${index}`;
|
||
}
|
||
|
||
function nextMaterialLabel(type: MaterialKind, existing: PromptMaterial[]): string {
|
||
const used = new Set(existing.map((material) => normalizeMaterialLabel(material.label)).filter((label): label is string => Boolean(label)));
|
||
let index = 1;
|
||
while (used.has(labelFor(type, index))) index += 1;
|
||
return labelFor(type, index);
|
||
}
|
||
|
||
function normalizeMaterialLabel(label?: string): string | undefined {
|
||
const match = label?.trim().match(/^@(图片|图|视频|音频)(\d+)$/);
|
||
if (!match) return undefined;
|
||
const type = match[1] === "视频" ? "video" : match[1] === "音频" ? "audio" : "image";
|
||
return labelFor(type, Number(match[2]));
|
||
}
|
||
|
||
function materialTypeForLabel(label: string): MaterialKind | undefined {
|
||
const normalized = normalizeMaterialLabel(label);
|
||
if (!normalized) return undefined;
|
||
if (normalized.startsWith("@视频")) return "video";
|
||
if (normalized.startsWith("@音频")) return "audio";
|
||
return "image";
|
||
}
|
||
|
||
function materialIsReferenced(material: PromptMaterial, referencedLabels: Set<string>): boolean {
|
||
const label = normalizeMaterialLabel(material.label);
|
||
return Boolean(label && referencedLabels.has(label));
|
||
}
|
||
|
||
function findMentionAtCursor(value: string, cursor: number): MentionState | null {
|
||
const beforeCursor = value.slice(0, cursor);
|
||
const start = beforeCursor.lastIndexOf("@");
|
||
if (start === -1) return null;
|
||
const query = beforeCursor.slice(start + 1);
|
||
if (query.length > 16 || /[\s\n\r,。;:、,.!?!?()[\]{}<>《》"'“”]/.test(query)) return null;
|
||
if (/^(图片|图|视频|音频)\d*$/.test(query)) return null;
|
||
return { start, query };
|
||
}
|
||
|
||
function materialMatchesMention(material: PromptMaterial, query: string): boolean {
|
||
const normalizedQuery = normalizeMentionText(query);
|
||
if (!normalizedQuery) return true;
|
||
return [
|
||
material.label,
|
||
material.label?.replace(/^@/, ""),
|
||
material.name,
|
||
shortTypeName(material.type),
|
||
material.type
|
||
].filter(Boolean).some((value) => normalizeMentionText(String(value)).includes(normalizedQuery));
|
||
}
|
||
|
||
function buildMaterialDraftOptions(input: { target: MaterialDraftTarget; query: string; prompt: string; materials: PromptMaterial[] }): MaterialDraftOption[] {
|
||
const options: MaterialDraftOption[] = [];
|
||
if (input.target === "prompt") {
|
||
for (const material of input.materials) {
|
||
const token = normalizeMaterialLabel(material.label);
|
||
if (!token || !materialMatchesMention(material, input.query)) continue;
|
||
options.push({
|
||
token,
|
||
label: material.name || token,
|
||
caption: shortTypeName(material.type),
|
||
type: material.type
|
||
});
|
||
if (options.length >= 5) break;
|
||
}
|
||
}
|
||
|
||
for (const type of ["image", "video", "audio"] as MaterialDraftKind[]) {
|
||
const token = nextMaterialDraftToken(type, input.prompt);
|
||
const option = {
|
||
token,
|
||
label: `${shortTypeName(type)}占位`,
|
||
caption: input.target === "template" ? "模板素材" : "待上传素材",
|
||
type
|
||
};
|
||
if (materialDraftOptionMatches(option, input.query)) options.push(option);
|
||
}
|
||
|
||
return uniqueMaterialDraftOptions(options).slice(0, 8);
|
||
}
|
||
|
||
function materialDraftOptionMatches(option: MaterialDraftOption, query: string): boolean {
|
||
const normalizedQuery = normalizeMentionText(query);
|
||
if (!normalizedQuery) return true;
|
||
return [option.token, option.token.replace(/^@/, ""), option.label, option.caption, shortTypeName(option.type), option.type]
|
||
.some((value) => normalizeMentionText(value).includes(normalizedQuery));
|
||
}
|
||
|
||
function uniqueMaterialDraftOptions(options: MaterialDraftOption[]): MaterialDraftOption[] {
|
||
const seen = new Set<string>();
|
||
return options.filter((option) => {
|
||
if (seen.has(option.token)) return false;
|
||
seen.add(option.token);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function normalizeMentionText(value: string): string {
|
||
return value.replace(/^@/, "").trim().toLowerCase();
|
||
}
|
||
|
||
function renderPromptTokenLayer(prompt: string, materials: PromptMaterial[]) {
|
||
const materialByLabel = new Map(materials.map((material) => [normalizeMaterialLabel(material.label) || material.label, material]));
|
||
const nodes: ReactNode[] = [];
|
||
let cursor = 0;
|
||
for (const match of prompt.matchAll(/@(图片|图|视频|音频)(\d+)/g)) {
|
||
const index = match.index ?? 0;
|
||
if (index > cursor) nodes.push(prompt.slice(cursor, index));
|
||
const token = match[1] === "图" ? `@图片${match[2]}` : match[0];
|
||
const material = materialByLabel.get(token);
|
||
nodes.push(
|
||
<span className={clsx("prompt-token-card", material ? "linked" : "unbound")} key={`${token}-${index}`}>
|
||
{token}
|
||
</span>
|
||
);
|
||
cursor = index + match[0].length;
|
||
}
|
||
if (cursor < prompt.length) nodes.push(prompt.slice(cursor));
|
||
return nodes.length ? nodes : prompt;
|
||
}
|
||
|
||
function shortTypeName(type: MaterialKind) {
|
||
if (type === "video") return "视频";
|
||
if (type === "audio") return "音频";
|
||
return "图片";
|
||
}
|