若干更新迭代
This commit is contained in:
1 parent
deb9cc5bcf
commit
4ac786aafb
64 files changed
+3251
-291
No files matched your search
@@ -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,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
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user