若干更新迭代

This commit is contained in:
andy committed 2026-10-02 19:56:32 +08:00
1 parent deb9cc5bcf
commit 4ac786aafb
64 files changed
+3251 -291

No files matched your search

+73 -24
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Building2, KeyRound, Loader2, Pencil, Plus, RefreshCw, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Building2, KeyRound, Pencil, Plus, RefreshCw, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { AccountSecurityPanel } from "@/components/account-security-panel";
type Role = "super_admin" | "organization_admin" | "user";
@@ -32,6 +32,8 @@ type AccountManagerProps = {
canManageAccounts: boolean;
};
type MemberGroup = { id: string; name: string; status?: Status; members: Member[] };
const roleLabels: Record<Role, string> = {
super_admin: "超级管理员",
organization_admin: "组织管理员",
@@ -47,36 +49,68 @@ export function AccountManager({ canManageAccounts }: AccountManagerProps) {
const [selectedOrganizationId, setSelectedOrganizationId] = useState("");
const [form, setForm] = useState({ phone: "", displayName: "", password: "", role: "user" as Role });
const [organizationName, setOrganizationName] = useState("");
const [accountsLoadFailed, setAccountsLoadFailed] = useState(false);
const accountsRequestVersion = useRef(0);
const membersPanelRef = useRef<HTMLElement | null>(null);
const selectedOrganization = useMemo(
() => payload?.organizations.find((organization) => organization.id === selectedOrganizationId),
[payload?.organizations, selectedOrganizationId]
);
const members = payload?.members || [];
const memberGroups = useMemo(() => {
if (!payload) return [];
const groups: MemberGroup[] = payload.organizations
.filter((organization) => !payload.currentOrganizationId || organization.id === payload.currentOrganizationId)
.map((organization) => ({ ...organization, members: [] }));
const byOrganization = new Map(groups.map((group) => [group.id, group]));
for (const member of payload.members) {
const organizationId = member.organizationId || "";
let group = byOrganization.get(organizationId);
if (!group) {
group = { id: organizationId, name: organizationId ? `未知组织(${organizationId})` : "未归属组织", members: [] };
byOrganization.set(organizationId, group);
groups.push(group);
}
group.members.push(member);
}
return groups;
}, [payload]);
async function loadAccounts(organizationId = selectedOrganizationId) {
if (!canManageAccounts) return;
const requestVersion = ++accountsRequestVersion.current;
setLoading(true);
setAccountsLoadFailed(false);
setError(null);
try {
const query = organizationId ? `?organizationId=${encodeURIComponent(organizationId)}` : "";
const response = await fetch(`/api/admin/accounts${query}`, { cache: "no-store" });
const next = await response.json() as AccountPayload & { error?: string };
if (requestVersion !== accountsRequestVersion.current) return;
if (!response.ok) throw new Error(next.error || "账号列表加载失败。");
setPayload(next);
setSelectedOrganizationId(next.currentOrganizationId || "");
} catch (requestError) {
if (requestVersion !== accountsRequestVersion.current) return;
setAccountsLoadFailed(true);
setError(requestError instanceof Error ? requestError.message : String(requestError));
} finally {
setLoading(false);
if (requestVersion === accountsRequestVersion.current) setLoading(false);
}
}
useEffect(() => {
if (canManageAccounts) void loadAccounts();
else setLoading(false);
return () => { accountsRequestVersion.current += 1; };
}, [canManageAccounts]);
function viewOrganizationAccounts(organizationId: string) {
setSelectedOrganizationId(organizationId);
void loadAccounts(organizationId);
membersPanelRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
}
async function createUser(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
@@ -259,6 +293,7 @@ export function AccountManager({ canManageAccounts }: AccountManagerProps) {
<div className="account-org-item" key={organization.id}>
<div><strong>{organization.name}</strong><small className={organization.status === "active" ? "org-active" : "org-disabled"}>{organization.status === "active" ? "启用" : "停用"}</small></div>
<div className="account-org-actions">
<button className="account-org-view" type="button" onClick={() => viewOrganizationAccounts(organization.id)} aria-label={`查看 ${organization.name} 的账号`}>查看账号</button>
<button className="icon-button" type="button" onClick={() => void renameOrganization(organization)} title="重命名" aria-label={`重命名 ${organization.name}`}><Pencil size={15} /></button>
<button className="icon-button" type="button" onClick={() => void toggleOrganization(organization)} title={organization.status === "active" ? "停用组织" : "启用组织"} aria-label={`${organization.status === "active" ? "停用" : "启用"} ${organization.name}`}><RefreshCw size={15} /></button>
<button className="icon-button danger-icon" type="button" onClick={() => void deleteOrganization(organization)} title="删除组织" aria-label={`删除 ${organization.name}`}><Trash2 size={15} /></button>
@@ -270,10 +305,11 @@ export function AccountManager({ canManageAccounts }: AccountManagerProps) {
</div>
</section>
<section className="account-members-panel" aria-labelledby="account-members-title">
<section className="account-members-panel" aria-labelledby="account-members-title" ref={membersPanelRef}>
<div className="account-panel-heading account-members-heading">
<div>
<h2 id="account-members-title">{selectedOrganization?.name || "全平台账号"}</h2>
<h2 id="account-members-title">{selectedOrganization ? `${selectedOrganization.name}的账号` : "按组织查看账号"}</h2>
<p className="account-members-description">{payload?.canManageOrganizations ? "按组织展示账号,包含已停用账号。" : "显示本组织内可管理的普通用户账号。"}</p>
</div>
<div className="account-members-tools">
<label className="account-filter-field">
@@ -287,26 +323,39 @@ export function AccountManager({ canManageAccounts }: AccountManagerProps) {
</div>
</div>
{loading ? <AccountLoading /> : members.length ? (
<div className="account-table" role="table" aria-label="账号列表">
<div className="account-row account-head" role="row"><span role="columnheader">账号</span><span role="columnheader">角色</span><span role="columnheader">状态</span><span role="columnheader">操作</span></div>
{members.map((member) => (
<div className="account-row" role="row" key={member.id}>
<div className="account-member-identity" role="cell">
<span className="account-avatar" aria-hidden="true">{accountInitial(member.displayName)}</span>
<div className="account-member-main"><strong>{member.displayName}</strong><small>{member.phone}</small></div>
</div>
<div className="account-role-cell" role="cell">{payload?.canAssignOrganizationAdmin ? <select className="account-status-select" value={member.role} onChange={(event) => void changeRole(member, event.target.value as Role)} aria-label={`${member.displayName}的角色`}><option value="user">{roleLabels.user}</option><option value="organization_admin">{roleLabels.organization_admin}</option><option value="super_admin">{roleLabels.super_admin}</option></select> : <span>{roleLabels[member.role]}</span>}</div>
<div role="cell"><span className={`account-status-badge ${member.status === "active" ? "active" : "disabled"}`}>{member.status === "active" ? "启用" : "停用"}</span></div>
<div className="account-row-actions" role="cell">
<button className="icon-button" type="button" onClick={() => void resetPassword(member)} title="重置密码" aria-label={`重置 ${member.displayName} 密码`}><KeyRound size={16} /></button>
<button className="icon-button" type="button" onClick={() => void changeStatus(member)} title={member.status === "active" ? "停用" : "启用"} aria-label={`${member.status === "active" ? "停用" : "启用"} ${member.displayName}`}><RefreshCw size={16} /></button>
<button className="icon-button danger-icon" type="button" onClick={() => void deleteMember(member)} title="彻底删除" aria-label={`删除 ${member.displayName}`}><Trash2 size={16} /></button>
</div>
</div>
{loading ? <AccountLoading /> : accountsLoadFailed ? (
<div className="account-empty"><span>账号列表加载失败。</span><button className="button secondary" type="button" onClick={() => void loadAccounts(selectedOrganizationId)}>重新加载</button></div>
) : memberGroups.length ? (
<div className="account-member-groups">
{memberGroups.map((group) => (
<section className="account-member-group" key={group.id} aria-label={`${group.name}的账号`}>
<header className="account-member-group-heading">
<h3><Building2 size={17} aria-hidden="true" />{group.name}</h3>
{group.status === "disabled" ? <span className="account-status-badge disabled">组织已停用</span> : null}
<span className="account-member-count">{group.members.length} 个{payload?.canManageOrganizations ? "账号" : "普通账号"}</span>
</header>
{group.members.length ? <div className="account-table" role="table" aria-label={`${group.name}账号列表`}>
<div className="account-row account-head" role="row"><span role="columnheader">账号</span><span role="columnheader">角色</span><span role="columnheader">状态</span><span role="columnheader">操作</span></div>
{group.members.map((member) => (
<div className="account-row" role="row" key={member.id}>
<div className="account-member-identity" role="cell">
<span className="account-avatar" aria-hidden="true">{accountInitial(member.displayName)}</span>
<div className="account-member-main"><strong>{member.displayName}</strong><small>{member.phone}</small></div>
</div>
<div className="account-role-cell" role="cell">{payload?.canAssignOrganizationAdmin ? <select className="account-status-select" value={member.role} onChange={(event) => void changeRole(member, event.target.value as Role)} aria-label={`${member.displayName}的角色`}><option value="user">{roleLabels.user}</option><option value="organization_admin">{roleLabels.organization_admin}</option><option value="super_admin">{roleLabels.super_admin}</option></select> : <span>{roleLabels[member.role]}</span>}</div>
<div role="cell"><span className={`account-status-badge ${member.status === "active" ? "active" : "disabled"}`}>{member.status === "active" ? "启用" : "停用"}</span></div>
<div className="account-row-actions" role="cell">
<button className="icon-button" type="button" onClick={() => void resetPassword(member)} title="重置密码" aria-label={`重置 ${member.displayName} 密码`}><KeyRound size={16} /></button>
<button className="icon-button" type="button" onClick={() => void changeStatus(member)} title={member.status === "active" ? "停用" : "启用"} aria-label={`${member.status === "active" ? "停用" : "启用"} ${member.displayName}`}><RefreshCw size={16} /></button>
<button className="icon-button danger-icon" type="button" onClick={() => void deleteMember(member)} title="彻底删除" aria-label={`删除 ${member.displayName}`}><Trash2 size={16} /></button>
</div>
</div>
))}
</div> : <p className="account-member-group-empty">{payload?.canManageOrganizations ? "该组织暂无账号。" : "本组织暂无可管理的普通用户账号。"}</p>}
</section>
))}
</div>
) : <div className="account-empty">当前组织还没有账号。</div>}
) : <div className="account-empty">暂无组织或账号。</div>}
</section>
</>
) : null}
+6 -8
View File
@@ -6,6 +6,8 @@ import { clampPage, pageItems, Pagination } from "@/components/pagination";
import { modalEnter, modalExit, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
import { formatBillingAmount } from "@/lib/billing";
import { assetPreviewUrl } from "@/lib/client/asset-urls";
import { GenerationError } from "@/components/generation-error";
import { useBrowserAuth } from "@/components/browser-auth";
import type { Asset, GenerationJob } from "@/lib/types";
type AssetView = "assets" | "tasks";
@@ -18,6 +20,7 @@ type AssetManagerProps = {
};
export function AssetManager({ initialView = "assets", initialTaskId }: AssetManagerProps = {}) {
const { isAdmin } = useBrowserAuth();
const [assets, setAssets] = useState<Asset[]>([]);
const [jobs, setJobs] = useState<GenerationJob[]>([]);
const [view, setView] = useState<AssetView>(initialView === "tasks" ? "tasks" : "assets");
@@ -280,7 +283,7 @@ export function AssetManager({ initialView = "assets", initialTaskId }: AssetMan
</button>
</div>
</div>
{expandedJobId === job.id ? <JobDetails job={job} now={durationNow} outputAssetIds={outputAssetIds} /> : null}
{expandedJobId === job.id ? <JobDetails job={job} now={durationNow} outputAssetIds={outputAssetIds} isAdmin={isAdmin} /> : null}
</article>
))}
{!jobs.length ? <div className="callout">暂无生成任务。提交图片或视频生成后会保留任务历史。</div> : null}
@@ -316,7 +319,7 @@ export function AssetManager({ initialView = "assets", initialTaskId }: AssetMan
);
}
function JobDetails({ job, now, outputAssetIds }: { job: GenerationJob; now: number; outputAssetIds: Set<string> }) {
function JobDetails({ job, now, outputAssetIds, isAdmin }: { job: GenerationJob; now: number; outputAssetIds: Set<string>; isAdmin: boolean }) {
return (
<div className="job-detail-panel">
<dl className="job-detail-grid">
@@ -360,13 +363,8 @@ function JobDetails({ job, now, outputAssetIds }: { job: GenerationJob; now: num
<dt>扣费</dt>
<dd>{billingLabel(job)}</dd>
</div>
{job.error ? (
<div>
<dt>错误</dt>
<dd>{job.error.message}</dd>
</div>
) : null}
</dl>
<GenerationError job={job} isAdmin={isAdmin} providerName={providerLabel(job.provider)} />
{job.prompt ? (
<div className="job-prompt-detail">
<span className="muted compact-hint">提示词</span>
+175 -57
View File
@@ -5,6 +5,9 @@ import { Check, CircleDollarSign, Download, Film, ImageIcon, ImagePlus, Info, La
import clsx from "clsx";
import { clampPage, pageItems, Pagination } from "@/components/pagination";
import { SeedreamWorkspace } from "@/components/seedream-workspace";
import { CreationBalance } from "@/components/creation-balance";
import { GenerationError } from "@/components/generation-error";
import { useBrowserAuth } from "@/components/browser-auth";
import { imageSizePresets, imageDimensionsForEngine, imageSizePresetFromDimensions, type ImageSizePreset } from "@/lib/image-size-presets";
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, configuredImageModel, evolinkModelCapability, imageModelAfterHealth, imageModelFromJob, imageModelLabel, templateImageModel } from "@/lib/image-models";
import { SeedreamLayerResultViewer, type SeedreamLayerOutput } from "@/components/seedream-layer-result-viewer";
@@ -44,6 +47,7 @@ import {
type SeedreamCreationMode
} from "@/lib/seedream/creation";
import { hasCompleteSeedreamLayerMetadata } from "@/lib/seedream/layer-composition";
import { BAILIAN_27_VIDEO_MODEL, BAILIAN_30_VIDEO_MODEL, BAILIAN_VIDEO_MODELS, videoImageFileError, videoInputHint, videoMaterialError, type VideoInputMode } from "@/lib/video-input";
import type { BillingQuote } from "@/lib/types";
type GenerateMode = "image" | "video";
@@ -191,6 +195,7 @@ const defaultTemplateForm: TemplateForm = {
};
export function CreateStudio({ initialMode = "image" }: { initialMode?: GenerateMode }) {
const { isAdmin } = useBrowserAuth();
const [mode, setMode] = useState<GenerateMode>(initialMode);
const [promptByMode, setPromptByMode] = useState<Record<GenerateMode, string>>({
image: "",
@@ -218,8 +223,15 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
const [templateRestoreState, setTemplateRestoreState] = useState<TemplateConsoleSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [billingQuote, setBillingQuote] = useState<BillingQuote | null>(null);
const [billingQuoteResolved, setBillingQuoteResolved] = useState(false);
const [billingQuoteLoading, setBillingQuoteLoading] = useState(false);
const [billingQuoteError, setBillingQuoteError] = useState<string | null>(null);
const [billingQuoteRetry, setBillingQuoteRetry] = useState(0);
const [balanceRefreshVersion, setBalanceRefreshVersion] = useState(0);
const balanceRefreshKey = useMemo(() => JSON.stringify([
balanceRefreshVersion,
recentJobs.map((job) => [job.id, job.status, job.updatedAt, job.billing?.status, job.billing?.settlementStatus, job.billing?.amountFen])
]), [recentJobs, balanceRefreshVersion]);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
@@ -241,6 +253,8 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
const [seedreamLayerAnnotations, setSeedreamLayerAnnotations] = useState<SeedreamAnnotation[]>([]);
const [videoEngine, setVideoEngine] = useState<VideoGenerateEngine>("bailian");
const [videoModel, setVideoModel] = useState(SEEDANCE_20_MODEL);
const [bailianVideoModel, setBailianVideoModel] = useState<string>(BAILIAN_27_VIDEO_MODEL);
const [videoInputMode, setVideoInputMode] = useState<VideoInputMode>("frames");
const [videoRatio, setVideoRatio] = useState("9:16");
const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT);
const [videoResolution, setVideoResolution] = useState("720p");
@@ -260,7 +274,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
const evolinkModelChosenRef = useRef(false);
const generateMode: GenerateMode = mode === "video" ? "video" : "image";
const pasteContextKey = [generateMode, imageCreationMode, imageEngine, evolinkModel, videoEngine, videoModel, activeTemplateId, templateEditorOpen, previewTemplate?.id, taskDetailJobId].join("|");
const pasteContextKey = [generateMode, imageCreationMode, imageEngine, evolinkModel, videoEngine, videoModel, bailianVideoModel, videoInputMode, activeTemplateId, templateEditorOpen, previewTemplate?.id, taskDetailJobId].join("|");
const pasteContextRef = useRef(pasteContextKey);
pasteContextRef.current = pasteContextKey;
const prompt = promptByMode[generateMode];
@@ -302,8 +316,12 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
);
const specializedImageMode = generateMode === "image" && imageCreationMode !== "basic";
const minimaxVideoMode = generateMode === "video" && videoEngine === "minimax";
const minimaxFirstFrameSelected = minimaxVideoMode && materials.length === 1 && materials[0]?.type === "image";
const minimaxMaterialsValid = !minimaxVideoMode || materials.length <= 1 && materials.every((material) => material.type === "image");
const effectiveVideoInputMode: VideoInputMode = videoEngine === "seedance"
? "reference"
: videoEngine === "bailian" && bailianVideoModel === BAILIAN_27_VIDEO_MODEL ? "frames" : videoInputMode;
const videoSelection = { engine: videoEngine, model: videoEngine === "seedance" ? videoModel : videoEngine === "bailian" ? bailianVideoModel : "MiniMax-H3", inputMode: effectiveVideoInputMode };
const videoInputError = generateMode === "video" ? videoMaterialError(videoSelection, materials) : null;
const minimaxFirstFrameSelected = minimaxVideoMode && effectiveVideoInputMode === "frames" && materials.length === 1 && materials[0]?.type === "image";
const unsupportedSpecializedPlaceholders = useMemo(
() => specializedImageMode ? materialPlaceholders.filter((placeholder) => (
placeholder.type !== "image"
@@ -336,11 +354,20 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
&& unsupportedSpecializedPlaceholders.length === 0
&& missingMaterialPlaceholders.length === 0
&& missingAnnotationPlaceholders.length === 0
&& minimaxMaterialsValid;
const submitDisabled = busy || uploading || !generationInputReady;
const submitTitle = uploading ? "素材上传或导入中,请稍候"
: !minimaxMaterialsValid
? "MiniMax H3 第一版仅支持 0 或 1 张图片素材"
&& !videoInputError;
const videoSubmitBlockReason = generateMode !== "video" ? null
: uploading ? "素材正在上传或导入,请稍候。"
: busy ? "任务正在提交,请稍候。"
: videoInputError
? videoInputError
: !prompt.trim() ? "请先填写视频内容提示词。"
: missingMaterialPlaceholders.length ? `请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}。`
: billingQuoteError ? `暂时无法生成:${billingQuoteError}`
: billingQuoteLoading || !billingQuoteResolved ? "正在计算费用,请稍候。" : null;
const submitDisabled = busy || uploading || !generationInputReady || Boolean(videoSubmitBlockReason);
const submitTitle = videoSubmitBlockReason || (uploading ? "素材上传或导入中,请稍候"
: videoInputError
? videoInputError
: unsupportedSpecializedPlaceholders.length
? `交互编辑和图层拆分不支持 ${unsupportedSpecializedPlaceholders.map((placeholder) => placeholder.token).join("、")}`
: missingMaterialPlaceholders.length
@@ -351,7 +378,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
? "请先选择一张底图"
: annotationRequired
? "请先框选或点选编辑位置"
: submitActionLabel(generateMode, imageCreationMode);
: submitActionLabel(generateMode, imageCreationMode));
const visibleMaterials = pageItems(materials, materialPage, MATERIAL_PAGE_SIZE);
const materialPageOffset = (clampPage(materialPage, materials.length, MATERIAL_PAGE_SIZE) - 1) * MATERIAL_PAGE_SIZE;
const mentionSuggestions = useMemo(() => {
@@ -448,10 +475,17 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
if (videoCapability.engine === "seedance") {
const nextModel = normalizeSeedanceModel(videoCapability.reqKey);
setVideoModel(nextModel);
setVideoInputMode("reference");
setVideoDuration((duration) => clampVideoDuration(duration, VIDEO_DURATION_DEFAULT, { allowAuto: false, model: nextModel }));
} else if (videoCapability.engine === "minimax") {
setVideoInputMode("frames");
setVideoDuration((duration) => Math.min(15, Math.max(4, duration)));
setVideoResolution("768P");
} else {
const nextModel = videoCapability.reqKey === BAILIAN_30_VIDEO_MODEL ? BAILIAN_30_VIDEO_MODEL : BAILIAN_27_VIDEO_MODEL;
setBailianVideoModel(nextModel);
setVideoInputMode(nextModel === BAILIAN_30_VIDEO_MODEL ? "reference" : "frames");
setVideoDuration((duration) => Math.min(nextModel === BAILIAN_30_VIDEO_MODEL ? 30 : 15, Math.max(2, duration)));
}
}
})
@@ -503,9 +537,16 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
useEffect(() => {
let active = true;
if (generateMode === "video" && generationInputReady) {
setBillingQuote(null);
setBillingQuoteResolved(false);
setBillingQuoteLoading(true);
setBillingQuoteError(null);
}
const timer = window.setTimeout(async () => {
if (!generationInputReady) {
if (active) setBillingQuote(null);
if (active) setBillingQuoteResolved(false);
if (active) setBillingQuoteError(null);
if (active) setBillingQuoteLoading(false);
return;
@@ -520,18 +561,22 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
});
const payload = await response.json().catch(() => ({}));
if (active) {
if (response.ok && payload.quote) {
setBillingQuote(payload.quote as BillingQuote);
if (response.ok && (payload.quote === null || payload.quote)) {
setBillingQuote(payload.quote as BillingQuote | null);
setBillingQuoteResolved(true);
setBillingQuoteError(null);
} else {
setBillingQuote(null);
setBillingQuoteError(typeof payload.error === "string" ? payload.error : "计费估算暂不可用,请检查服务配置");
setBillingQuoteResolved(false);
const reason = typeof payload.error === "string" ? payload.error : "该模型暂不可用,请联系管理员配置或切换模型。";
setBillingQuoteError(generateMode === "video" ? friendlyVideoServiceError(reason) : reason);
}
}
} catch {
if (active) {
setBillingQuote(null);
setBillingQuoteError("计费估算暂不可用,请检查后端服务配置");
setBillingQuoteResolved(false);
setBillingQuoteError("暂时无法连接服务,请稍后重试;若持续发生,请联系管理员。");
}
} finally {
if (active) setBillingQuoteLoading(false);
@@ -541,7 +586,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
active = false;
window.clearTimeout(timer);
};
}, [generateMode, imageCreationMode, imageEngine, evolinkModel, imageSize.height, imageSize.width, seedreamInteractiveSourceKeys, seedreamRequestAnnotations, seedreamLayerSize, seedreamOptimizeMode, seedreamOutputFormat, seedreamSize, seedreamSourceKey, selectedEvolinkQuality.quality, selectedJimengInfluence.scale, videoDuration, videoEngine, videoModel, videoRatio, videoResolution, materials, generationInputReady, prompt]);
}, [generateMode, imageCreationMode, imageEngine, evolinkModel, imageSize.height, imageSize.width, seedreamInteractiveSourceKeys, seedreamRequestAnnotations, seedreamLayerSize, seedreamOptimizeMode, seedreamOutputFormat, seedreamSize, seedreamSourceKey, selectedEvolinkQuality.quality, selectedJimengInfluence.scale, videoDuration, videoEngine, videoModel, bailianVideoModel, videoInputMode, videoRatio, videoResolution, materials, generationInputReady, prompt, billingQuoteRetry]);
async function loadImageTemplates(isActive: () => boolean = () => true) {
setTemplatesLoading(true);
@@ -679,7 +724,10 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
method: "POST",
body: formData
});
const payload = await response.json();
const payload = await response.json().catch(() => ({}));
if (response.status === 413) {
throw new Error("本次素材总大小超出平台单次上传限制;请分批上传,或压缩文件后重试。");
}
if (!response.ok) throw new Error(payload.error || "上传失败");
if (!Array.isArray(payload.assets) || payload.assets.length !== files.length) {
throw new Error("上传结果不完整,请刷新素材后重试。");
@@ -715,19 +763,22 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
}
if (selectedSeedreamSource && !window.confirm("替换当前待拆分图片吗?当前标注会清空,原图片仍保留在素材中。")) return;
}
} else if (minimaxVideoMode) {
options.maxFiles = 1;
options.replaceAll = true;
if (materials.length && !window.confirm("MiniMax H3 只支持一张首帧图,是否替换当前素材?")) return;
} else if ((generateMode === "image" && imageEngine === "seedream") || (generateMode === "video" && videoEngine === "bailian")) {
const limit = generateMode === "image" ? 10 : 2;
} else if (generateMode === "image" && imageEngine === "seedream") {
const limit = 10;
const remaining = Math.max(0, limit - materials.filter((material) => material.type === "image").length);
if (!remaining) {
setError(generateMode === "image" ? "Seedream 5.0 Pro 最多支持 10 张参考图,请先移除图片。" : "百炼图生视频最多支持 2 张图片,请先移除图片。");
setError("Seedream 5.0 Pro 最多支持 10 张参考图,请先移除图片。");
return;
}
options.maxFiles = remaining;
}
if (generateMode === "video") {
const nextInputError = videoMaterialError(videoSelection, [...materials, ...Array.from({ length: imageURL ? 1 : files.length }, () => ({ type: "image" as const }))]);
if (nextInputError) {
setError(nextInputError);
return;
}
}
const textarea = event.target === promptRef.current ? promptRef.current : null;
options.paste = {
mode: generateMode,
@@ -784,6 +835,22 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
setError("Seedream 5.0 Pro 图层拆分只支持 PNG 或 JPEG 输入图片。");
return;
}
if (generateMode === "video") {
const incoming = selectedFiles.map((file) => ({ type: file.type.startsWith("video/") ? "video" as const : file.type.startsWith("audio/") ? "audio" as const : "image" as const }));
const nextInputError = videoMaterialError(videoSelection, [...materialsRef.current, ...incoming]);
if (nextInputError) {
setError(nextInputError);
return;
}
for (const file of selectedFiles) {
if (!file.type.startsWith("image/") && !/\.(jpe?g|png|webp|heic|heif|bmp)$/i.test(file.name)) continue;
const fileError = videoImageFileError(videoSelection, file);
if (fileError) {
setError(fileError);
return;
}
}
}
await receiveMaterialAssets(() => uploadAssetFiles(selectedFiles), options, allFiles.length);
}
@@ -821,6 +888,10 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
}
next.push(material);
});
if (generateMode === "video") {
const nextInputError = videoMaterialError(videoSelection, next);
if (nextInputError) throw new Error(nextInputError);
}
setMaterials(next);
if (options.paste?.insertReferences) {
const pasted = options.paste;
@@ -1263,11 +1334,12 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
kind: "video",
capability: "video.generate",
engine: videoEngine,
...(videoEngine === "seedance" ? { model: videoModel } : {}),
...(videoEngine === "seedance" ? { model: videoModel } : videoEngine === "bailian" ? { model: bailianVideoModel } : {}),
prompt,
materials,
settings: {
...(videoEngine === "seedance" || videoEngine === "minimax"
inputMode: effectiveVideoInputMode,
...(videoEngine === "seedance" || videoEngine === "minimax" || (videoEngine === "bailian" && bailianVideoModel === BAILIAN_30_VIDEO_MODEL)
? { ratio: videoEngine === "minimax" && minimaxFirstFrameSelected ? "adaptive" : videoRatio }
: {}),
duration: videoDuration,
@@ -1321,17 +1393,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
if (generateMode === "image" && imageCreationMode === "interactive" && selectedSeedreamSources.length > 10) {
throw new Error("Seedream 5.0 Pro 交互编辑最多支持 10 张输入图片。");
}
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 张首尾帧图。");
}
}
if (generateMode === "video" && videoEngine === "minimax") {
if (materials.length > 1 || materials.some((material) => material.type !== "image")) {
throw new Error("MiniMax H3 第一版支持纯文字生成,或只上传 1 张图片作为首帧。");
}
}
if (videoInputError) throw new Error(videoInputError);
const markedMaterials = generateMode === "image" && imageCreationMode !== "basic"
? await prepareMarkedSeedreamMaterials()
: undefined;
@@ -1342,7 +1404,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
body: JSON.stringify(body)
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "提交生成失败");
if (!response.ok) throw new Error(generateMode === "video" ? friendlyVideoServiceError(payload.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)));
@@ -1361,6 +1423,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
setBalanceRefreshVersion((value) => value + 1);
}
}
@@ -1378,6 +1441,10 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
</button>
</div>
<div className="toolbar create-actions">
{generateMode === "video" && videoSubmitBlockReason ? (
<span className="video-submit-reason" role="status">{videoSubmitBlockReason}</span>
) : null}
{generateMode === "video" && !videoSubmitBlockReason && error ? <span className="video-submit-reason" role="alert">{error}</span> : null}
<button
className="button primary create-submit-button"
type="button"
@@ -1456,9 +1523,12 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
) : null}
</div>
</aside>
<section className="panel create-workbench-panel create-center-panel">
<section className={clsx("panel create-workbench-panel create-center-panel", generateMode === "video" && "video-create-center")}>
<div className="create-center-head">
<h2>制作中心</h2>
<div className="create-center-title-row">
<h2>制作中心</h2>
<CreationBalance refreshKey={balanceRefreshKey} estimatedAmountFen={!billingQuoteLoading && !billingQuoteError ? billingQuote?.amountFen : undefined} />
</div>
{renderCreateModeBar()}
</div>
<div className="create-workbench-grid">
@@ -1518,16 +1588,14 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
{uploading ? <Loader2 className="spin" size={18} /> : <Upload size={18} />}
<input
type="file"
multiple={minimaxVideoMode ? false : !specializedImageMode || imageCreationMode === "interactive"}
accept={minimaxVideoMode ? "image/*" : specializedImageMode ? imageCreationMode === "layers" ? "image/png,image/jpeg" : "image/*" : materialUploadAccept}
multiple={!specializedImageMode || imageCreationMode === "interactive"}
accept={generateMode === "video" && videoEngine !== "seedance" ? "image/*" : specializedImageMode ? imageCreationMode === "layers" ? "image/png,image/jpeg" : "image/*" : materialUploadAccept}
disabled={uploading || busy}
onChange={(event) => {
void uploadFiles(event.target.files, minimaxVideoMode
? { expectedType: "image", maxFiles: 1, replaceAll: true }
: specializedImageMode ? {
void uploadFiles(event.target.files, specializedImageMode ? {
expectedType: "image",
seedreamSourceMode: imageCreationMode === "interactive" ? "append" : "single"
} : {});
} : generateMode === "video" && videoEngine !== "seedance" ? { expectedType: "image" } : {});
event.currentTarget.value = "";
}}
/>
@@ -1585,6 +1653,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
</div>
<small>{uploading ? "素材上传或导入中,请稍候…" : "支持 Ctrl+V / ⌘+V 粘贴截图、图片或图片下载地址;链接确认后导入,文字可照常粘贴。"}</small>
{generateMode === "video" ? (
<small className="video-material-number-hint">上传后会自动标记为 @图片1、@图片2……,在提示词中点击或输入编号即可指定每张图的用途。</small>
) : null}
{unsupportedSpecializedPlaceholders.length ? (
<div className="specialized-prompt-warning" role="alert">
@@ -1664,7 +1735,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
) : null}
</div>
<div className="generation-settings-row">
<div className={clsx("generation-settings-row", generateMode === "video" && "video-settings-row")}>
<div className="inline-settings">
{generateMode === "image" ? (
<>
@@ -1754,8 +1825,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
? "bailian"
: event.target.value === "minimax" ? "minimax" : "seedance";
setVideoEngine(nextEngine);
setVideoInputMode(nextEngine === "seedance" ? "reference" : nextEngine === "bailian" && bailianVideoModel === BAILIAN_30_VIDEO_MODEL ? "reference" : "frames");
setVideoDuration((duration) => nextEngine === "bailian"
? Math.min(15, Math.max(2, duration))
? Math.min(bailianVideoModel === BAILIAN_30_VIDEO_MODEL ? 30 : 15, Math.max(2, duration))
: nextEngine === "minimax"
? Math.min(15, Math.max(4, duration))
: clampVideoDuration(duration, VIDEO_DURATION_DEFAULT, { allowAuto: false, model: videoModel }));
@@ -1779,7 +1851,30 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
</select>
</div>
) : null}
{videoEngine === "seedance" || videoEngine === "minimax" ? (
{videoEngine === "bailian" ? (
<div className="field inline-field">
<label htmlFor="bailianVideoModel">百炼模型</label>
<select id="bailianVideoModel" value={bailianVideoModel} onChange={(event) => {
const nextModel = event.target.value === BAILIAN_30_VIDEO_MODEL ? BAILIAN_30_VIDEO_MODEL : BAILIAN_27_VIDEO_MODEL;
setBailianVideoModel(nextModel);
setVideoInputMode(nextModel === BAILIAN_30_VIDEO_MODEL ? "reference" : "frames");
setVideoDuration((duration) => Math.min(nextModel === BAILIAN_30_VIDEO_MODEL ? 30 : 15, Math.max(2, duration)));
setVideoResolution((resolution) => nextModel === BAILIAN_27_VIDEO_MODEL && resolution === "480p" ? "720p" : resolution);
}}>
{BAILIAN_VIDEO_MODELS.map((model) => <option key={model.id} value={model.id}>{model.label}</option>)}
</select>
</div>
) : null}
{(videoEngine === "minimax" || videoEngine === "bailian" && bailianVideoModel === BAILIAN_30_VIDEO_MODEL) ? (
<div className="field inline-field">
<label htmlFor="videoInputMode">图片用法</label>
<select id="videoInputMode" value={effectiveVideoInputMode} onChange={(event) => setVideoInputMode(event.target.value === "reference" ? "reference" : "frames")}>
<option value="reference">多图参考</option>
<option value="frames">{videoEngine === "minimax" ? "首帧" : "首尾帧"}</option>
</select>
</div>
) : null}
{videoEngine === "seedance" || videoEngine === "minimax" || videoEngine === "bailian" && bailianVideoModel === BAILIAN_30_VIDEO_MODEL ? (
<div className="field inline-field">
<label htmlFor="videoRatio">比例</label>
<select id="videoRatio" value={videoEngine === "minimax" && minimaxFirstFrameSelected ? "adaptive" : videoRatio} disabled={videoEngine === "minimax" && minimaxFirstFrameSelected} onChange={(event) => setVideoRatio(event.target.value)}>
@@ -1794,12 +1889,14 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
<select
id="videoDuration"
value={videoDuration}
onChange={(event) => setVideoDuration(videoEngine === "minimax"
onChange={(event) => setVideoDuration(videoEngine === "bailian"
? Math.min(bailianVideoModel === BAILIAN_30_VIDEO_MODEL ? 30 : 15, Math.max(2, Number(event.target.value)))
: videoEngine === "minimax"
? Math.min(15, Math.max(4, Number(event.target.value)))
: clampVideoDuration(event.target.value, videoDuration, { allowAuto: false, model: videoModel }))}
>
{(videoEngine === "bailian"
? Array.from({ length: 14 }, (_, index) => index + 2)
? Array.from({ length: (bailianVideoModel === BAILIAN_30_VIDEO_MODEL ? 30 : 15) - 1 }, (_, index) => index + 2)
: videoEngine === "minimax"
? Array.from({ length: 12 }, (_, index) => index + 4)
: seedanceDurationOptions(videoModel)).map((seconds) => <option key={seconds} value={seconds}>{seconds} 秒</option>)}
@@ -1808,7 +1905,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
<div className="field inline-field">
<label htmlFor="videoResolution">清晰度</label>
<select id="videoResolution" value={videoResolution} onChange={(event) => setVideoResolution(event.target.value)}>
{(videoEngine === "bailian" ? ["720p", "1080p"] : videoEngine === "minimax" ? minimaxVideoResolutions : VIDEO_RESOLUTIONS).map((resolution) => <option key={resolution} value={resolution}>{resolution}</option>)}
{(videoEngine === "bailian" ? bailianVideoModel === BAILIAN_30_VIDEO_MODEL ? ["480p", "720p", "1080p"] : ["720p", "1080p"] : videoEngine === "minimax" ? minimaxVideoResolutions : VIDEO_RESOLUTIONS).map((resolution) => <option key={resolution} value={resolution}>{resolution}</option>)}
</select>
</div>
</>
@@ -1816,6 +1913,21 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
</div>
<BillingEstimate quote={billingQuote} loading={billingQuoteLoading} error={billingQuoteError} reserved={generateMode === "image" && imageCreationMode === "layers"} />
</div>
{generateMode === "video" ? (
<div className="video-input-guidance">
<p><Info size={16} aria-hidden="true" />{videoInputHint(videoSelection)}</p>
{videoInputError ? <p className="video-input-problem" role="alert">{videoInputError}</p> : null}
{billingQuoteError ? <button className="video-quote-retry" type="button" onClick={() => setBillingQuoteRetry((count) => count + 1)}>重新估算费用</button> : null}
{(videoEngine === "minimax" || videoEngine === "bailian" && bailianVideoModel === BAILIAN_30_VIDEO_MODEL) ? (
<details>
<summary>参考图格式要求</summary>
{videoEngine === "minimax"
? <p>MiniMax H3:JPG、PNG、WEBP、HEIC、HEIF;模型单张上限 30 MB,宽高均为 256–5760 像素,宽高比 0.4–2.5。上传还受平台单次容量限制;多张图可分批上传。</p>
: <p>Wan 3.0:JPG、PNG、BMP、WEBP;模型单张上限 20 MB,宽高均为 240–8000 像素。PNG 需为不透明图片;上传还受平台单次容量限制,多张图可分批上传。</p>}
</details>
) : null}
</div>
) : null}
</div>
{templateEditorOpen ? (
<div className="template-editor-backdrop" role="dialog" aria-modal="true" aria-label={editingTemplateId ? "编辑模板" : "添加模板"}>
@@ -2148,6 +2260,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
job={selectedTask}
assets={taskAssetById}
now={durationNow}
isAdmin={isAdmin}
onClose={() => setTaskDetailJobId(null)}
/>
) : null}
@@ -2216,11 +2329,13 @@ function TaskDetailModal({
job,
assets,
now,
isAdmin,
onClose
}: {
job: GenerationJob;
assets: Map<string, Asset>;
now: number;
isAdmin: boolean;
onClose: () => void;
}) {
const outputAssets = job.outputAssetIds
@@ -2279,7 +2394,7 @@ function TaskDetailModal({
) : (
<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>
<span>{isPendingTask(job) ? "任务完成后,结果会显示在这里" : "该任务没有可下载的结果"}</span>
</div>
)}
</section>
@@ -2359,12 +2474,7 @@ function TaskDetailModal({
</section>
</div>
{job.error ? (
<section className="task-detail-error" role="alert">
<strong>任务信息</strong>
<span>{job.error.message}</span>
</section>
) : null}
<GenerationError job={job} isAdmin={isAdmin} providerName={providerLabel(job.provider)} />
</div>
</div>
</div>
@@ -2984,3 +3094,11 @@ function shortTypeName(type: MaterialKind) {
if (type === "audio") return "音频";
return "图片";
}
function friendlyVideoServiceError(reason: string): string {
if (/余额|balance|图片|素材|时长|分辨率|比例|超限|过大|格式|上传/i.test(reason)) return reason;
if (/not configured|api.?key|environment|env\b|unauthorized|forbidden|permission|price rule|pricing|未配置|无权限|未开通|模型不可用|model.*(unavailable|unsupported|not found)/i.test(reason)) {
return "该模型暂不可用,请联系管理员检查模型权限或价格配置,或切换其他模型。";
}
return reason;
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2, RefreshCw, WalletCards } from "lucide-react";
import clsx from "clsx";
import { useBrowserAuth } from "@/components/browser-auth";
import { formatBillingAmount } from "@/lib/billing";
import { fetchOrganizationBalance, type OrganizationBalance } from "@/lib/client/organization-balance";
type BalanceState = {
scope: string;
balance: OrganizationBalance | null;
loading: boolean;
error: string | null;
};
export function CreationBalance({ refreshKey, estimatedAmountFen }: { refreshKey: string; estimatedAmountFen?: number }) {
const { user, status, isSuperAdmin } = useBrowserAuth();
const organizationId = user?.organizationId || "";
const scope = `${user?.id || ""}:${organizationId}:${isSuperAdmin}`;
const [retry, setRetry] = useState(0);
const [state, setState] = useState<BalanceState>({ scope: "", balance: null, loading: false, error: null });
useEffect(() => {
if (status !== "ready" || isSuperAdmin || !organizationId) return;
let active = true;
let controller: AbortController | null = null;
const refresh = async () => {
controller?.abort();
const request = new AbortController();
controller = request;
setState((previous) => ({ scope, balance: previous.scope === scope ? previous.balance : null, loading: true, error: null }));
try {
const balance = await fetchOrganizationBalance(organizationId, request.signal);
if (active && !request.signal.aborted) setState({ scope, balance, loading: false, error: null });
} catch (cause) {
if (active && !request.signal.aborted) {
setState({ scope, balance: null, loading: false, error: cause instanceof Error ? cause.message : "余额读取失败,请重试。" });
}
}
};
const refreshWhenVisible = () => {
if (document.visibilityState === "visible") void refresh();
};
void refresh();
const timer = window.setInterval(refreshWhenVisible, 30000);
window.addEventListener("focus", refreshWhenVisible);
document.addEventListener("visibilitychange", refreshWhenVisible);
return () => {
active = false;
controller?.abort();
window.clearInterval(timer);
window.removeEventListener("focus", refreshWhenVisible);
document.removeEventListener("visibilitychange", refreshWhenVisible);
};
}, [scope, organizationId, status, isSuperAdmin, refreshKey, retry]);
if (status !== "ready") return <span className="creation-balance muted">正在读取余额…</span>;
if (isSuperAdmin) {
return <a className="creation-balance creation-balance-exempt" href="/billing" title="超级管理员生成任务会记录费用,但不扣减组织余额;点击查看计费中心。"><WalletCards size={15} aria-hidden="true" />超管不计额度</a>;
}
if (!organizationId) {
return <span className="creation-balance creation-balance-warning" title="当前账号未绑定组织,请联系管理员绑定后使用共享余额。"><WalletCards size={15} aria-hidden="true" />未绑定组织</span>;
}
const current = state.scope === scope ? state : null;
const balance = current?.balance;
const loading = !current || current.loading;
const error = current?.error;
const insufficient = balance && (balance.wallet.balanceFen <= 0 || estimatedAmountFen !== undefined && balance.wallet.balanceFen < estimatedAmountFen);
const amount = balance ? formatBillingAmount(balance.wallet.balanceFen) : error ? "暂不可用" : "读取中…";
const description = error || (insufficient
? "组织余额不足,请联系管理员充值;点击查看余额明细。"
: `${balance?.organization.name || user?.organizationName || "当前组织"}的共享可用余额,图片和视频共用;点击查看余额明细。`);
return (
<div className={clsx("creation-balance", (insufficient || error) && "creation-balance-warning")}>
<a className="creation-balance-summary" href="/billing" title={description}>
<WalletCards size={15} aria-hidden="true" />
<span>组织余额</span>
<strong aria-live="polite">{amount}</strong>
</a>
<button type="button" className="creation-balance-refresh" onClick={() => setRetry((value) => value + 1)} disabled={loading} aria-label={error ? "重试读取组织余额" : "刷新组织余额"} title={error ? "读取失败,点击重试" : "刷新组织余额"}>
{loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
</button>
{insufficient ? <span className="creation-balance-note">余额不足,请联系管理员充值</span> : null}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import type { GenerationJob } from "@/lib/types";
import { generationErrorMessage, providerErrorDiagnostic } from "@/lib/client/provider-error";
export function GenerationError({ job, isAdmin, providerName }: {
job: GenerationJob;
isAdmin: boolean;
providerName: string;
}) {
if (!job.error) return null;
const diagnostic = isAdmin ? providerErrorDiagnostic(job) : null;
const isFailed = job.status === "failed" || job.status === "expired";
const entries = diagnostic ? [
["服务商", providerName],
["HTTP 状态", diagnostic.status],
["错误码", diagnostic.code],
["请求 ID", diagnostic.requestId],
["阶段", diagnostic.phase === "submit" ? "提交" : diagnostic.phase === "query" ? "查询" : undefined],
["错误类型", diagnostic.kind],
["服务商说明", diagnostic.detail]
].filter((entry): entry is [string, string | number] => entry[1] !== undefined && entry[1] !== "") : [];
return (
<section className={`generation-error ${isFailed ? "generation-error-failed" : "generation-error-info"}`} role={isFailed ? "alert" : "status"}>
<strong>{isFailed ? "失败原因" : "任务提示"}</strong>
<p>{generationErrorMessage(job)}</p>
{isAdmin && entries.length > 0 ? (
<details className="generation-error-details">
<summary>错误详情</summary>
<dl>
{entries.map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{value}</dd></div>)}
</dl>
</details>
) : null}
</section>
);
}