Compare commits

..

1 Commits

Author SHA1 Message Date
stringadmin 6cc81e3804 Improve generation task client errors 2026-06-04 21:07:48 +08:00
48 changed files with 185 additions and 4032 deletions
+1 -5
View File
@@ -48,7 +48,6 @@ const CommunityCaseAddPage = lazy(() => import("./features/community-review/Comm
const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage"));
const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage"));
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
const HomePage = lazy(() => import("./features/home/HomePage"));
const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage"));
@@ -111,7 +110,6 @@ const VIEW_KEYS = new Set<WebViewKey>([
"resolutionUpscale",
"watermarkRemoval",
"subtitleRemoval",
"dialogGenerator",
"digitalHuman",
"avatarConsole",
"characterMix",
@@ -125,7 +123,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
"not-found",
]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]);
function normalizeViewKey(rawView: string): WebViewKey {
const normalized =
@@ -1161,8 +1159,6 @@ function App() {
onSelectView={handleSetView}
/>
);
case "dialogGenerator":
return <DialogGeneratorPage />;
case "report":
return <ReportPage />;
case "providerHealth":
+1 -24
View File
@@ -134,12 +134,6 @@ export interface ChatInput {
temperature?: number;
}
export interface ChatUsage {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}
export interface AiTaskStatus {
taskId: string;
projectId?: string;
@@ -506,7 +500,6 @@ export const aiGenerationClient = {
input: ChatInput,
onChunk: (text: string) => void,
signal?: AbortSignal,
onUsage?: (usage: ChatUsage) => void,
): Promise<void> {
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
@@ -536,24 +529,8 @@ export const aiGenerationClient = {
const payload = line.slice(6).trim();
if (!payload) continue;
try {
const chunk = JSON.parse(payload) as {
delta?: string;
done?: boolean;
error?: string;
usage?: ChatUsage & {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
};
const chunk = JSON.parse(payload) as { delta?: string; done?: boolean; error?: string };
if (chunk.error) throw new Error(chunk.error);
if (chunk.usage) {
onUsage?.({
promptTokens: chunk.usage.promptTokens ?? chunk.usage.prompt_tokens,
completionTokens: chunk.usage.completionTokens ?? chunk.usage.completion_tokens,
totalTokens: chunk.usage.totalTokens ?? chunk.usage.total_tokens,
});
}
if (chunk.delta) onChunk(chunk.delta);
if (chunk.done) return;
} catch (e) {
+5 -79
View File
@@ -4,8 +4,6 @@ export interface ScriptEvalResult {
totalScore: number;
grade: string;
dimensionScores: Record<string, number>;
subScores?: Record<string, Record<string, number>>;
evidence?: Record<string, string[]>;
summary: string;
issues: string[];
highlights: string[];
@@ -14,33 +12,6 @@ export interface ScriptEvalResult {
const MODEL = "qwen3.7-max";
const EVAL_OUTPUT_CONTRACT = `
强制输出 JSON,主维度键名必须严格为:
hook(20), plot(20), character(15), logic(15), visual(15), content(15)。
不要把 dialogue 作为主维度返回;台词对白作为 character/plot/content 的证据和子项分析。
同时返回 subScores 和 evidence
- subScores:每个主维度 3-5 个细分参数,分值按该维度满分拆分。
- evidence:每个主维度 1-3 条具体证据,必须指向场景、台词、设定、冲突或段落。
返回结构:
{
"dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "logic": 数字, "visual": 数字, "content": 数字 },
"subScores": {
"hook": { "openingImpact": 数字, "suspenseChain": 数字, "sceneHook": 数字 },
"plot": { "structure": 数字, "rhythm": 数字, "conflict": 数字, "reversal": 数字 },
"character": { "motivation": 数字, "arc": 数字, "voice": 数字, "relationship": 数字 },
"logic": { "causality": 数字, "worldRules": 数字, "foreshadowing": 数字, "continuity": 数字 },
"visual": { "sceneDetail": 数字, "shotPotential": 数字, "aigcFeasibility": 数字 },
"content": { "theme": 数字, "emotion": 数字, "marketFit": 数字, "originality": 数字 }
},
"evidence": { "hook": ["..."], "plot": ["..."], "character": ["..."], "logic": ["..."], "visual": ["..."], "content": ["..."] },
"summary": "200-300字综合评价",
"issues": ["具体扣分点,带维度和证据", ...],
"highlights": ["具体亮点,带维度和证据", ...],
"suggestions": ["按优先级排列的改稿建议", ...]
}`;
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
【剧本类型识别】
@@ -75,10 +46,10 @@ const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有
const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = {
hook: { maxScore: 20 },
plot: { maxScore: 20 },
character: { maxScore: 15 },
logic: { maxScore: 15 },
character: { maxScore: 18 },
dialogue: { maxScore: 15 },
visual: { maxScore: 15 },
content: { maxScore: 15 },
content: { maxScore: 12 },
};
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
@@ -97,48 +68,6 @@ function extractJson(text: string): unknown {
return JSON.parse(raw);
}
function normalizeScoreValue(value: unknown, maxScore: number): number {
const score = Number(value);
if (!Number.isFinite(score)) return 0;
return Math.max(0, Math.min(maxScore, Math.round(score * 10) / 10));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function normalizeNestedScores(value: unknown): Record<string, Record<string, number>> {
if (!isRecord(value)) return {};
const normalized: Record<string, Record<string, number>> = {};
for (const [dimensionKey, dimension] of Object.entries(DIMENSION_WEIGHTS)) {
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
if (!isRecord(source)) continue;
const entries = Object.entries(source)
.map(([key, score]) => [key, normalizeScoreValue(score, dimension.maxScore)] as const)
.filter(([, score]) => score > 0);
if (entries.length > 0) normalized[dimensionKey] = Object.fromEntries(entries);
}
return normalized;
}
function normalizeEvidence(value: unknown): Record<string, string[]> {
if (!isRecord(value)) return {};
const normalized: Record<string, string[]> = {};
for (const dimensionKey of Object.keys(DIMENSION_WEIGHTS)) {
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
if (!Array.isArray(source)) continue;
const items = source.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3);
if (items.length > 0) normalized[dimensionKey] = items;
}
return normalized;
}
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
@@ -147,7 +76,6 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
model: MODEL,
messages: [
{ role: "system", content: EVAL_SYSTEM_PROMPT },
{ role: "system", content: EVAL_OUTPUT_CONTRACT },
{ role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` },
],
stream: false,
@@ -173,8 +101,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常");
for (const key of Object.keys(DIMENSION_WEIGHTS)) {
const rawValue = key === "logic" ? rawScores.logic ?? rawScores.dialogue : rawScores[key];
dimensionScores[key] = normalizeScoreValue(rawValue, DIMENSION_WEIGHTS[key].maxScore);
const val = Number(rawScores[key] ?? 0);
dimensionScores[key] = Math.max(0, Math.min(DIMENSION_WEIGHTS[key].maxScore, val));
}
const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
@@ -183,8 +111,6 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
totalScore,
grade,
dimensionScores,
subScores: normalizeNestedScores(parsed.subScores),
evidence: normalizeEvidence(parsed.evidence),
summary: String(parsed.summary || ""),
issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [],
highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [],
+4 -38
View File
@@ -1,9 +1,4 @@
import { aiGenerationClient } from "./aiGenerationClient";
import {
buildLocalTimeoutMessage,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../utils/taskLifecycle";
export interface TaskProgressEvent {
taskId: string;
@@ -17,28 +12,16 @@ export interface WaitForTaskOptions {
onProgress?: (event: TaskProgressEvent) => void;
abortRef?: { current: boolean };
timeoutMs?: number;
noProgressTimeoutMs?: number;
startedAt?: number;
kind?: "image" | "video" | "text";
model?: string | null;
operation?: string | null;
}
const POLL_INTERVAL = 3000;
const DEFAULT_TIMEOUT = 30 * 60 * 1000;
export function waitForTask(
taskId: string,
options: WaitForTaskOptions = {},
): Promise<string | null> {
const { onProgress, abortRef } = options;
const timeoutPolicy = getTaskTimeoutPolicy({
kind: options.kind,
model: options.model,
operation: options.operation,
});
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
const startedAt = options.startedAt ?? Date.now();
const { onProgress, abortRef, timeoutMs = DEFAULT_TIMEOUT } = options;
return new Promise((resolve, reject) => {
let settled = false;
@@ -46,8 +29,6 @@ export function waitForTask(
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let sseConnected = false;
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
let lastProgress = 0;
let lastProgressAt = startedAt;
const settle = (fn: () => void) => {
if (settled) return;
@@ -59,7 +40,7 @@ export function waitForTask(
};
timeoutId = setTimeout(
() => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
() => settle(() => reject(new Error("等待任务结果超时,请稍后在任务历史中查看"))),
timeoutMs,
);
@@ -69,11 +50,6 @@ export function waitForTask(
settle(() => resolve(null));
return;
}
const progress = Number(event.progress || 0);
if (progress > lastProgress || event.status === "completed") {
lastProgress = Math.max(lastProgress, progress);
lastProgressAt = Date.now();
}
onProgress?.(event);
if (event.status === "completed") {
settle(() => resolve(event.resultUrl || null));
@@ -100,16 +76,6 @@ export function waitForTask(
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL));
if (settled || abortRef?.current) return;
const timeoutReason = isTaskLocallyTimedOut({
startedAt,
lastProgressAt,
progress: lastProgress,
policy: { ...timeoutPolicy, noProgressTimeoutMs },
});
if (timeoutReason) {
settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))));
return;
}
try {
const task = await aiGenerationClient.getTaskStatus(taskId);
handleUpdate({
@@ -124,7 +90,7 @@ export function waitForTask(
}
}
};
void poll();
poll();
}
});
}
+1 -3
View File
@@ -13,7 +13,6 @@ import {
import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient";
import { toast } from "./toast/toastStore";
import type { ServerConnectionHealth } from "../api/serverConnection";
import { ossAssets } from "../data/ossAssets";
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
@@ -87,7 +86,6 @@ function AppShell({
"imageWorkbench",
"resolutionUpscale",
"digitalHuman",
"dialogGenerator",
"avatarConsole",
"characterMix",
] as WebViewKey[];
@@ -371,7 +369,7 @@ function AppShell({
className="member-button"
type="button"
aria-label={`积分余额 ${displayedBalanceLabel}`}
onClick={() => toast.info("充值功能即将开放,敬请期待")}
onClick={() => setRechargeOpen(true)}
>
<WalletOutlined />
<span className="member-button__label">{displayedBalanceLabel}</span>
-1
View File
@@ -23,7 +23,6 @@ const NAV_ORDER: string[] = [
"resolutionUpscale",
"watermarkRemoval",
"subtitleRemoval",
"dialogGenerator",
"digitalHuman",
"avatarConsole",
"characterMix",
+1 -1
View File
@@ -21,7 +21,7 @@ export const ossAssets = {
},
home: {
backgroundVideo: muban("hero-bg.mp4"),
heroSlides: [oss("static/banners/light2_轮播1.jpg"), oss("static/banners/light2_轮播2.jpg"), oss("static/banners/light2_轮播3.jpg")],
heroSlides: [muban("hero-1.png"), muban("hero-2.png"), muban("hero-3.png")],
features: {
ecommerce: muban("feature-ecommerce.jpg"),
script: muban("feature-script.jpg"),
+10 -59
View File
@@ -182,7 +182,6 @@ import {
} from "./canvasWorkflowDeserialize";
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
import type { CanvasNodeToolbarAction } from "./canvasComponents";
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({
@@ -337,7 +336,6 @@ function CanvasPage({
const [imageFocusNodeId, setImageFocusNodeId] = useState<string | null>(null);
const [imageFocusDraft, setImageFocusDraft] = useState<CanvasImageFocusSelection | null>(null);
const [imageFocusDrag, setImageFocusDrag] = useState<CanvasImageFocusDrag | null>(null);
const [canvasToolModal, setCanvasToolModal] = useState<{ tool: "multiGrid" | "upscale" | "inpaint"; imageNode: CanvasImageNode } | null>(null);
const [stylePickerImageNodeId, setStylePickerImageNodeId] = useState<string | null>(null);
const [stylePickerCases, setStylePickerCases] = useState<CanvasStyleCase[]>([]);
const [stylePickerLoading, setStylePickerLoading] = useState(false);
@@ -2826,7 +2824,7 @@ function CanvasPage({
if (targetPort) {
connectCanvasPorts(connectorDrag.port, targetPort);
} else {
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, -40);
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, 0);
setConnectionDropMenu({
...menuPosition,
originLeft: event.clientX,
@@ -3752,12 +3750,12 @@ function CanvasPage({
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
/>
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
<button type="button" title="缩小" aria-label="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" aria-label="重置缩放" onClick={resetCanvasZoom}>
<button type="button" title="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}>
{Math.round(canvasViewport.zoom * 100)}%
</button>
<button type="button" title="放大" aria-label="放大" onClick={zoomCanvasIn}>+</button>
<button type="button" title="适应视图" aria-label="适应视图" onClick={fitCanvasView}></button>
<button type="button" title="放大" onClick={zoomCanvasIn}>+</button>
<button type="button" title="适应视图" onClick={fitCanvasView}></button>
</div>
{(shouldShowEmptyProjectState || isWaitingForProjects) ? (
<div
@@ -4266,7 +4264,7 @@ function CanvasPage({
setSelectedExistingCategory("");
setSaveAssetOpen(true);
}
if (key === "upscale") setCanvasToolModal({ tool: "upscale", imageNode });
if (key === "upscale") void handleGenerateImageNode(imageNode.id);
}}
moreActions={[
{ key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl },
@@ -4572,42 +4570,16 @@ function CanvasPage({
)}
<button
type="button"
title="多宫格生成"
disabled={!imageNode.imageUrl}
className={imageNodeFocusActive ? "is-active" : ""}
title="框选聚焦区域"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "multiGrid", imageNode });
openImageFocusMode(imageNode);
}}
>
<BarsOutlined /><span></span>
</button>
<button
type="button"
title="图片超分辨率"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "upscale", imageNode });
}}
>
<ThunderboltOutlined /><span></span>
</button>
<button
type="button"
title="局部重绘"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "inpaint", imageNode });
}}
>
<EditOutlined /><span></span>
<BarsOutlined /><span></span>
</button>
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开"></button>
</div>
@@ -5757,27 +5729,6 @@ function CanvasPage({
</section>
</div>
{canvasToolModal && (
<div className="studio-canvas-tool-modal-overlay" onClick={() => setCanvasToolModal(null)}>
<div className="studio-canvas-tool-modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={canvasToolModal.tool === "multiGrid" ? "多宫格" : canvasToolModal.tool === "upscale" ? "超分" : "局部重绘"}>
<header className="studio-canvas-tool-modal__header">
<h3>{canvasToolModal.tool === "multiGrid" ? "多宫格生成" : canvasToolModal.tool === "upscale" ? "图片超分" : "局部重绘"}</h3>
<button type="button" aria-label="关闭" onClick={() => setCanvasToolModal(null)}><CloseOutlined /></button>
</header>
<div className="studio-canvas-tool-modal__body">
{canvasToolModal.tool === "multiGrid" && (
<CanvasMultiGridPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
{canvasToolModal.tool === "upscale" && (
<CanvasUpscalePanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
{canvasToolModal.tool === "inpaint" && (
<CanvasInpaintPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
</div>
</div>
</div>
)}
</WorkspacePageShell>
);
}
-221
View File
@@ -1,221 +0,0 @@
import { useCallback, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { toast } from "../../components/toast/toastStore";
import type { CanvasImageNode } from "./canvasTypes";
interface CanvasToolPanelProps {
imageUrl: string;
imageNode: CanvasImageNode;
onComplete: (resultUrl: string) => void;
}
export function CanvasMultiGridPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [gridMode, setGridMode] = useState<"grid-4" | "grid-9">("grid-4");
const [prompt, setPrompt] = useState("");
const [loading, setLoading] = useState(false);
const cancelRef = useRef(false);
const handleGenerate = useCallback(async () => {
if (!imageUrl) return;
setLoading(true);
cancelRef.current = false;
try {
const { taskId } = await aiGenerationClient.createImageTask({
model: "gpt-image-2",
prompt: prompt || "基于参考图生成多宫格变体",
referenceUrls: [imageUrl],
gridMode,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("多宫格生成完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "多宫格生成失败");
} finally {
setLoading(false);
}
}, [imageUrl, prompt, gridMode, onComplete]);
return (
<div className="studio-canvas-tool-panel">
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"宫格模式"}</label>
<div className="studio-canvas-tool-panel__options">
{([["grid-4", "2×2"], ["grid-9", "3×3"]] as const).map(([value, label]) => (
<button key={value} type="button" className={gridMode === value ? "is-active" : ""} onClick={() => setGridMode(value)}>{label}</button>
))}
</div>
<label className="studio-canvas-tool-panel__label">{"提示词(可选)"}</label>
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述多宫格内容变化" />
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleGenerate}>
{loading ? "生成中..." : "生成多宫格"}
</button>
</div>
</div>
);
}
export function CanvasUpscalePanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [scale, setScale] = useState<"2x" | "4x">("2x");
const [loading, setLoading] = useState(false);
const cancelRef = useRef(false);
const handleUpscale = useCallback(async () => {
if (!imageUrl) return;
setLoading(true);
cancelRef.current = false;
try {
const { taskId } = await aiGenerationClient.createImageSuperResolveTask({
imageUrl,
scale,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("超分完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "超分失败");
} finally {
setLoading(false);
}
}, [imageUrl, scale, onComplete]);
return (
<div className="studio-canvas-tool-panel">
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"放大倍数"}</label>
<div className="studio-canvas-tool-panel__options">
{(["2x", "4x"] as const).map((s) => (
<button key={s} type="button" className={scale === s ? "is-active" : ""} onClick={() => setScale(s)}>{s}</button>
))}
</div>
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleUpscale}>
{loading ? "处理中..." : "开始超分"}
</button>
</div>
</div>
);
}
export function CanvasInpaintPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [prompt, setPrompt] = useState("");
const [brushSize, setBrushSize] = useState(30);
const [loading, setLoading] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawingRef = useRef(false);
const cancelRef = useRef(false);
const initCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
img.src = imageUrl;
}, [imageUrl]);
const getPos = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
};
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawingRef.current) return;
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
const { x, y } = getPos(e);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(255, 0, 0, 0.4)";
ctx.beginPath();
ctx.arc(x, y, brushSize, 0, Math.PI * 2);
ctx.fill();
};
const getMaskDataUrl = (): string => {
const canvas = canvasRef.current!;
const maskCanvas = document.createElement("canvas");
maskCanvas.width = canvas.width;
maskCanvas.height = canvas.height;
const srcCtx = canvas.getContext("2d")!;
const maskCtx = maskCanvas.getContext("2d")!;
const imgData = srcCtx.getImageData(0, 0, canvas.width, canvas.height);
const maskData = maskCtx.createImageData(canvas.width, canvas.height);
for (let i = 0; i < imgData.data.length; i += 4) {
const hasColor = imgData.data[i + 3] > 10;
maskData.data[i] = hasColor ? 255 : 0;
maskData.data[i + 1] = hasColor ? 255 : 0;
maskData.data[i + 2] = hasColor ? 255 : 0;
maskData.data[i + 3] = 255;
}
maskCtx.putImageData(maskData, 0, 0);
return maskCanvas.toDataURL("image/png");
};
const handleInpaint = useCallback(async () => {
if (!imageUrl || !prompt) {
toast.error("请输入重绘提示词");
return;
}
setLoading(true);
cancelRef.current = false;
try {
const maskDataUrl = getMaskDataUrl();
const { taskId } = await aiGenerationClient.createImageEditTask({
imageUrl,
function: "inpaint",
prompt,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("局部重绘完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "局部重绘失败");
} finally {
setLoading(false);
}
}, [imageUrl, prompt, onComplete]);
return (
<div className="studio-canvas-tool-panel studio-canvas-tool-panel--inpaint">
<div className="studio-canvas-tool-panel__canvas-wrap">
<img src={imageUrl} alt="" className="studio-canvas-tool-panel__canvas-bg" />
<canvas
ref={canvasRef}
className="studio-canvas-tool-panel__canvas"
onMouseDown={(e) => { isDrawingRef.current = true; draw(e); }}
onMouseMove={draw}
onMouseUp={() => { isDrawingRef.current = false; }}
onMouseLeave={() => { isDrawingRef.current = false; }}
/>
</div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"画笔大小"}</label>
<input type="range" min={5} max={80} value={brushSize} onChange={(e) => setBrushSize(Number(e.target.value))} />
<label className="studio-canvas-tool-panel__label">{"重绘提示词"}</label>
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述需要重绘区域的内容" />
<div className="studio-canvas-tool-panel__actions">
<button type="button" className="studio-canvas-tool-panel__reset" onClick={initCanvas}>{"清除蒙版"}</button>
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleInpaint}>
{loading ? "处理中..." : "开始重绘"}
</button>
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -251,7 +251,7 @@ export function blobToDataUrl(blob: Blob) {
export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
const resultUrl = await waitForTask(taskId, {
kind: "image",
timeoutMs: 10 * 60 * 1000,
onProgress: (e) => {
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
},
@@ -262,7 +262,7 @@ export async function waitForImageTaskResult(taskId: string, onStatus?: (status:
export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
const resultUrl = await waitForTask(taskId, {
kind: "video",
timeoutMs: 30 * 60 * 1000,
onProgress: (e) => {
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
},
@@ -1,290 +0,0 @@
import { useCallback, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
type DialogStyle = "style1" | "style2" | "style3" | "style4";
interface DialogItem {
id: number;
style: DialogStyle;
x: number;
y: number;
text: string;
color: string;
confirmed: boolean;
}
interface DragState {
id: number;
offsetX: number;
offsetY: number;
}
const dialogStyles: Array<{
key: DialogStyle;
label: string;
description: string;
swatchClass: string;
}> = [
{ key: "style1", label: "白色圆角对话框", description: "适合浅色说明与标注", swatchClass: "is-white" },
{ key: "style2", label: "蓝色气泡对话框", description: "适合角色台词与重点提示", swatchClass: "is-blue" },
{ key: "style3", label: "黄色提示对话框", description: "适合醒目提醒与强调", swatchClass: "is-amber" },
{ key: "style4", label: "灰色简约对话框", description: "适合信息备注与辅助说明", swatchClass: "is-gray" },
];
const textColorOptions = [
{ value: "#ffffff", label: "白色" },
{ value: "#111827", label: "黑色" },
{ value: "#ef4444", label: "红色" },
{ value: "#f59e0b", label: "黄色" },
{ value: "#165dff", label: "蓝色" },
{ value: "#00ff88", label: "绿色" },
];
function DialogGeneratorPage() {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const previewRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragState | null>(null);
const nextIdRef = useRef(0);
const [backgroundUrl, setBackgroundUrl] = useState("");
const [dialogs, setDialogs] = useState<DialogItem[]>([]);
const [selectedTextColor, setSelectedTextColor] = useState(textColorOptions[0].value);
const [activeDragId, setActiveDragId] = useState<number | null>(null);
const handleFile = useCallback((file?: File | null) => {
if (!file || !file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
setBackgroundUrl(reader.result);
}
};
reader.readAsDataURL(file);
}, []);
const addDialog = useCallback((style: DialogStyle) => {
nextIdRef.current += 1;
const id = nextIdRef.current;
setDialogs((current) => [
...current,
{
id,
style,
x: 30 + (id * 25) % 200,
y: 30 + (id * 20) % 150,
text: "",
color: selectedTextColor,
confirmed: false,
},
]);
}, [selectedTextColor]);
const updateDialog = useCallback((id: number, patch: Partial<DialogItem>) => {
setDialogs((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
}, []);
const deleteDialog = useCallback((id: number) => {
setDialogs((current) => current.filter((item) => item.id !== id));
}, []);
const startDrag = useCallback((id: number, clientX: number, clientY: number) => {
const dialogEl = document.querySelector<HTMLElement>(`[data-dialog-id="${id}"]`);
if (!dialogEl) return;
const rect = dialogEl.getBoundingClientRect();
dragRef.current = {
id,
offsetX: clientX - rect.left,
offsetY: clientY - rect.top,
};
setActiveDragId(id);
}, []);
const moveDrag = useCallback((clientX: number, clientY: number) => {
const drag = dragRef.current;
const preview = previewRef.current;
if (!drag || !preview) return;
const dialogEl = document.querySelector<HTMLElement>(`[data-dialog-id="${drag.id}"]`);
if (!dialogEl) return;
const bounds = preview.getBoundingClientRect();
const nextX = Math.max(0, Math.min(clientX - drag.offsetX - bounds.left, bounds.width - dialogEl.offsetWidth));
const nextY = Math.max(0, Math.min(clientY - drag.offsetY - bounds.top, bounds.height - dialogEl.offsetHeight));
updateDialog(drag.id, { x: nextX, y: nextY });
}, [updateDialog]);
const endDrag = useCallback(() => {
dragRef.current = null;
setActiveDragId(null);
}, []);
const handleCanvasMouseMove = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
moveDrag(event.clientX, event.clientY);
}, [moveDrag]);
const handleCanvasTouchMove = useCallback((event: ReactTouchEvent<HTMLDivElement>) => {
const touch = event.touches[0];
if (!touch) return;
moveDrag(touch.clientX, touch.clientY);
}, [moveDrag]);
return (
<section className="dialog-generator-page page-motion">
<div className="dialog-generator-shell">
<aside className="dialog-generator-panel">
<div className="dialog-generator-heading">
<span className="dialog-generator-kicker">Interactive Dialog</span>
<h1></h1>
<p></p>
</div>
<div className="dialog-generator-section">
<h2></h2>
<button
type="button"
className="dialog-generator-drop"
onClick={() => fileInputRef.current?.click()}
onDragOver={(event) => {
event.preventDefault();
}}
onDrop={(event) => {
event.preventDefault();
handleFile(event.dataTransfer.files[0]);
}}
>
<span className="dialog-generator-drop-icon">🖼</span>
<strong></strong>
<small> JPGPNGWEBP </small>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={(event) => handleFile(event.target.files?.[0])}
/>
</div>
<div className="dialog-generator-section">
<h2></h2>
<p className="dialog-generator-hint"></p>
<div className="dialog-generator-color-picker" role="radiogroup" aria-label="文字颜色">
{textColorOptions.map((item) => (
<button
key={item.value}
type="button"
className={`dialog-generator-color${selectedTextColor === item.value ? " is-active" : ""}`}
style={{ "--text-color": item.value } as CSSProperties}
aria-checked={selectedTextColor === item.value}
role="radio"
onClick={() => setSelectedTextColor(item.value)}
>
<span />
<strong>{item.label}</strong>
</button>
))}
</div>
<div className="dialog-generator-style-list">
{dialogStyles.map((item) => (
<button key={item.key} type="button" className="dialog-generator-style" onClick={() => addDialog(item.key)}>
<span className={`dialog-generator-swatch ${item.swatchClass}`} />
<span>
<strong>{item.label}</strong>
<small>{item.description}</small>
</span>
</button>
))}
</div>
</div>
<button type="button" className="dialog-generator-clear" onClick={() => setDialogs([])}>
</button>
</aside>
<main className="dialog-generator-preview-card">
<div className="dialog-generator-preview-head">
<div>
<span>Preview</span>
<h2></h2>
</div>
<p></p>
</div>
<div
ref={previewRef}
className="dialog-generator-preview"
onMouseMove={handleCanvasMouseMove}
onMouseUp={endDrag}
onMouseLeave={endDrag}
onTouchMove={handleCanvasTouchMove}
onTouchEnd={endDrag}
>
{backgroundUrl ? <div className="dialog-generator-image" style={{ backgroundImage: `url(${backgroundUrl})` }} /> : null}
{!backgroundUrl ? (
<div className="dialog-generator-empty">
<span>🖼</span>
<p></p>
</div>
) : null}
{dialogs.map((dialog) => (
<div
key={dialog.id}
data-dialog-id={dialog.id}
className={`dialog-generator-bubble ${dialog.style}${dialog.confirmed ? " is-confirmed" : ""}${activeDragId === dialog.id ? " is-dragging" : ""}`}
style={{ left: dialog.x, top: dialog.y, "--dialog-text-color": dialog.color } as CSSProperties}
onMouseDown={(event) => {
const target = event.target as HTMLElement;
if (target.closest("textarea,button")) return;
startDrag(dialog.id, event.clientX, event.clientY);
event.preventDefault();
}}
onTouchStart={(event) => {
const target = event.target as HTMLElement;
if (target.closest("textarea,button")) return;
const touch = event.touches[0];
if (touch) startDrag(dialog.id, touch.clientX, touch.clientY);
}}
onDoubleClick={() => {
if (dialog.confirmed) updateDialog(dialog.id, { confirmed: false });
}}
>
{!dialog.confirmed ? (
<button type="button" className="dialog-generator-delete" onClick={() => deleteDialog(dialog.id)} aria-label="删除文字">
×
</button>
) : null}
{dialog.confirmed ? (
<div className="dialog-generator-text-display">{dialog.text}</div>
) : (
<textarea
className="dialog-generator-text"
rows={2}
placeholder="输入文本..."
value={dialog.text}
onChange={(event) => updateDialog(dialog.id, { text: event.target.value })}
/>
)}
{!dialog.confirmed ? (
<div className="dialog-generator-bubble-bottom">
<button
type="button"
className="dialog-generator-confirm"
onClick={() => {
if (dialog.text.trim()) {
updateDialog(dialog.id, { text: dialog.text.trim(), confirmed: true });
}
}}
>
</button>
</div>
) : null}
</div>
))}
</div>
</main>
</div>
</section>
);
}
export default DialogGeneratorPage;
+38 -283
View File
@@ -59,12 +59,9 @@ interface CloneImageItem {
id: string;
src: string;
name: string;
file?: File;
width?: number;
height?: number;
format?: string;
mimeType?: string;
ossKey?: string;
}
interface CloneResult {
@@ -101,18 +98,6 @@ interface CloneSavedSetting {
requirement: string;
}
interface EcommerceImagePromptOptions {
gender?: string;
age?: string;
ethnicity?: string;
body?: string;
appearance?: string;
scenes?: string[];
customScene?: string;
smartScene?: boolean;
detailModules?: string[];
}
type PlatformRatioModeKey = ProductSetOutputKey | "hot" | "video-outfit";
interface PlatformRatioGroup {
@@ -686,85 +671,15 @@ function readImageDimensions(src: string): Promise<{ width: number; height: numb
});
}
const blobToDataUrl = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsDataURL(blob);
});
async function createUploadedImageItems(files: File[], limit: number, prefix: string): Promise<CloneImageItem[]> {
const selectedFiles = Array.from(files).slice(0, limit);
const stamp = Date.now();
const items = await Promise.all(selectedFiles.map(async (file, index) => {
const localPreviewUrl = URL.createObjectURL(file);
let dimensions: { width?: number; height?: number } = {};
try {
dimensions = await readImageDimensions(localPreviewUrl);
} catch {
dimensions = {};
} finally {
URL.revokeObjectURL(localPreviewUrl);
}
const mimeType = normalizeEcommerceImageMime(file.type);
const uploadBlob = file.type === mimeType ? file : new Blob([file], { type: mimeType });
const { url, ossKey } = await aiGenerationClient.uploadAssetBinary(uploadBlob, {
function createObjectImageItems(files: File[], limit: number, prefix: string) {
return Array.from(files)
.slice(0, limit)
.map<CloneImageItem>((file, index) => ({
id: `${prefix}-${Date.now()}-${index}`,
src: URL.createObjectURL(file),
name: file.name,
mimeType,
scope: "ecommerce-product",
});
return {
id: `${prefix}-${stamp}-${index}`,
src: url,
name: file.name,
file,
format: getImageFileFormat(file),
mimeType,
ossKey,
...dimensions,
};
}));
return items;
}
async function persistGeneratedImageUrl(sourceUrl: string, scope: string, namePrefix: string): Promise<string> {
if (!sourceUrl) return sourceUrl;
try {
if (sourceUrl.startsWith("data:")) {
const { url } = await aiGenerationClient.uploadAsset({
dataUrl: sourceUrl,
name: `${namePrefix}-${Date.now()}.png`,
scope,
});
return url || sourceUrl;
}
if (sourceUrl.startsWith("blob:")) {
const rawBlob = await fetch(sourceUrl).then((res) => res.blob());
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const { url } = await aiGenerationClient.uploadAssetBinary(blob, {
name: `${namePrefix}-${Date.now()}.png`,
mimeType,
scope,
});
return url;
}
const { url } = await aiGenerationClient.uploadAssetByUrl({
sourceUrl,
name: `${namePrefix}-${Date.now()}`,
scope,
});
return url || sourceUrl;
} catch {
return sourceUrl;
}
}
function notifyRejectedImages(files: File[]): File[] {
@@ -876,7 +791,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [status, setStatus] = useState<ProductCloneStatus>("idle");
const [results, setResults] = useState<CloneResult[]>([]);
const imageAbortRef = useRef({ current: false });
const activeEcommerceTaskIdsRef = useRef<Set<string>>(new Set());
const lastFailedActionRef = useRef<(() => void) | null>(null);
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
const [modelSource, setModelSource] = useState<TryOnModelSource>("ai");
@@ -931,30 +845,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
} as CSSProperties;
const trackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.add(taskId);
};
const untrackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.delete(taskId);
};
const handleCancelGenerate = () => {
imageAbortRef.current.current = true;
const taskIds = Array.from(activeEcommerceTaskIdsRef.current);
activeEcommerceTaskIdsRef.current.clear();
taskIds.forEach((taskId) => {
aiGenerationClient.cancelTask(taskId).catch(() => {});
});
lastFailedActionRef.current = null;
if (productSetStatus === "generating") setProductSetStatus("idle");
if (status === "generating") setStatus("idle");
if (detailStatus === "generating") setDetailStatus("idle");
if (tryOnStatus === "generating") setTryOnStatus("idle");
if (tryOnStatus === "modeling") setTryOnStatus("ready");
toast.info("\u5df2\u53d6\u6d88\u751f\u6210");
};
const syncRequirementMentionQuery = (value: string, selectionStart: number | null | undefined) => {
setRequirementImageMentionQuery(ecommerceMentionImages.length ? getImageMentionQuery(value, selectionStart) : null);
};
@@ -971,26 +861,21 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
});
};
const addSetImages = async (files: File[]) => {
const addSetImages = (files: File[]) => {
if (setImages.length >= 3) return;
const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return;
try {
const nextImages = await createUploadedImageItems(imageFiles, 3 - setImages.length, "set");
setSetImages((current) => {
if (current.length >= 3) return current;
const nextImages = createObjectImageItems(imageFiles, 3 - current.length, "set");
return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current;
});
setProductSetStatus("ready");
} catch (err) {
toast.error(err instanceof Error ? err.message : "商品图上传失败");
}
};
const handleSetUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void addSetImages(Array.from(files));
addSetImages(Array.from(files));
event.target.value = "";
};
@@ -998,7 +883,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.preventDefault();
setIsSetUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) void addSetImages(files);
if (files.length) addSetImages(files);
};
const removeSetImage = (imageId: string) => {
@@ -1009,26 +894,22 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
});
};
const addProductImages = async (files: File[]) => {
const addProductImages = (files: File[]) => {
const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return;
try {
const nextImages = await createUploadedImageItems(imageFiles, maxCloneProductImages - productImages.length, "product");
setProductImages((current) => {
if (current.length >= maxCloneProductImages) return current;
const nextImages = createObjectImageItems(imageFiles, maxCloneProductImages - current.length, "product");
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current;
});
setStatus("ready");
setResults([]);
} catch (err) {
toast.error(err instanceof Error ? err.message : "商品图上传失败");
}
};
const handleProductUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void addProductImages(Array.from(files));
addProductImages(Array.from(files));
event.target.value = "";
};
@@ -1036,7 +917,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.preventDefault();
setIsProductUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) void addProductImages(files);
if (files.length) addProductImages(files);
};
const removeProductImage = (imageId: string) => {
@@ -1062,28 +943,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
});
};
const addCloneReferenceImages = async (files: File[]) => {
const addCloneReferenceImages = (files: File[]) => {
const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return;
const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length;
if (remainingSlots <= 0) return;
try {
const nextImages = await createUploadedImageItems(imageFiles, remainingSlots, "reference");
const nextImages = createObjectImageItems(imageFiles, remainingSlots, "reference");
if (!nextImages.length) return;
setCloneReferenceImages((current) => {
if (current.length >= maxCloneReferenceImages) return current;
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current;
});
hydrateCloneReferenceImageMeta(nextImages);
} catch (err) {
toast.error(err instanceof Error ? err.message : "参考图上传失败");
}
};
const handleCloneReferenceUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void addCloneReferenceImages(Array.from(files));
addCloneReferenceImages(Array.from(files));
event.target.value = "";
};
@@ -1398,15 +1275,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.target.value = "";
return;
}
void (async () => {
try {
const nextImages = await createUploadedImageItems(uploadedFiles, 5 - garmentImages.length, "garment");
setGarmentImages((current) => [...current, ...nextImages].slice(0, 5));
setGarmentImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 5 - current.length, "garment")].slice(0, 5));
setTryOnStatus("ready");
} catch (err) {
toast.error(err instanceof Error ? err.message : "服饰图上传失败");
}
})();
event.target.value = "";
};
@@ -1418,15 +1288,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.target.value = "";
return;
}
void (async () => {
try {
const nextImages = await createUploadedImageItems(uploadedFiles, 3 - detailProductImages.length, "detail");
setDetailProductImages((current) => [...current, ...nextImages].slice(0, 3));
setDetailProductImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 3 - current.length, "detail")].slice(0, 3));
setDetailStatus("ready");
} catch (err) {
toast.error(err instanceof Error ? err.message : "详情图上传失败");
}
})();
event.target.value = "";
};
@@ -1442,15 +1305,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const urls: string[] = [];
for (const item of images) {
try {
if (!item.file && item.src.startsWith("blob:")) {
throw new Error("本地预览图缺少原始文件,无法上传");
}
const rawBlob = item.file ?? (item.src.startsWith("data:") ? null : await (await fetch(item.src)).blob());
const mimeType = normalizeEcommerceImageMime(
rawBlob?.type || item.src.match(/^data:([^;,]+)/)?.[1] || "image/png",
);
const blob = rawBlob ? (rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType })) : null;
const dataUrl = item.src.startsWith("data:") ? item.src : await blobToDataUrl(blob!);
const resp = await fetch(item.src);
const rawBlob = await resp.blob();
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const dataUrl = await blobToDataUrl(blob);
const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType, scope: "ecommerce-product" });
urls.push(url);
} catch {
@@ -1468,32 +1327,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
scene: { label: "场景图", promptDesc: "lifestyle scene image showing the product in a realistic usage environment with natural surroundings" },
};
const buildDetailModulePrompt = (moduleIds: string[]): string => {
if (!moduleIds.length) {
return "Generate a complete A+ detail layout with hero, selling points, usage scene, product detail, and specification modules.";
}
const selectedModules = cloneDetailModules.filter((module) => moduleIds.includes(module.id));
if (!selectedModules.length) return "";
const moduleList = selectedModules.map((module) => `${module.title}: ${module.desc}`).join("; ");
return `Only generate these selected A+ detail modules, no extra modules: ${moduleList}. Keep the output focused even if only one or two modules are selected.`;
};
const buildSetSubPrompt = (countKey: CloneSetCountKey, index: number, totalCount: number, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => {
const info = setCountLabels[countKey];
const parts: string[] = [];
parts.push(`Generate an e-commerce ${info.label.toLowerCase()} for a product listing.`);
parts.push(info.promptDesc);
if (countKey === "white") {
parts.push("The output must be a clean white-background product image. Do not use lifestyle backgrounds, props, text overlays, or people.");
}
if (countKey === "scene") {
parts.push("The output must be a realistic usage scene image. Keep the product clearly visible and preserve its shape, color, and key details.");
}
if (countKey === "selling") {
parts.push("The output must be a selling-point graphic with clear hierarchy, concise copy, and product detail callouts.");
}
if (totalCount > 1) {
parts.push(`This is variant ${index + 1} of ${totalCount} — vary the angle, composition, or emphasis to make each distinct.`);
}
@@ -1505,14 +1343,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const buildEcommerceImagePrompt = (
outputKey: CloneOutputKey, userText: string,
pPlatform: string, pRatio: string, pLanguage: string, pMarket: string,
tryOnOptions?: EcommerceImagePromptOptions,
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
): string => {
const parts: string[] = [];
if (outputKey === "detail") {
parts.push("Generate a professional A+ detail page hero image for an e-commerce product listing.");
parts.push("Create a high-impact first-screen visual that combines the product photo with key selling points, usage scenes, and detailed specifications in a cohesive layout.");
parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`);
if (outputKey === "detail" && tryOnOptions?.detailModules) parts.push(buildDetailModulePrompt(tryOnOptions.detailModules));
parts.push("Follow platform A+ page best practices — clear hierarchy, professional typography, high visual impact.");
} else if (outputKey === "model") {
parts.push("Generate model/try-on lifestyle images for an e-commerce product listing.");
@@ -1525,7 +1362,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
if (tryOnOptions.body) parts.push(`Model body type: ${tryOnOptions.body}.`);
if (tryOnOptions.appearance) parts.push(`Model appearance details: ${tryOnOptions.appearance}.`);
if (tryOnOptions.scenes?.length) parts.push(`Background scenes: ${tryOnOptions.scenes.join(", ")}.`);
if (tryOnOptions.customScene) parts.push(`Custom background scene: ${tryOnOptions.customScene}.`);
if (tryOnOptions.smartScene) parts.push("Use smart scene matching to select the best background context.");
}
parts.push("Model should appear natural and appealing. Background should complement the product. Image must meet platform standards.");
@@ -1559,10 +1395,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setStatusFn("idle");
return;
}
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
const generatedUrls: string[] = [];
const stamp = Date.now();
@@ -1582,26 +1414,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
gridMode: "single",
referenceUrls,
});
trackEcommerceTask(taskId);
const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId });
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, {
const resultUrl = await waitForTask(taskId, {
abortRef: imageAbortRef.current,
onProgress: () => {},
});
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) break;
if (resultUrl) {
const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${countKey}-${i + 1}`);
generatedUrls.push(persistedUrl);
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
generatedUrls.push(resultUrl);
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
} else {
generatedUrls.push("");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
@@ -1609,17 +1432,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}
}
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
setResultFn(generatedUrls);
setStatusFn(generatedUrls.some(Boolean) ? "done" : "idle");
} catch (err) {
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
if (err instanceof ServerRequestError && err.status === 402) {
setResultFn([]);
toast.error("余额不足,请充值后继续");
@@ -1639,7 +1454,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
pRatio: string,
pLanguage: string,
pMarket: string,
tryOnOptions?: EcommerceImagePromptOptions,
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
resultFn?: (results: CloneResult[]) => void,
): Promise<void> => {
@@ -1650,10 +1465,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
statusFn?.("idle");
return;
}
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions);
const stamp = Date.now();
@@ -1666,39 +1477,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
gridMode: "single",
referenceUrls,
});
trackEcommerceTask(taskId);
const storeId = imageGen.submitTask({ title: `电商${outputKey}`, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId });
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, {
const resultUrl = await waitForTask(taskId, {
abortRef: imageAbortRef.current,
onProgress: () => {},
});
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
if (resultUrl) {
const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${outputKey}`);
resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: selectedCloneOutput.label }]);
resultFn?.([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
statusFn?.("done");
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
} else {
statusFn?.("idle");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
}
} catch (err) {
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
if (err instanceof ServerRequestError && err.status === 402) {
resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
toast.error("余额不足,请充值后继续");
@@ -1732,38 +1527,21 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
dataUrl: refDataUrl, name: videoOutfitRefFile.name,
mimeType: videoOutfitRefFile.type || "image/png", scope: "video-outfit",
});
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
const { taskId } = await aiGenerationClient.createVideoEditTask({
videoUrl: videoAsset.url,
referenceUrls: [refAsset.url],
prompt: requirement || undefined,
});
trackEcommerceTask(taskId);
const { waitForTask } = await import("../../api/taskSubscription");
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
imageAbortRef.current = { current: false };
const resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
if (resultUrl) {
setResults([{ id: crypto.randomUUID(), src: resultUrl, label: "换装视频" }]);
}
setStatus("done");
} catch (err) {
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
setStatus("failed");
toast.error(err instanceof Error ? err.message : "视频换装生成失败");
}
@@ -1793,24 +1571,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
(urls) => setProductSetResultImages(urls),
);
} else {
const clonePromptOptions: EcommerceImagePromptOptions | undefined =
cloneOutput === "model"
? {
gender: cloneModelGender,
age: cloneModelAge,
ethnicity: cloneModelEthnicity,
body: cloneModelBody,
appearance: cloneModelAppearance,
scenes: selectedCloneModelScenes,
customScene: cloneModelCustomScene,
}
: cloneOutput === "detail"
? { detailModules: selectedCloneDetailModules }
: undefined;
void generateEcommerceImage(
cloneOutput, productImages, requirement,
platform, ratio, language, market,
clonePromptOptions,
undefined,
(s: string) => setStatus(s as ProductCloneStatus), setResults,
);
lastFailedActionRef.current = () => handleGenerate();
@@ -1890,7 +1654,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
void generateEcommerceImage(
"detail", detailProductImages, detailRequirement,
detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket,
{ detailModules: selectedDetailModules },
undefined,
(s: string) => setDetailStatus(s as DetailStatus),
(res) => setDetailResultUrl(res[0]?.src ?? null),
);
@@ -1969,7 +1733,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
for (let i = 0; i < count; i++) {
setPreviewCards.push({
id: `${countKey}-${i}`,
src: productSetResultImages[setIndex] || productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src || "",
src: productSetResultImages[setIndex] ?? productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src ?? "",
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
});
setIndex++;
@@ -1984,7 +1748,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
for (let i = 0; i < count; i++) {
clonePreviewCards.push({
id: `${countKey}-${i}`,
src: results[cloneIndex]?.src || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "",
src: results[cloneIndex]?.src ?? productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src ?? "",
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
});
cloneIndex++;
@@ -2113,7 +1877,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
clampCloneVideoDuration={clampCloneVideoDuration}
setCloneVideoSmart={setCloneVideoSmart}
handleGenerate={handleGenerate}
onCancelGenerate={handleCancelGenerate}
formatRatioDisplayValue={formatRatioDisplayValue}
setVideoOutfitFiles={(video, ref) => { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }}
onStartVideoPlan={() => setVideoPlanTrigger((n) => n + 1)}
@@ -2147,7 +1910,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
handleDetailAiWrite={handleDetailAiWrite}
toggleDetailModule={toggleDetailModule}
handleDetailGenerate={handleDetailGenerate}
onCancelGenerate={handleCancelGenerate}
/>
);
@@ -2185,7 +1947,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setSmartScene={setSmartScene}
setTryOnRatio={setTryOnRatio}
handleTryOnGenerate={handleTryOnGenerate}
onCancelGenerate={handleCancelGenerate}
/>
);
@@ -2261,11 +2022,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{productSetStatus === "generating" ? <LoadingOutlined /> : null}
{setPrimaryLabel}
</button>
{productSetStatus === "generating" ? (
<button type="button" className="product-set-floating-submit product-set-floating-submit--cancel" onClick={handleCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</section>
<button type="button" className="product-clone-help" aria-label="帮助">
@@ -2617,7 +2373,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<EcommerceVideoWorkspace
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
productImageDataUrls={productImages.map((img) => img.src)}
productImageFiles={productImages.map((img) => img.file)}
requirement={requirement}
platform={platform}
aspectRatio={ratio.includes("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"}
@@ -34,7 +34,6 @@ import {
interface EcommerceVideoWorkspaceProps {
isAuthenticated: boolean;
productImageDataUrls: string[];
productImageFiles?: Array<File | undefined>;
requirement: string;
platform: string;
aspectRatio: string;
@@ -98,7 +97,6 @@ function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress
export default function EcommerceVideoWorkspace({
isAuthenticated,
productImageDataUrls,
productImageFiles = [],
requirement,
platform,
aspectRatio,
@@ -378,9 +376,8 @@ export default function EcommerceVideoWorkspace({
});
};
try {
const productImageSources = productImageDataUrls.map((url, index) => productImageFiles[index] ?? url);
const result = await runVideoPlan(
productImageSources, requirement, buildConfig(),
productImageDataUrls, requirement, buildConfig(),
{
onStepStart: (step) => setCurrentStep(step),
onStepDone: (step) => {
+31 -72
View File
@@ -126,61 +126,13 @@ export interface PlanCallbacks {
resumeFrom?: EcommerceVideoPlanProgress;
}
const LOCAL_PREVIEW_MISSING_FILE_MESSAGE = "Please re-upload the product image before generating the short video.";
function readBlobAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("File read failed"));
reader.readAsDataURL(blob);
});
}
function normalizeRemoteImageUrl(source: string): string | null {
try {
const url = new URL(source, typeof window !== "undefined" ? window.location.href : undefined);
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
} catch {
return null;
}
}
async function uploadProductImageSource(source: string | Blob): Promise<string> {
if (typeof source === "string") {
if (source.startsWith("blob:")) {
throw new Error(LOCAL_PREVIEW_MISSING_FILE_MESSAGE);
}
if (source.startsWith("data:")) {
const mimeType = normalizeEcommerceImageMime(source.match(/^data:([^;,]+)/)?.[1] || "image/png");
const result = await aiGenerationClient.uploadAsset({ dataUrl: source, mimeType, scope: "ecommerce-product" });
return result.url;
}
const remoteUrl = normalizeRemoteImageUrl(source);
if (remoteUrl) {
const result = await aiGenerationClient.uploadAssetByUrl({ sourceUrl: remoteUrl, scope: "ecommerce-product" });
return result.url;
}
throw new Error("Unsupported product image URL. Please re-upload the product image.");
}
const mimeType = normalizeEcommerceImageMime(source.type || "image/png");
const blob = source.type === mimeType ? source : new Blob([source], { type: mimeType });
const dataUrl = await readBlobAsDataUrl(blob);
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
return result.url;
}
/**
* Run the full ad video planning pipeline.
* Supports resumption: if `resumeFrom` contains data for a step, that step is skipped.
* After each step, `onPartialProgress` fires so callers can persist intermediate state.
*/
export async function runVideoPlan(
imageSources: Array<string | Blob>,
imageDataUrls: string[],
manualText: string,
config: AdVideoUserConfig,
callbacks: PlanCallbacks,
@@ -189,30 +141,41 @@ export async function runVideoPlan(
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
const emit = () => callbacks.onPartialProgress?.({ ...progress });
// Step: upload
// ── Step: upload ──────────────────────────────────────
if (!progress.imageUrls?.length) {
onStepStart("upload");
const imageUrls: string[] = [];
const rejected: string[] = [];
for (const source of imageSources) {
for (const srcUrl of imageDataUrls) {
try {
imageUrls.push(await uploadProductImageSource(source));
const resp = await fetch(srcUrl);
const rawBlob = await resp.blob();
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsDataURL(blob);
});
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
imageUrls.push(result.url);
} catch (err) {
rejected.push(err instanceof Error ? err.message : "Image upload failed");
rejected.push(err instanceof Error ? err.message : "图片上传失败");
}
}
if (rejected.length) {
progress.uploadWarnings = rejected;
callbacks.onUploadRejected?.(rejected);
}
if (!imageUrls.length) throw new Error("Image upload failed. Please check the image format or network and try again.");
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
progress.imageUrls = imageUrls;
onStepDone("upload");
callbacks.onImagesUploaded?.(imageUrls);
emit();
}
// Step: analyze
// ── Step: analyze ─────────────────────────────────────
if (progress.imageDescription === undefined) {
onStepStart("analyze");
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
@@ -220,7 +183,7 @@ export async function runVideoPlan(
emit();
}
// Step: summary
// ── Step: summary ─────────────────────────────────────
if (!progress.summary) {
onStepStart("summary");
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
@@ -228,7 +191,7 @@ export async function runVideoPlan(
emit();
}
// Step: selling
// ── Step: selling ─────────────────────────────────────
if (!progress.selling) {
onStepStart("selling");
progress.selling = await extractSellingPoints(progress.summary, signal);
@@ -236,16 +199,16 @@ export async function runVideoPlan(
emit();
}
// Step: creative
// ── Step: creative ────────────────────────────────────
if (!progress.creatives?.length) {
onStepStart("creative");
progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
if (!progress.creatives.length) throw new Error("Failed to generate valid ad creatives.");
if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
onStepDone("creative");
emit();
}
// Step: storyboard
// ── Step: storyboard ──────────────────────────────────
if (!progress.storyboard) {
onStepStart("storyboard");
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
@@ -253,7 +216,7 @@ export async function runVideoPlan(
emit();
}
// Step: prompts
// ── Step: prompts ─────────────────────────────────────
if (!progress.videoPrompts) {
onStepStart("prompts");
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
@@ -261,7 +224,7 @@ export async function runVideoPlan(
emit();
}
// Step: compliance
// ── Step: compliance ──────────────────────────────────
if (!progress.compliance) {
onStepStart("compliance");
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
@@ -312,15 +275,13 @@ export async function renderSceneImage(
const resultUrl = await waitForTask(taskId, {
abortRef,
kind: "image",
model: "gpt-image-2",
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
});
if (resultUrl) {
callbacks.onSceneImageCompleted(input.sceneId, resultUrl);
} else {
callbacks.onSceneImageFailed(input.sceneId, "Image generation returned no result.");
callbacks.onSceneImageFailed(input.sceneId, "图片生成未返回结果");
}
}
@@ -369,15 +330,13 @@ export async function renderScene(
const resultUrl = await waitForTask(taskId, {
abortRef,
kind: "video",
model,
onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress),
});
if (resultUrl) {
callbacks.onSceneCompleted(input.sceneId, resultUrl);
} else {
callbacks.onSceneFailed(input.sceneId, "Task returned no result.");
callbacks.onSceneFailed(input.sceneId, "任务未返回结果");
}
}
@@ -396,7 +355,7 @@ export function buildSceneTasks(
});
}
// Video History API
// ── Video History API ──────────────────────────────────
export interface VideoHistoryScene {
sceneId: number;
@@ -491,7 +450,7 @@ export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promis
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
body: JSON.stringify(historyPayload),
});
if (!res.ok) throw new Error("Failed to save video history");
if (!res.ok) throw new Error("保存历史记录失败");
return res.json();
}
@@ -515,7 +474,7 @@ export async function fetchVideoHistory(
`${API_BASE}?limit=${limit}&offset=${offset}`,
{ headers: getAuthHeaders() },
);
if (!res.ok) throw new Error("Failed to fetch video history");
if (!res.ok) throw new Error("获取历史记录失败");
const history = (await res.json()) as VideoHistoryListResponse;
return {
...history,
@@ -528,5 +487,5 @@ export async function deleteVideoHistory(id: number): Promise<void> {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("Failed to delete video history");
if (!res.ok) throw new Error("删除失败");
}
@@ -133,7 +133,6 @@ interface EcommerceClonePanelProps {
clampCloneVideoDuration: (value: number) => number;
setCloneVideoSmart: (updater: (current: boolean) => boolean) => void;
handleGenerate: () => void;
onCancelGenerate: () => void;
formatRatioDisplayValue: (value: string) => string;
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
onStartVideoPlan?: () => void;
@@ -201,7 +200,6 @@ export default function EcommerceClonePanel({
clampCloneVideoDuration,
setCloneVideoSmart,
handleGenerate,
onCancelGenerate,
formatRatioDisplayValue,
setVideoOutfitFiles,
onStartVideoPlan,
@@ -748,11 +746,6 @@ export default function EcommerceClonePanel({
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <ReloadOutlined /> : null}
{status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : cloneOutput === "video-outfit" ? "✦ 开始换装" : "✦ 开始生成"}
</button>
{status === "generating" && cloneOutput !== "video" ? (
<button type="button" className="clone-ai-generate clone-ai-generate--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</div>
</>
);
@@ -28,7 +28,6 @@ interface EcommerceDetailPanelProps {
handleDetailAiWrite: () => void;
toggleDetailModule: (id: string) => void;
handleDetailGenerate: () => void;
onCancelGenerate: () => void;
}
export default function EcommerceDetailPanel({
@@ -57,7 +56,6 @@ export default function EcommerceDetailPanel({
handleDetailAiWrite,
toggleDetailModule,
handleDetailGenerate,
onCancelGenerate,
}: EcommerceDetailPanelProps) {
return (
<>
@@ -164,11 +162,6 @@ export default function EcommerceDetailPanel({
{detailStatus === "generating" ? <LoadingOutlined /> : null}
{detailPrimaryLabel}
</button>
{detailStatus === "generating" ? (
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</footer>
</>
);
@@ -35,7 +35,6 @@ interface EcommerceTryOnPanelProps {
setSmartScene: (updater: (current: boolean) => boolean) => void;
setTryOnRatio: (value: string) => void;
handleTryOnGenerate: () => void;
onCancelGenerate: () => void;
}
export default function EcommerceTryOnPanel({
@@ -71,7 +70,6 @@ export default function EcommerceTryOnPanel({
setSmartScene,
setTryOnRatio,
handleTryOnGenerate,
onCancelGenerate,
}: EcommerceTryOnPanelProps) {
return (
<>
@@ -215,11 +213,6 @@ export default function EcommerceTryOnPanel({
{tryOnStatus === "generating" ? <LoadingOutlined /> : null}
{tryOnPrimaryLabel}
</button>
{tryOnStatus === "generating" ? (
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</footer>
</>
);
-2
View File
@@ -7,7 +7,6 @@ import {
DeleteOutlined,
EditOutlined,
HighlightOutlined,
MessageOutlined,
SwapOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
@@ -43,7 +42,6 @@ const tools: MoreTool[] = [
{ id: "camera", title: "镜头实验室", text: "角度、焦段和机位控制", icon: <CameraOutlined />, category: "image", imageTool: "camera", ready: true },
{ id: "upscale", title: "分辨率提升", text: "图片与视频高清超分", icon: <ColumnWidthOutlined />, category: "image", target: "resolutionUpscale", ready: true },
{ id: "watermarkRemoval", title: "去水印", text: "AI 智能去除图片水印和文字", icon: <DeleteOutlined />, category: "image", target: "watermarkRemoval", ready: true },
{ id: "dialogGenerator", title: "交互式对话框生成器", text: "上传背景图,添加可拖拽编辑的对话框", icon: <MessageOutlined />, category: "image", target: "dialogGenerator", ready: true },
{ id: "subtitleRemoval", title: "字幕去除", text: "AI 智能擦除视频字幕", icon: <DeleteOutlined />, category: "video", target: "subtitleRemoval", ready: true },
{ id: "digitalHuman", title: "数字人", text: "参考人像与音频生成口播视频", icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true },
{ id: "characterMix", title: "角色迁移", text: "人物图迁移到参考视频动作", icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true },
+7 -7
View File
@@ -857,25 +857,25 @@ function ProfilePage({
{tasks.length}
</button>
</div>
<div className="profile-page__account-summary">
<div className="profile-page__upload-card profile-page__upload-card--meta">
{accountPanel === "credits" ? (
<>
<span className="profile-page__account-summary-main">
<span className="profile-page__meta-item">
<small></small>
<strong>{displayName}</strong>
</span>
<span className="profile-page__account-summary-metric">
<span className="profile-page__meta-item">
<small></small>
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
</span>
</>
) : (
<>
<span className="profile-page__account-summary-main">
<small></small>
<strong>{tasks.length} </strong>
<span className="profile-page__meta-item">
<small></small>
<strong>{tasks.length}</strong>
</span>
<span className="profile-page__account-summary-metric">
<span className="profile-page__meta-item">
<small></small>
<strong>{completedTasks.length}</strong>
</span>
+2 -110
View File
@@ -25,8 +25,6 @@ interface EvalResult {
totalScore: number;
grade: string;
dimensionScores: Record<string, number>;
subScores?: Record<string, Record<string, number>>;
evidence?: Record<string, string[]>;
summary: string;
issues: string[];
highlights: string[];
@@ -194,60 +192,6 @@ const SCORE_DIMENSIONS: ScoreDimension[] = [
{ key: "content", label: "内容深度", maxScore: 15, hint: "主题表达·情感共鸣·思想内核", detail: "核心设定将科技伦理与人性困境紧密结合,主题表达深刻有力。" },
];
const SUB_SCORE_LABELS: Record<string, string> = {
openingImpact: "开篇冲击",
suspenseChain: "悬念链",
sceneHook: "场内钩子",
structure: "结构完整",
rhythm: "节奏推进",
conflict: "冲突强度",
reversal: "反转效率",
motivation: "动机清晰",
arc: "人物弧光",
voice: "语言辨识",
relationship: "关系张力",
causality: "因果链",
worldRules: "世界规则",
foreshadowing: "伏笔回收",
continuity: "连续性",
sceneDetail: "场景细节",
shotPotential: "镜头潜力",
aigcFeasibility: "AIGC 可实现",
theme: "主题表达",
emotion: "情感共鸣",
marketFit: "市场匹配",
originality: "原创性",
};
function clampScore(score: unknown, maxScore: number): number {
const numeric = Number(score);
if (!Number.isFinite(numeric)) return 0;
return Math.max(0, Math.min(maxScore, numeric));
}
function getDimensionScore(result: EvalResult, dim: ScoreDimension): number {
const value = result.dimensionScores[dim.key] ?? (dim.key === "logic" ? result.dimensionScores.dialogue : undefined);
return clampScore(value, dim.maxScore);
}
function formatSubScoreLabel(key: string): string {
return SUB_SCORE_LABELS[key] ?? key.replace(/([A-Z])/g, " $1").trim();
}
function getDimensionSubScores(result: EvalResult, dim: ScoreDimension): Array<[string, number]> {
const scores = result.subScores?.[dim.key] ?? (dim.key === "logic" ? result.subScores?.dialogue : undefined);
if (!scores) return [];
return Object.entries(scores)
.map(([key, value]) => [key, clampScore(value, dim.maxScore)] as [string, number])
.filter(([, value]) => value > 0)
.slice(0, 5);
}
function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[] {
const evidence = result.evidence?.[dim.key] ?? (dim.key === "logic" ? result.evidence?.dialogue : undefined);
return Array.isArray(evidence) ? evidence.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3) : [];
}
function formatReportMarkdown(result: EvalResult, script: string): string {
const lines: string[] = [];
lines.push(`# 剧本评测报告`);
@@ -259,16 +203,9 @@ function formatReportMarkdown(result: EvalResult, script: string): string {
lines.push("");
lines.push(`## 六维评分`);
for (const dim of SCORE_DIMENSIONS) {
const score = getDimensionScore(result, dim);
const score = result.dimensionScores[dim.key] ?? 0;
const pct = Math.round((score / dim.maxScore) * 100);
const subScores = getDimensionSubScores(result, dim);
const evidence = getDimensionEvidence(result, dim);
const nestedReportLines = [
...subScores.map(([key, value]) => ` - ${formatSubScoreLabel(key)}: ${value}`),
...evidence.map((item) => ` - 证据: ${item}`),
];
lines.push(`- **${dim.label}**: ${score}/${dim.maxScore} (${pct}%) — ${dim.hint}`);
lines.push(...nestedReportLines);
}
if (result.highlights.length > 0) {
lines.push("");
@@ -699,7 +636,7 @@ function ScriptTokensPage() {
</div>
<div className="script-eval-report__chart-grid">
{SCORE_DIMENSIONS.map((dim, dimIndex) => {
const score = getDimensionScore(result, dim);
const score = result.dimensionScores[dim.key] ?? 0;
const pct = Math.max(0, Math.min(1, score / dim.maxScore));
const lossPct = 1 - pct;
const isPerfect = score === dim.maxScore;
@@ -739,51 +676,6 @@ function ScriptTokensPage() {
</div>
</section>
<div className="script-eval-report__detail-grid">
{SCORE_DIMENSIONS.map((dim) => {
const score = getDimensionScore(result, dim);
const pct = Math.round((score / dim.maxScore) * 100);
const subScores = getDimensionSubScores(result, dim);
const evidence = getDimensionEvidence(result, dim);
return (
<section className="script-eval-report__detail-card" key={dim.key}>
<header className="script-eval-report__detail-head">
<div>
<span>{dim.label}</span>
<strong>{score}<small>/{dim.maxScore}</small></strong>
</div>
<em>{pct}%</em>
</header>
<p className="script-eval-report__detail-hint">{dim.hint}</p>
{subScores.length > 0 ? (
<div className="script-eval-report__subscore-list">
{subScores.map(([key, value]) => {
const subPct = Math.max(0, Math.min(100, Math.round((value / dim.maxScore) * 100)));
return (
<div className="script-eval-report__subscore-row" key={key}>
<span>{formatSubScoreLabel(key)}</span>
<div className="script-eval-report__subscore-bar" aria-hidden="true">
<i style={{ width: `${subPct}%` }} />
</div>
<b>{value}</b>
</div>
);
})}
</div>
) : (
<p className="script-eval-report__detail-empty"></p>
)}
{evidence.length > 0 ? (
<ul className="script-eval-report__evidence-list">
{evidence.map((item, index) => <li key={index}>{item}</li>)}
</ul>
) : null}
</section>
);
})}
</div>
<div className="script-eval-report__findings">
{result.highlights.length > 0 ? (
<section className="script-eval-report__finding-group is-highlight">
+20 -162
View File
@@ -64,12 +64,6 @@ import {
import { renderMarkdownBlocks } from "./markdownRenderer";
import { downloadResultAsset } from "./workbenchDownload";
import { translateTaskError } from "../../utils/translateTaskError";
import {
buildLocalTimeoutMessage,
formatTextTokenUsage,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../../utils/taskLifecycle";
import { detectMentionTrigger } from "../../utils/mentionTrigger";
import {
isHappyHorseModel,
@@ -109,7 +103,6 @@ import {
VIDEO_MODEL_OPTIONS,
RATIO_OPTIONS,
GRID_MODE_OPTIONS,
GRID_SUPPORTED_MODELS,
VIDEO_FRAME_OPTIONS,
VIDEO_DURATION_OPTIONS,
MESSAGE_STORAGE_KEY,
@@ -257,8 +250,6 @@ function WorkbenchPage({
const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null);
const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]);
const [referencePreviewOpen, setReferencePreviewOpen] = useState(false);
const [isComposerDragging, setIsComposerDragging] = useState(false);
const composerDragCounterRef = useRef(0);
const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null);
const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null);
const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]);
@@ -872,9 +863,6 @@ function WorkbenchPage({
let lastKnownProgress = Math.max(0, Number(task.progress || 0));
let taskPollFailures = 0;
let lastProgressAt = task.startedAt || Date.now();
const taskKind = task.mode === "image" ? "image" : "video";
const timeoutPolicy = getTaskTimeoutPolicy({ kind: taskKind, model: task.modelLabel, operation: task.operation });
const abortController = new AbortController();
taskAbortControllersRef.current.set(task.taskId, abortController);
if (activeConversationIdRef.current === task.conversationId) {
@@ -921,9 +909,6 @@ function WorkbenchPage({
const progress = status.status === "completed"
? 100
: Math.min(99, Math.max(10, lastKnownProgress, currentMessageProgress, Math.round(baseProgress)));
if (progress > lastKnownProgress || status.status === "completed") {
lastProgressAt = Date.now();
}
lastKnownProgress = Math.max(lastKnownProgress, progress);
const isSuperResolveTask = task.operation === "video-super-resolution";
const statusLabel =
@@ -948,28 +933,6 @@ function WorkbenchPage({
setGenerationProgress(progress);
}
const localTimeoutReason = status.status !== "completed" && status.status !== "failed" && status.status !== "cancelled"
? isTaskLocallyTimedOut({
startedAt: task.startedAt || Date.now(),
lastProgressAt,
progress,
policy: timeoutPolicy,
})
: null;
if (localTimeoutReason) {
await patchConversationMessage(task.conversationId, task.assistantMessageId, {
body: buildLocalTimeoutMessage(taskKind),
status: "local_timeout",
taskLifecycleStatus: "local_timeout",
taskRefundStatus: "unknown",
taskProgress: progress,
taskStatusLabel: "本地等待超时",
});
removeKeepaliveTask(task.taskId);
onRefreshUsage?.();
return;
}
if (status.status === "completed" && status.resultUrl) {
const completedPatch: Partial<ChatMessage> = {
body: isSuperResolveTask
@@ -1496,22 +1459,9 @@ function WorkbenchPage({
setReferenceItems(nextItems);
};
const handleReferenceUploadClick = () => {
if (referenceItems.length > 0) {
setToolbarMenuId(null);
setReferencePreviewOpen((current) => !current);
return;
}
referenceInputRef.current?.click();
};
const handleReferenceAddMore = () => {
setToolbarMenuId(null);
setReferencePreviewOpen(true);
referenceInputRef.current?.click();
};
const processReferenceFiles = async (files: File[]) => {
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
if (files.length === 0) return;
const existingFingerprints = new Set(
@@ -1598,46 +1548,20 @@ function WorkbenchPage({
window.requestAnimationFrame(() => textareaRef.current?.focus());
};
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
await processReferenceFiles(files);
const handleReferenceUploadClick = () => {
if (referenceItems.length > 0) {
setToolbarMenuId(null);
setReferencePreviewOpen((current) => !current);
return;
}
referenceInputRef.current?.click();
};
const handleComposerDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
composerDragCounterRef.current += 1;
if (composerDragCounterRef.current === 1) {
setIsComposerDragging(true);
}
}, []);
const handleComposerDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
composerDragCounterRef.current -= 1;
if (composerDragCounterRef.current <= 0) {
composerDragCounterRef.current = 0;
setIsComposerDragging(false);
}
}, []);
const handleComposerDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleComposerDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
composerDragCounterRef.current = 0;
setIsComposerDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
void processReferenceFiles(files);
}
}, [activeMode]);
const handleReferenceAddMore = () => {
setToolbarMenuId(null);
setReferencePreviewOpen(true);
referenceInputRef.current?.click();
};
const insertPromptMention = (token: string) => {
const rawBefore = inputValue.slice(0, cursorIndex);
@@ -2017,7 +1941,6 @@ function WorkbenchPage({
runKeepalivePoll(keepaliveTask);
} else {
let streamedText = "";
let chatUsage: ChatMessage["taskUsage"] | undefined;
setGenerationProgress(36);
setGenerationStatus("正在回复");
updateAssistantMessage(assistantMessageId, {
@@ -2050,9 +1973,6 @@ function WorkbenchPage({
});
},
abortController.signal,
(usage) => {
chatUsage = usage;
},
);
if (abortController.signal.aborted) return;
@@ -2061,7 +1981,6 @@ function WorkbenchPage({
const completedMessages = updateAssistantMessage(assistantMessageId, {
body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。",
status: "completed",
taskUsage: chatUsage,
});
if (!conversationId) {
const conv = await conversationClient.create(
@@ -2189,38 +2108,6 @@ function WorkbenchPage({
}
};
const handleReleaseStuckTask = (message: ChatMessage) => {
if (message.taskId) {
taskAbortControllersRef.current.get(message.taskId)?.abort();
taskAbortControllersRef.current.delete(message.taskId);
removeKeepaliveTask(message.taskId);
}
if (message.conversationId) {
void patchConversationMessage(message.conversationId, message.id, {
body: buildLocalTimeoutMessage(message.mode === "image" ? "image" : "video"),
status: "local_timeout",
taskLifecycleStatus: "local_timeout",
taskRefundStatus: message.taskRefundStatus || "unknown",
taskStatusLabel: "本地占用已释放",
});
}
setMessages((current) =>
current.map((item) =>
item.id === message.id
? {
...item,
body: buildLocalTimeoutMessage(item.mode === "image" ? "image" : "video"),
status: "local_timeout",
taskLifecycleStatus: "local_timeout",
taskRefundStatus: item.taskRefundStatus || "unknown",
taskStatusLabel: "本地占用已释放",
}
: item,
),
);
syncActiveGenerationUi();
};
const handleSuperResolveVideo = async (message: ChatMessage) => {
if (!message.resultUrl || message.resultType !== "video") {
setProjectError("仅支持对视频结果进行超分");
@@ -2674,11 +2561,6 @@ function WorkbenchPage({
>
<ReferencePreview item={item} label={getReferenceKindLabel(item.kind)} />
</button>
{(item.kind === "image" || item.kind === "video") && item.previewUrl ? (
<span className="wb-composer__ref-zoom" aria-hidden="true">
{item.kind === "video" ? <video src={item.previewUrl} muted playsInline /> : <img src={item.previewUrl} alt="" />}
</span>
) : null}
<button
type="button"
className="wb-composer__ref-remove"
@@ -2730,7 +2612,7 @@ function WorkbenchPage({
isOpen={toolbarMenuId === "image-model"}
onToggle={() => toggleToolbarMenu("image-model")}
onClose={closeToolbarMenus}
onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
onChange={setImageModel}
direction={dropdownDirection}
/>
<CompoundSelectChip
@@ -2742,7 +2624,6 @@ function WorkbenchPage({
onToggle={() => toggleToolbarMenu("image-settings")}
direction={dropdownDirection}
/>
{GRID_SUPPORTED_MODELS.has(imageModel) && (
<SelectChip
chipId="image-grid-mode"
value={imageGridMode}
@@ -2754,7 +2635,6 @@ function WorkbenchPage({
onChange={setImageGridMode}
direction={dropdownDirection}
/>
)}
</>
)}
{activeMode === "video" && (
@@ -2938,14 +2818,7 @@ function WorkbenchPage({
<h1 className="wb-home__title"></h1>
</div>
<div
className={`wb-home__composer${isComposerDragging ? " wb-composer--drag-active" : ""}`}
ref={toolbarRef}
onDragEnter={handleComposerDragEnter}
onDragLeave={handleComposerDragLeave}
onDragOver={handleComposerDragOver}
onDrop={handleComposerDrop}
>
<div className="wb-home__composer" ref={toolbarRef}>
<div className="wb-composer__content">
<div className="wb-composer__input-row">
{renderComposerReferences(false)}
@@ -3081,7 +2954,7 @@ function WorkbenchPage({
))}
</div>
)}
{(message.status === "failed" || message.status === "local_timeout") && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
{message.status === "failed" && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
<div className="ai-chat-failed-actions">
<button type="button" className="ai-chat-failed-actions__retry" onClick={() => handleRegenerate(message)}>
<ReloadOutlined />
@@ -3089,12 +2962,9 @@ function WorkbenchPage({
<button type="button" className="ai-chat-failed-actions__switch" onClick={() => { setToolbarMenuId(message.mode === "video" ? "video-model" : "image-model"); scrollMessagesSurface("bottom"); }}>
<AppstoreOutlined />
</button>
<button type="button" className="ai-chat-failed-actions__release" onClick={() => handleReleaseStuckTask(message)}>
<StopOutlined />
</button>
</div>
)}
{(message.status === "thinking" || message.status === "stopping") && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
{message.status === "thinking" && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
<GenerationPendingCard message={message} onStop={() => handleStopSingleTask(message.id)} />
)}
{message.status === "thinking" && message.mode === "chat" && (
@@ -3102,11 +2972,6 @@ function WorkbenchPage({
<span>{message.taskStatusLabel || generationStatus}</span>
</div>
)}
{message.role === "assistant" && message.mode === "chat" && message.status === "completed" && (
<div className="ai-chat-task-billing-note">
{formatTextTokenUsage(message.taskUsage)}
</div>
)}
{(message.resultUrl || (message.result && message.status !== "thinking")) && (
<ResultCard
message={message}
@@ -3128,14 +2993,7 @@ function WorkbenchPage({
</div>
</section>
<section
className={`wb-composer${composerHidden ? " is-hidden" : ""}${isComposerDragging ? " wb-composer--drag-active" : ""}`}
ref={toolbarRef}
onDragEnter={handleComposerDragEnter}
onDragLeave={handleComposerDragLeave}
onDragOver={handleComposerDragOver}
onDrop={handleComposerDrop}
>
<section className={`wb-composer${composerHidden ? " is-hidden" : ""}`} ref={toolbarRef}>
<div className="wb-composer__content">
<div className="wb-composer__input-row">
{renderComposerReferences(false)}
+1 -6
View File
@@ -1,5 +1,3 @@
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
export type WorkbenchMode = "chat" | "image" | "video";
export interface WorkbenchChatAttachment {
@@ -18,10 +16,7 @@ export interface WorkbenchChatMessage {
body: string;
prompt?: string;
createdAt: string;
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
taskLifecycleStatus?: GenerationLifecycleStatus;
taskRefundStatus?: TaskRefundStatus;
taskUsage?: TextTokenUsage;
status?: "thinking" | "queued" | "completed" | "failed";
taskId?: string;
conversationId?: number;
taskProgress?: number;
+2 -18
View File
@@ -1,7 +1,6 @@
import { isServerRequestError } from "../../api/serverConnection";
import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy";
import type { WebGenerationPreviewTask } from "../../types";
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
import type { ReactNode } from "react";
export type WorkbenchMode = "chat" | "image" | "video";
@@ -72,10 +71,7 @@ export interface ChatMessage {
body: string;
prompt?: string;
createdAt: string;
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
taskLifecycleStatus?: GenerationLifecycleStatus;
taskRefundStatus?: TaskRefundStatus;
taskUsage?: TextTokenUsage;
status?: "thinking" | "queued" | "completed" | "failed";
taskId?: string;
conversationId?: number;
taskProgress?: number;
@@ -236,13 +232,6 @@ export const GRID_MODE_OPTIONS: WorkbenchOption[] = [
{ value: "grid-25", label: "25 宫格" },
];
export const GRID_SUPPORTED_MODELS = new Set([
"wan2.7-image-pro",
"wan2.7-image",
"gpt-image-2",
"gpt-image-2-vip",
]);
export const VIDEO_FRAME_OPTIONS: WorkbenchOption[] = [
{ value: "omni", label: "全能参考" },
{ value: "start-end", label: "首尾帧" },
@@ -377,16 +366,11 @@ export function shouldPersistPatch(patch: Partial<ChatMessage>): boolean {
return (
patch.status === "completed" ||
patch.status === "failed" ||
patch.status === "local_timeout" ||
patch.status === "stopping" ||
typeof patch.taskId === "string" ||
typeof patch.resultUrl === "string" ||
typeof patch.resultOssKey === "string" ||
typeof patch.resultOriginalUrl === "string" ||
typeof patch.resultMimeType === "string" ||
typeof patch.taskRefundStatus === "string" ||
typeof patch.taskLifecycleStatus === "string" ||
typeof patch.taskUsage === "object"
typeof patch.resultMimeType === "string"
);
}
+10 -41
View File
@@ -1,11 +1,5 @@
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
import { aiGenerationClient } from "../api/aiGenerationClient";
import {
buildLocalTimeoutMessage,
buildTaskFailureInfo,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../utils/taskLifecycle";
type PollCallback = (item: GenerationQueueItem) => void;
@@ -13,7 +7,7 @@ const activePollers = new Map<string, ReturnType<typeof setInterval>>();
const pollCallbacks = new Set<PollCallback>();
const POLL_INTERVAL = 3000;
const MAX_POLL_ATTEMPTS = 200; // Keep the previous 10-minute guard as a fallback.
const MAX_POLL_ATTEMPTS = 200; // 10 minutes max per task
export function subscribeToTaskUpdates(callback: PollCallback): () => void {
pollCallbacks.add(callback);
@@ -24,25 +18,10 @@ function notifyCallbacks(item: GenerationQueueItem): void {
pollCallbacks.forEach((cb) => cb(item));
}
function getQueueItemKind(item: GenerationQueueItem): "image" | "video" | "text" {
if (item.type === "image") return "image";
if (item.type === "video" || item.type === "ecommerce-video") return "video";
return "text";
}
function getQueueItemModel(item: GenerationQueueItem): string | undefined {
return typeof item.params?.model === "string" ? item.params.model : undefined;
}
function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void {
const key = `poll-${item.id}`;
if (activePollers.has(key)) return;
const kind = getQueueItemKind(item);
const timeoutPolicy = getTaskTimeoutPolicy({ kind, model: getQueueItemModel(item) });
let lastProgress = Math.max(0, Number(item.progress || 0));
let lastProgressAt = Date.now();
const interval = setInterval(async () => {
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") {
@@ -51,31 +30,18 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
}
attemptsRef.current++;
const timeoutReason = isTaskLocallyTimedOut({
startedAt: current.createdAt || item.createdAt || Date.now(),
lastProgressAt,
progress: lastProgress,
policy: timeoutPolicy,
});
if (timeoutReason || attemptsRef.current > MAX_POLL_ATTEMPTS) {
const error = buildLocalTimeoutMessage(kind);
if (attemptsRef.current > MAX_POLL_ATTEMPTS) {
useGenerationStore.getState().updateTask(item.id, {
status: "failed",
error,
error: "任务超时,请重新提交",
});
notifyCallbacks({ ...item, status: "failed", error });
notifyCallbacks({ ...item, status: "failed", error: "任务超时,请重新提交" });
cleanupPoll(key);
return;
}
try {
const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || "");
const nextProgress = Number(status.progress || 0);
if (nextProgress > lastProgress || status.status === "completed") {
lastProgress = Math.max(lastProgress, nextProgress);
lastProgressAt = Date.now();
}
const patch: Partial<GenerationQueueItem> = {
progress: status.progress,
resultUrl: status.resultUrl || current.resultUrl,
@@ -89,7 +55,6 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
cleanupPoll(key);
} else if (status.status === "failed" || status.status === "cancelled") {
patch.status = "failed";
patch.error = buildTaskFailureInfo(status.error).message;
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "failed" });
cleanupPoll(key);
@@ -99,7 +64,7 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
notifyCallbacks({ ...item, ...patch, status: "running" });
}
} catch {
// Network errors during polling are retried until the lifecycle guard trips.
// Network error during poll — keep trying
}
}, POLL_INTERVAL);
@@ -140,20 +105,24 @@ export function stopAllPolling(): void {
activePollers.clear();
}
// ── Recovery on page load ──────────────────────────
export function recoverAndResumeTasks(): void {
const pendingTasks = useGenerationStore.getState().getRunningTasks();
if (!pendingTasks.length) return;
pendingTasks.forEach((task) => {
if (task.taskId) {
// Mark as pending so the workbench/ecommerce can re-submit to polling
useGenerationStore.getState().updateTask(task.id, { status: "pending" });
} else {
// No taskId means it was queued but never submitted — mark failed
useGenerationStore.getState().updateTask(task.id, {
status: "failed",
error: "页面刷新后任务没有服务端 ID,已释放本地占用,请重新提交",
error: "页面刷新后任务丢失,请重新提交",
});
}
});
// Start polling recovered tasks
setTimeout(() => startBackgroundPolling(), 500);
}
-1
View File
@@ -20,7 +20,6 @@
@import "./pages/studio-layout.css";
@import "./pages/image-workbench.css";
@import "./pages/subtitle-removal.css";
@import "./pages/dialog-generator.css";
@import "./pages/size-template.css";
@import "./pages/script-tokens-v5.css";
@import "./pages/script-tokens.css";
-12
View File
@@ -1,13 +1 @@
/* Agent page rules move here as they are retired from legacy-pages.css. */
@media (max-width: 900px) {
.agent-page {
padding: 16px 16px 80px;
}
}
@media (max-width: 560px) {
.agent-page {
padding: 12px 10px 80px;
}
}
-21
View File
@@ -243,24 +243,3 @@
max-height: calc(100vh - 190px);
}
}
@media (max-width: 560px) {
.asset-preview-modal {
padding: 8px;
}
.asset-preview-modal__panel {
width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
border-radius: 12px;
}
.asset-preview-modal__body {
padding: 8px;
}
.asset-preview-modal__body img,
.asset-preview-modal__body video {
max-height: calc(100vh - 160px);
}
}
-27
View File
@@ -4211,30 +4211,3 @@
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 560px) {
.avatar-console-page {
height: auto;
min-height: 100%;
overflow: auto;
}
.avatar-console-main {
min-height: 100vh;
padding-bottom: 80px;
}
.avatar-console-scroll {
padding: 10px;
}
.avatar-console-toolbar {
flex-wrap: wrap;
gap: 6px;
}
.avatar-console-video-actions {
flex-direction: column;
gap: 8px;
}
}
-279
View File
@@ -722,282 +722,3 @@
right: -9999px;
height: 1px;
}
/* Tool Modal Overlay */
.studio-canvas-tool-modal-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
}
.studio-canvas-tool-modal {
position: relative;
width: 90vw;
max-width: 720px;
max-height: 80vh;
overflow-y: auto;
border-radius: 16px;
background: var(--bg-panel);
border: 1px solid var(--border-subtle);
box-shadow: var(--shadow-heavy, 0 12px 40px rgba(0,0,0,0.4));
padding: 24px;
}
.studio-canvas-tool-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.studio-canvas-tool-modal__title {
font-size: 16px;
font-weight: 600;
color: var(--fg-default);
}
.studio-canvas-tool-modal__close {
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: var(--bg-subtle);
color: var(--fg-muted);
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.studio-canvas-tool-modal__close:hover {
background: var(--bg-hover);
}
/* Tool Panel Components */
.studio-canvas-tool-panel {
display: flex;
gap: 20px;
min-height: 280px;
}
.studio-canvas-tool-panel--inpaint {
flex-direction: column;
}
.studio-canvas-tool-panel__preview {
flex: 0 0 260px;
border-radius: 10px;
overflow: hidden;
background: var(--bg-subtle);
display: flex;
align-items: center;
justify-content: center;
}
.studio-canvas-tool-panel__preview img {
width: 100%;
height: 100%;
object-fit: contain;
}
.studio-canvas-tool-panel__controls {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.studio-canvas-tool-panel__label {
font-size: 13px;
font-weight: 500;
color: var(--fg-muted);
}
.studio-canvas-tool-panel__options {
display: flex;
gap: 8px;
}
.studio-canvas-tool-panel__options button {
padding: 6px 16px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-subtle);
color: var(--fg-default);
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.studio-canvas-tool-panel__options button.is-active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.studio-canvas-tool-panel__textarea {
width: 100%;
min-height: 60px;
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-subtle);
color: var(--fg-default);
font-size: 13px;
resize: vertical;
}
.studio-canvas-tool-panel__submit {
margin-top: auto;
padding: 10px 20px;
border-radius: 8px;
border: none;
background: var(--accent);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.studio-canvas-tool-panel__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.studio-canvas-tool-panel__actions {
display: flex;
gap: 10px;
margin-top: auto;
}
.studio-canvas-tool-panel__reset {
padding: 10px 16px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-subtle);
color: var(--fg-default);
font-size: 13px;
cursor: pointer;
}
.studio-canvas-tool-panel__canvas-wrap {
position: relative;
width: 100%;
max-height: 320px;
border-radius: 10px;
overflow: hidden;
background: var(--bg-subtle);
}
.studio-canvas-tool-panel__canvas-bg {
width: 100%;
height: auto;
display: block;
}
.studio-canvas-tool-panel__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
cursor: crosshair;
}
/*
Responsive: Canvas
Breakpoints: 900px / 560px
*/
@media (max-width: 900px) {
.studio-canvas-page {
flex-direction: column;
}
.studio-canvas-toolbar {
flex: 0 0 auto;
flex-direction: row;
width: 100%;
padding: 8px 12px;
gap: 6px;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border-weak);
-webkit-overflow-scrolling: touch;
}
.studio-canvas-toolbar__group {
flex-direction: row;
flex-shrink: 0;
gap: 4px;
}
.studio-canvas-toolbar button {
min-width: 36px;
height: 36px;
font-size: 14px;
}
.studio-canvas-main {
flex: 1;
min-height: 0;
}
.studio-canvas-node {
min-width: 140px;
min-height: 60px;
}
}
@media (max-width: 560px) {
.studio-canvas-page {
flex-direction: column;
}
.studio-canvas-toolbar {
flex: 0 0 auto;
padding: 6px 8px;
gap: 4px;
border-bottom: 1px solid var(--border-weak);
}
.studio-canvas-toolbar button {
min-width: 30px;
height: 30px;
padding: 0 6px;
font-size: 12px;
border-radius: 8px;
}
.studio-canvas-main {
flex: 1;
min-height: 0;
}
.studio-canvas-node {
min-width: 110px;
min-height: 48px;
border-radius: 10px;
padding: 8px;
}
.studio-canvas-project-bar {
right: 8px;
left: 8px;
bottom: 80px;
gap: 4px;
border-radius: 10px;
padding: 8px 10px;
}
.studio-canvas-project-bar__name-form {
font-size: 12px;
}
}
-49
View File
@@ -117,52 +117,3 @@
font-weight: 600;
line-height: 1.6;
}
/*
Responsive: Community
Breakpoints: 900px / 560px
*/
@media (max-width: 900px) {
.community-page {
padding: 12px 16px 80px;
}
.community-carousel__slide--video {
min-height: 200px;
}
.community-carousel__video {
max-height: 220px;
}
.community-case-card__preview {
min-height: 140px;
}
}
@media (max-width: 560px) {
.community-page {
padding: 10px 10px 80px;
}
.community-carousel__slide--video {
min-height: 160px;
}
.community-carousel__video {
max-height: 180px;
}
.community-case-card__preview {
min-height: 110px;
}
.community-case-empty {
padding: 32px 16px;
}
.community-case-empty__icon {
font-size: 18px;
}
}
-580
View File
@@ -1,580 +0,0 @@
.dialog-generator-page {
min-height: 100%;
overflow: auto;
background:
radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0, 255, 136, 0.04) 0%, transparent 70%),
radial-gradient(ellipse 60% 50% at 80% 70%, rgba(42, 159, 212, 0.03) 0%, transparent 60%),
linear-gradient(180deg, #070b10 0%, #05080d 100%);
color: #e8eaef;
}
.dialog-generator-shell {
display: grid;
grid-template-columns: minmax(300px, 0.42fr) minmax(0, 0.58fr);
gap: clamp(18px, 2.8vw, 34px);
min-height: var(--shell-content-height, 100vh);
padding: clamp(24px, 4vw, 52px);
}
.dialog-generator-panel,
.dialog-generator-preview-card {
border: 1px solid rgba(0, 255, 136, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
box-shadow:
0 24px 72px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(18px);
}
.dialog-generator-panel {
display: grid;
align-content: start;
gap: 24px;
padding: clamp(22px, 2.6vw, 34px);
}
.dialog-generator-heading {
display: grid;
gap: 12px;
}
.dialog-generator-kicker {
color: #00ff88;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dialog-generator-heading h1 {
margin: 0;
background: linear-gradient(135deg, #00ff88, #22f0c0, #4fc3f7);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-size: clamp(32px, 3.6vw, 56px);
font-weight: 950;
letter-spacing: 0;
line-height: 1.1;
}
.dialog-generator-heading p,
.dialog-generator-hint,
.dialog-generator-preview-head p {
margin: 0;
color: #9aa1b8;
font-size: 15px;
font-weight: 650;
line-height: 1.7;
}
.dialog-generator-section {
display: grid;
gap: 12px;
}
.dialog-generator-section h2 {
margin: 0;
color: #f6f8fb;
font-size: 18px;
font-weight: 900;
}
.dialog-generator-drop {
display: grid;
justify-items: center;
gap: 8px;
min-height: 168px;
border: 1px dashed rgba(0, 255, 136, 0.28);
border-radius: 8px;
background: rgba(0, 255, 136, 0.035);
color: #e8eaef;
padding: 24px;
cursor: pointer;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-drop:hover {
border-color: rgba(0, 255, 136, 0.5);
background: rgba(0, 255, 136, 0.06);
transform: translateY(-1px);
}
.dialog-generator-drop-icon {
font-size: 42px;
}
.dialog-generator-drop strong {
font-size: 16px;
font-weight: 900;
}
.dialog-generator-drop small,
.dialog-generator-style small {
color: #62697f;
font-size: 13px;
font-weight: 700;
}
.dialog-generator-style-list {
display: grid;
gap: 10px;
}
.dialog-generator-color-picker {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.dialog-generator-color {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 38px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #dce3ed;
cursor: pointer;
font-size: 13px;
font-weight: 850;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-color:hover,
.dialog-generator-color.is-active {
border-color: var(--text-color);
background: rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
}
.dialog-generator-color span {
width: 14px;
height: 14px;
border: 1px solid rgba(255, 255, 255, 0.38);
border-radius: 50%;
background: var(--text-color);
box-shadow: 0 0 12px color-mix(in srgb, var(--text-color) 42%, transparent);
}
.dialog-generator-color strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog-generator-style {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 14px;
border: 1px solid rgba(0, 255, 136, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #e8eaef;
padding: 15px 18px;
text-align: left;
cursor: pointer;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-style:hover {
border-color: rgba(0, 255, 136, 0.28);
background: rgba(255, 255, 255, 0.06);
transform: translateX(3px);
}
.dialog-generator-style span:last-child {
display: grid;
gap: 4px;
min-width: 0;
}
.dialog-generator-style strong {
color: #f7fafc;
font-size: 16px;
font-weight: 900;
}
.dialog-generator-swatch {
width: 14px;
height: 14px;
border-radius: 4px;
}
.dialog-generator-swatch.is-white {
border: 1px solid #cbd5e1;
background: #ffffff;
}
.dialog-generator-swatch.is-blue {
background: #165dff;
}
.dialog-generator-swatch.is-amber {
background: #f59e0b;
}
.dialog-generator-swatch.is-gray {
background: #6b7280;
}
.dialog-generator-clear {
min-height: 48px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #e8eaef;
cursor: pointer;
font-size: 15px;
font-weight: 900;
transition:
border-color 180ms ease,
background 180ms ease;
}
.dialog-generator-clear:hover {
border-color: rgba(255, 77, 103, 0.32);
background: rgba(255, 77, 103, 0.1);
}
.dialog-generator-preview-card {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 16px;
min-width: 0;
min-height: 0;
padding: clamp(22px, 2.6vw, 34px);
}
.dialog-generator-preview-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.dialog-generator-preview-head span {
color: #00ff88;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dialog-generator-preview-head h2 {
margin: 4px 0 0;
color: #ffffff;
font-size: clamp(24px, 2vw, 34px);
font-weight: 950;
}
.dialog-generator-preview-head p {
max-width: 440px;
text-align: right;
}
.dialog-generator-preview {
position: relative;
min-height: 520px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
rgba(5, 8, 13, 0.72);
background-size: 32px 32px, 32px 32px, auto;
touch-action: none;
}
.dialog-generator-image {
position: absolute;
inset: 0;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
.dialog-generator-empty {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 12px;
color: #62697f;
text-align: center;
pointer-events: none;
}
.dialog-generator-empty span {
font-size: 52px;
}
.dialog-generator-empty p {
margin: 0;
font-size: 16px;
font-weight: 800;
}
.dialog-generator-bubble {
position: absolute;
z-index: 10;
min-width: 140px;
max-width: 280px;
border-radius: 12px;
padding: 12px 14px;
user-select: none;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
transition: box-shadow 0.2s;
}
.dialog-generator-bubble.is-confirmed {
min-width: 0;
max-width: min(420px, 80%);
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
box-shadow: none;
cursor: move;
}
.dialog-generator-bubble:hover {
box-shadow: 0 6px 32px rgba(0, 0, 0, 0.18);
}
.dialog-generator-bubble.is-confirmed:hover {
box-shadow: none;
}
.dialog-generator-bubble.is-dragging {
z-index: 20;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.22);
}
.dialog-generator-bubble.is-confirmed.is-dragging {
box-shadow: none;
}
.dialog-generator-bubble.style1 {
border: 2px solid #cbd5e1;
background: rgba(255, 255, 255, 0.97);
}
.dialog-generator-bubble.style2 {
border: 2px solid #4f8aff;
border-radius: 16px 16px 4px 16px;
background: rgba(22, 93, 255, 0.95);
}
.dialog-generator-bubble.style3 {
border: 2px solid #f59e0b;
background: rgba(255, 247, 237, 0.97);
}
.dialog-generator-bubble.style4 {
border: 2px solid #6b7280;
border-radius: 4px;
background: rgba(248, 250, 252, 0.97);
}
.dialog-generator-bubble.is-confirmed.style1,
.dialog-generator-bubble.is-confirmed.style2,
.dialog-generator-bubble.is-confirmed.style3,
.dialog-generator-bubble.is-confirmed.style4 {
border: 0;
background: transparent;
}
.dialog-generator-delete {
position: absolute;
top: -8px;
right: -8px;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 2px solid #fff;
border-radius: 50%;
background: #ef4444;
color: #fff;
cursor: pointer;
font-size: 13px;
line-height: 1;
opacity: 0;
transition: opacity 0.15s;
z-index: 5;
}
.dialog-generator-bubble:hover .dialog-generator-delete {
opacity: 1;
}
.dialog-generator-text,
.dialog-generator-text-display {
width: 100%;
border: 0;
outline: none;
background: transparent;
color: var(--dialog-text-color, #1e293b);
padding: 0;
resize: none;
font-family: inherit;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.dialog-generator-text-display {
width: max-content;
max-width: min(420px, 80vw);
color: var(--dialog-text-color, #ffffff);
font-size: clamp(18px, 2.2vw, 30px);
font-weight: 900;
line-height: 1.35;
letter-spacing: 0;
overflow-wrap: anywhere;
text-shadow:
0 2px 8px rgba(0, 0, 0, 0.72),
0 0 1px rgba(0, 0, 0, 0.9);
}
.dialog-generator-text::placeholder {
color: rgba(0, 0, 0, 0.3);
}
.dialog-generator-bubble.style2 .dialog-generator-text,
.dialog-generator-bubble.style2 .dialog-generator-text-display {
color: var(--dialog-text-color, #fff);
}
.dialog-generator-bubble.is-confirmed.style2 .dialog-generator-text-display {
color: var(--dialog-text-color, #7fb4ff);
}
.dialog-generator-bubble.style2 .dialog-generator-text::placeholder {
color: rgba(255, 255, 255, 0.62);
}
.dialog-generator-bubble.style3 .dialog-generator-text,
.dialog-generator-bubble.style3 .dialog-generator-text-display {
color: var(--dialog-text-color, #92400e);
}
.dialog-generator-bubble.is-confirmed.style3 .dialog-generator-text-display {
color: var(--dialog-text-color, #ffd76a);
}
.dialog-generator-bubble.style3 .dialog-generator-text::placeholder {
color: rgba(146, 64, 14, 0.4);
}
.dialog-generator-bubble.style4 .dialog-generator-text,
.dialog-generator-bubble.style4 .dialog-generator-text-display {
color: var(--dialog-text-color, #1f2937);
}
.dialog-generator-bubble.is-confirmed.style4 .dialog-generator-text-display {
color: var(--dialog-text-color, #111827);
text-shadow:
0 1px 0 rgba(255, 255, 255, 0.72),
0 0 8px rgba(255, 255, 255, 0.58);
}
.dialog-generator-bubble-bottom {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 6px;
}
.dialog-generator-confirm {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 6px;
background: #165dff;
color: #fff;
cursor: pointer;
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
transition:
filter 0.15s,
transform 0.15s;
}
.dialog-generator-confirm:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.dialog-generator-bubble.style2 .dialog-generator-confirm {
background: #fff;
color: #165dff;
}
.dialog-generator-bubble.style3 .dialog-generator-confirm {
background: #f59e0b;
}
.dialog-generator-bubble.style4 .dialog-generator-confirm {
background: #6b7280;
}
.dialog-generator-edit-hint {
display: none;
color: rgba(0, 0, 0, 0.36);
font-size: 10px;
font-weight: 700;
}
.dialog-generator-bubble.is-confirmed .dialog-generator-confirm {
display: none;
}
.dialog-generator-bubble.is-confirmed .dialog-generator-edit-hint {
display: inline-block;
}
@media (max-width: 980px) {
.dialog-generator-shell {
grid-template-columns: 1fr;
}
.dialog-generator-preview-head {
align-items: start;
flex-direction: column;
}
.dialog-generator-preview-head p {
max-width: none;
text-align: left;
}
}
@media (max-width: 560px) {
.dialog-generator-shell {
padding: 18px;
}
.dialog-generator-preview {
min-height: 420px;
}
}
-179
View File
@@ -418,15 +418,6 @@
cursor: not-allowed;
}
.product-clone-page[data-tool="set"] .product-set-floating-submit--cancel {
background: #303540;
color: #eef2f6;
}
.product-clone-page[data-tool="set"] .product-set-floating-submit--cancel:hover {
background: #3a4050;
}
.product-clone-page[data-tool="set"] .product-clone-help {
display: none;
}
@@ -3985,7 +3976,6 @@
.product-clone-panel__footer {
display: grid;
align-items: center;
gap: 8px;
border-top: 1px solid #e5e7eb;
padding: 12px 16px;
}
@@ -4010,11 +4000,6 @@
cursor: not-allowed;
}
.product-clone-primary--cancel {
background: #303540;
color: #eef2f6;
}
.product-clone-preview {
display: grid;
align-content: center;
@@ -4945,7 +4930,6 @@
}
.product-set-main-card {
position: relative;
height: 380px;
border-radius: 16px;
transition: transform 250ms ease, box-shadow 250ms ease;
@@ -8382,147 +8366,6 @@
.clone-ai-adwizard__risk.is-low { color: #52c41a; }
.clone-ai-adwizard__risk.is-medium { color: #faad14; }
.clone-ai-adwizard__risk.is-high { color: #ff4d4f; }
/*
Responsive: Ecommerce Tools
Breakpoints: 1180px / 900px / 560px
*/
/* ── 1180px: Narrower panels ── */
@media (max-width: 1180px) {
.product-clone-page[data-tool="clone"] > .product-clone-shell {
grid-template-columns: minmax(360px, 440px) minmax(0, 1fr);
}
.product-clone-page[data-tool="set"] > .product-clone-shell {
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
}
}
/* ── 900px: Tablet — stacked layout ── */
@media (max-width: 900px) {
/* All tools: collapse shell to single column */
.product-clone-page > .product-clone-shell,
.product-clone-page[data-tool="clone"] > .product-clone-shell,
.product-clone-page[data-tool="set"] > .product-clone-shell {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
}
/* Rail becomes horizontal tab bar */
.product-clone-rail {
display: flex !important;
flex-direction: row;
gap: 4px;
width: 100%;
height: auto;
padding: 8px 12px;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border-weak, #2a3032);
-webkit-overflow-scrolling: touch;
}
.product-clone-rail button {
flex-shrink: 0;
white-space: nowrap;
flex-direction: row;
gap: 6px;
padding: 8px 14px;
font-size: 12px;
}
/* Panel: full-width, reduced height */
.product-clone-panel {
width: 100%;
max-height: 45vh;
overflow-y: auto;
}
.product-clone-page[data-tool="clone"] .product-clone-panel {
--clone-settings-panel-width: 100%;
max-height: 45vh;
}
/* Preview: independent scroll area */
.product-clone-preview {
min-height: 40vh;
overflow-y: auto;
padding: 20px 16px;
}
.product-clone-page[data-tool="set"] .product-clone-preview {
padding: 20px 16px;
}
/* Collapse toggle tweak */
.product-clone-page[data-tool="clone"] .product-clone-panel {
transition: max-height 320ms ease, opacity 320ms ease;
}
.product-clone-page.is-settings-collapsed .product-clone-panel {
max-height: 0;
overflow: hidden;
opacity: 0;
}
}
/* ── 560px: Phone — further compression ── */
@media (max-width: 560px) {
.product-clone-page {
grid-template-rows: 44px minmax(0, 1fr);
}
.product-clone-rail {
padding: 6px 8px;
gap: 2px;
}
.product-clone-rail button {
padding: 6px 10px;
font-size: 11px;
gap: 4px;
}
.product-clone-panel {
max-height: 38vh;
}
.product-clone-page[data-tool="clone"] .product-clone-panel {
max-height: 38vh;
}
.product-clone-page[data-tool="set"] .product-clone-panel__scroll {
padding: 20px 14px 24px;
}
.product-clone-page[data-tool="set"] :is(.product-set-upload-section, .product-set-settings-section) {
padding: 18px;
border-radius: 14px;
margin-bottom: 18px;
}
.product-clone-page[data-tool="set"] .product-set-upload {
min-height: 180px;
padding: 16px;
gap: 8px;
}
.product-clone-page[data-tool="set"] .product-set-upload-title {
font-size: 15px;
}
.product-clone-page[data-tool="set"] .product-set-upload strong {
height: 42px;
min-width: 140px;
font-size: 16px;
}
/* Preview full-width */
.product-clone-preview {
padding: 12px 10px !important;
min-height: 50vh;
}
}
.clone-ai-adwizard__issues { margin: 0; padding-left: 16px; font-size: 12px; display: flex; flex-direction: column; gap: 4px; }
/* ===== Ecommerce Template Apple Carousel ===== */
@@ -8916,17 +8759,6 @@
filter: none;
}
.product-clone-page[data-tool="clone"] .clone-ai-generate--cancel {
border: 1px solid var(--ecm-line);
background: var(--ecm-inset);
color: var(--ecm-text);
box-shadow: none;
}
.product-clone-page[data-tool="clone"] .clone-ai-generate--cancel:hover:not(:disabled) {
background: var(--ecm-inset-hover);
}
.product-clone-page[data-tool="clone"] .clone-ai-settings-toggle {
border-color: var(--ecm-line-strong);
background: rgba(20, 23, 25, 0.86);
@@ -9152,17 +8984,6 @@
box-shadow: none;
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(.product-clone-primary--cancel, .product-set-floating-submit--cancel) {
border: 1px solid var(--ecm-line);
background: var(--ecm-inset);
color: var(--ecm-text);
box-shadow: none;
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(.product-clone-primary--cancel, .product-set-floating-submit--cancel):hover {
background: var(--ecm-inset-hover);
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) .product-clone-preview {
background:
radial-gradient(circle at 50% 40%, rgba(var(--ecm-accent-rgb), 0.032), transparent 40%),
-25
View File
@@ -1776,28 +1776,3 @@ textarea.image-workbench-prompt {
.image-workbench-result-card:nth-child(2) { animation-delay: 0.08s; }
.image-workbench-result-card:nth-child(3) { animation-delay: 0.16s; }
.image-workbench-result-card:nth-child(4) { animation-delay: 0.24s; }
@media (max-width: 900px) {
.image-workbench-page {
padding: 12px;
}
.image-workbench-toolbar {
flex-wrap: wrap;
gap: 8px;
}
}
@media (max-width: 560px) {
.image-workbench-page {
padding: 8px 8px 80px;
}
.image-workbench-toolbar {
gap: 4px;
}
.image-workbench-toolbar button {
min-width: 36px;
height: 36px;
padding: 0 10px;
font-size: 12px;
}
}
-20
View File
@@ -1286,23 +1286,3 @@ textarea.image-workbench-prompt,
justify-items: start;
}
}
@media (max-width: 560px) {
.token-usage-page {
padding: 0 8px 80px;
}
.token-usage-page .script-token-page__scroll,
.script-token-page__scroll {
padding-inline: 8px;
}
.token-member-card {
padding: 14px;
gap: 8px;
}
.token-member-card__head {
gap: 8px;
}
}
-36
View File
@@ -313,39 +313,3 @@
font-size: 15px;
}
}
@media (max-width: 560px) {
.more-page-v2 {
padding-left: 0;
}
.more-page-v2__header {
padding: 10px 12px 8px;
gap: 8px;
}
.more-page-v2__scroll {
padding: 12px 10px 72px;
}
.more-page-v2__grid {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.more-page-v2__featured-grid {
gap: 10px;
}
.more-card--featured {
padding: 14px;
gap: 10px;
}
.more-card__featured-icon {
width: 36px;
height: 36px;
border-radius: 10px;
font-size: 18px;
}
}
+5 -21
View File
@@ -2,32 +2,16 @@
/* ── 代表作滚动容器:固定3列,刚好显示9个(3行),超出可滚动,隐藏滚动条 ── */
.profile-page__works-scroll {
max-height: 390px;
max-height: 390px; /* 3行卡片:3 × 120(min-height) + 2 × 10(gap) = 380px,留10px余量 */
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.profile-page__works-scroll::-webkit-scrollbar {
display: none;
display: none; /* Chrome/Safari/Edge */
}
.profile-page__works-scroll .profile-page__list-grid {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 900px) {
.profile-page,
.auth-page,
.settings-page {
padding: 16px 16px 80px;
}
}
@media (max-width: 560px) {
.profile-page,
.auth-page,
.settings-page {
padding: 12px 10px 80px;
}
grid-template-columns: repeat(3, 1fr); /* 固定3列,刚好3×3=9个可见 */
}
-37
View File
@@ -164,40 +164,3 @@
.provider-health-table tr:hover td {
background: var(--surface-elevated);
}
@media (max-width: 900px) {
.provider-health-page__inner {
padding: 16px;
gap: 16px;
}
.provider-health-grid {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
}
@media (max-width: 560px) {
.provider-health-page__inner {
padding: 12px 12px 80px;
gap: 12px;
}
.provider-health-grid {
grid-template-columns: 1fr;
gap: 10px;
}
.provider-health-toolbar {
flex-direction: column;
gap: 8px;
}
.provider-health-table {
font-size: 11px;
}
.provider-health-table th,
.provider-health-table td {
padding: 6px 8px;
}
}
-156
View File
@@ -2802,10 +2802,6 @@
color: #e9fff5;
font-size: 14px;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 8em;
}
.script-eval-v5-uf-size {
@@ -3234,141 +3230,6 @@
color: var(--report-green);
}
.script-eval-report__detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin: 0 18px 18px;
}
.script-eval-report__detail-card {
min-width: 0;
border: 1px solid rgb(255 255 255 / 6%);
border-radius: var(--v5-radius-md);
background: linear-gradient(180deg, rgb(255 255 255 / 3.4%), transparent), var(--report-row);
padding: 14px;
}
.script-eval-report__detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.script-eval-report__detail-head div {
display: grid;
gap: 4px;
min-width: 0;
}
.script-eval-report__detail-head span {
color: #dfe8e4;
font-size: 14px;
font-weight: 800;
}
.script-eval-report__detail-head strong {
color: var(--report-green);
font-size: 22px;
line-height: 1;
}
.script-eval-report__detail-head small {
color: #7e8a86;
font-size: 12px;
}
.script-eval-report__detail-head em {
flex-shrink: 0;
border: 1px solid rgb(0 255 136 / 18%);
border-radius: 999px;
background: rgb(0 255 136 / 7%);
color: #98e8bd;
padding: 4px 8px;
font-size: 12px;
font-style: normal;
font-weight: 800;
}
.script-eval-report__detail-hint,
.script-eval-report__detail-empty {
margin: 10px 0 0;
color: #7f8c88;
font-size: 12px;
font-weight: 650;
line-height: 1.5;
}
.script-eval-report__subscore-list {
display: grid;
gap: 9px;
margin-top: 13px;
}
.script-eval-report__subscore-row {
display: grid;
grid-template-columns: minmax(62px, 86px) minmax(0, 1fr) 34px;
align-items: center;
gap: 8px;
color: #bdcbc6;
font-size: 12px;
font-weight: 750;
}
.script-eval-report__subscore-row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.script-eval-report__subscore-row b {
color: #dfe8e4;
text-align: right;
}
.script-eval-report__subscore-bar {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: rgb(255 255 255 / 5%);
}
.script-eval-report__subscore-bar i {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #18de8a, #a3f7c1);
}
.script-eval-report__evidence-list {
display: grid;
gap: 7px;
margin: 13px 0 0;
padding: 0;
list-style: none;
}
.script-eval-report__evidence-list li {
position: relative;
padding-left: 13px;
color: #aebcb7;
font-size: 12px;
font-weight: 650;
line-height: 1.55;
}
.script-eval-report__evidence-list li::before {
content: "";
position: absolute;
left: 0;
top: 0.62em;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--report-green);
}
.script-eval-report__findings {
gap: 18px;
}
@@ -3417,10 +3278,6 @@
.script-eval-report--inside .script-eval-report__body {
padding-inline: 24px;
}
.script-eval-report__detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
@@ -3440,19 +3297,6 @@
}
@media (max-width: 680px) {
.script-eval-report__detail-grid {
grid-template-columns: 1fr;
margin-inline: 0;
}
.script-eval-report__detail-card {
padding: 12px;
}
.script-eval-report__subscore-row {
grid-template-columns: minmax(58px, 78px) minmax(0, 1fr) 30px;
}
.script-eval-v5 {
overflow: hidden;
}
-21
View File
@@ -474,24 +474,3 @@
width: min(100%, 430px);
}
}
@media (max-width: 560px) {
.size-template-page {
padding: 12px 10px 80px;
}
.size-template-main-frame {
grid-template-columns: 1fr;
gap: 12px;
}
.size-template-spec-grid {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.size-template-preview-note,
.size-template-detail-card {
width: 100%;
}
}
-16
View File
@@ -674,19 +674,3 @@
max-height: 320px;
}
}
@media (max-width: 560px) {
.studio-tool-layout {
grid-template-rows: 40px auto minmax(200px, 1fr) 32px;
}
.studio-tool-layout__toolstrip {
padding: 6px 8px;
gap: 4px;
}
.studio-panel {
max-height: 240px;
padding: 12px;
}
}
-12
View File
@@ -133,15 +133,3 @@
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 900px) {
.subtitle-removal-page {
padding: 12px;
}
}
@media (max-width: 560px) {
.subtitle-removal-page {
padding: 8px 8px 80px;
}
}
+2 -2
View File
@@ -202,9 +202,9 @@
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: repeat(2, minmax(0, 1fr));
grid-template-rows: 1fr 1fr;
gap: 16px;
min-height: clamp(560px, 52vw, 760px);
min-height: clamp(360px, 40vw, 520px);
}
/* ===== Tool Cards ===== */
+8 -142
View File
@@ -9,10 +9,10 @@
.wb-prompt-cases__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-auto-flow: dense;
grid-auto-rows: 10px;
gap: 8px;
gap: 10px;
}
.wb-prompt-case-card {
@@ -34,22 +34,22 @@
.wb-prompt-case-card--ratio-wide {
grid-column: span 1;
grid-row: span 13;
grid-row: span 8;
}
.wb-prompt-case-card--ratio-tall {
grid-column: span 1;
grid-row: span 30;
grid-row: span 23;
}
.wb-prompt-case-card--ratio-square {
grid-column: span 1;
grid-row: span 18;
grid-row: span 13;
}
.wb-prompt-case-card--ratio-portrait {
grid-column: span 1;
grid-row: span 24;
grid-row: span 16;
}
.wb-prompt-case-card img {
@@ -328,7 +328,7 @@
@media (max-width: 980px) {
.wb-prompt-cases__grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
grid-auto-rows: 8px;
gap: 8px;
}
@@ -387,7 +387,7 @@
@media (max-width: 560px) {
.wb-prompt-cases__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-auto-rows: 8px;
gap: 8px;
}
@@ -440,137 +440,3 @@
grid-template-columns: 1fr;
}
}
/*
Responsive: Workbench Launch & Active State
Breakpoints: 900px / 560px
*/
/* ── 900px: Tablet — launch state ── */
@media (max-width: 900px) {
.wb-home {
padding: 36px 16px 80px;
gap: 18px;
}
.wb-home__title {
font-size: clamp(24px, 6vw, 36px);
}
.wb-home__composer {
max-width: 100%;
}
.wb-home__suggestions {
max-width: 100%;
}
.wb-showcase {
max-width: 100%;
}
.wb-showcase__grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
/* Active state: message thread */
.ai-workbench-content-scroll {
width: 100%;
padding: 16px 12px;
}
.ai-chat-message-stack {
max-width: 86%;
}
.ai-chat-message-bubble {
padding: 10px 12px;
font-size: 13px;
}
/* Composer in active state */
.wb-composer {
width: 100%;
padding: 8px 0 12px;
}
}
/* ── 560px: Phone — full-width compact ── */
@media (max-width: 560px) {
.wb-home {
padding: 24px 10px 72px;
gap: 14px;
}
.wb-home__title {
font-size: clamp(20px, 7vw, 28px);
}
.wb-home__glow {
width: 240px;
height: 120px;
top: -40px;
}
.wb-showcase__grid {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.wb-suggestion-chip {
font-size: 12px;
padding: 7px 12px;
}
/* Active state: messages fill screen */
.ai-workbench-content-scroll {
padding: 12px 8px;
}
.ai-workbench-thread-shell {
max-width: 100%;
}
.ai-chat-message-list {
gap: 8px;
}
.ai-chat-message-stack {
max-width: 90%;
}
.ai-chat-message-bubble {
padding: 8px 10px;
font-size: 13px;
border-radius: 12px;
}
.ai-chat-message-bubble--user {
border-radius: 12px 12px 4px 12px;
}
.ai-chat-message-bubble--ai {
border-radius: 12px 12px 12px 4px;
}
.ai-chat-avatar {
width: 26px;
height: 26px;
flex: 0 0 26px;
font-size: 10px;
}
.ai-chat-message-row {
gap: 6px;
}
/* Composer at bottom */
.wb-composer {
padding: 6px 0 10px;
}
.wb-chat-scroll-actions {
display: none;
}
}
-46
View File
@@ -896,21 +896,6 @@
cursor: pointer;
}
@media (max-width: 1180px) {
.brand-lockup__tone {
display: none;
}
.creator-button {
display: none;
}
.member-button {
max-width: 148px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@media (max-width: 900px) {
.web-shell {
overflow: hidden;
@@ -977,7 +962,6 @@
}
}
/* ── 640px: Narrower topbar adjustments ── */
@media (max-width: 640px) {
.brand-lockup__name {
font-size: 14px;
@@ -997,33 +981,3 @@
justify-content: space-between;
}
}
/* ── 560px: Phone-sized compact layout ── */
@media (max-width: 560px) {
.web-topbar {
flex: 0 0 44px;
padding: 6px 10px;
gap: 6px;
}
.brand-lockup {
gap: 6px;
}
.brand-lockup__mark {
width: 24px;
height: 24px;
border-radius: 6px;
}
.brand-lockup__name {
font-size: 13px;
max-width: 80px;
}
.profile-button,
.icon-button {
min-width: 36px;
height: 36px;
}
}
+5 -701
View File
@@ -2376,48 +2376,6 @@
border-color: rgba(239, 68, 68, 0.6);
}
.web-shell[data-ui-theme="dark-green"] .ai-chat-failed-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.web-shell[data-ui-theme="dark-green"] .ai-chat-failed-actions button {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 6px 12px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 6px;
background: rgba(15, 23, 42, 0.62);
color: rgba(226, 232, 240, 0.92);
font-size: 12px;
cursor: pointer;
}
.web-shell[data-ui-theme="dark-green"] .ai-chat-failed-actions button:hover {
border-color: rgba(56, 189, 248, 0.42);
background: rgba(8, 47, 73, 0.45);
}
.web-shell[data-ui-theme="dark-green"] .ai-chat-failed-actions__release {
color: #fbbf24;
}
.web-shell[data-ui-theme="dark-green"] .ai-chat-task-billing-note {
margin-top: 10px;
padding: 8px 10px;
border: 1px solid rgba(45, 212, 191, 0.22);
border-radius: 6px;
background: rgba(13, 148, 136, 0.08);
color: rgba(204, 251, 241, 0.86);
font-size: 12px;
line-height: 1.6;
white-space: pre-line;
}
.web-shell[data-ui-theme="dark-green"] .ai-generation-pending-card__bar,
.web-shell[data-ui-theme="dark-green"] .ai-generation-pending-card.is-video .ai-generation-pending-card__bar {
position: absolute;
@@ -4959,332 +4917,6 @@
color: var(--accent);
}
/* Auth page: refined SaaS entry surface, preserving current auth behavior and OSS assets. */
.web-shell[data-ui-theme="dark-green"] .auth-page {
grid-template-columns: minmax(0, 1.55fr) minmax(400px, 0.9fr);
background: var(--dg-page);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase {
background: #0d0d0f;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__video {
opacity: 0.48;
filter: saturate(1.08) contrast(1.04);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__video-overlay {
align-items: center;
justify-content: center;
padding: clamp(36px, 6vw, 76px);
background:
linear-gradient(90deg, rgba(13, 13, 15, 0.86), rgba(13, 13, 15, 0.46) 58%, rgba(13, 13, 15, 0.72)),
linear-gradient(180deg, rgba(13, 13, 15, 0.28), rgba(13, 13, 15, 0.92));
text-align: left;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-content {
display: grid;
gap: 14px;
width: min(620px, 100%);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand-row {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand {
color: var(--accent);
font-size: 64px;
line-height: 0.96;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__tagline {
max-width: 480px;
color: var(--fg-muted);
font-size: 22px;
line-height: 1.45;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__features {
justify-content: flex-start;
gap: 10px;
margin-top: 2px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__features span {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: var(--fg-body);
backdrop-filter: blur(12px);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
width: min(520px, 100%);
margin-top: 10px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-stats span {
display: grid;
gap: 4px;
min-width: 0;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-sm);
background: rgba(16, 18, 20, 0.58);
color: var(--fg-soft);
backdrop-filter: blur(14px);
font-size: 12px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-stats strong {
color: var(--fg-body);
font-size: 14px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-panel {
align-items: center;
padding: clamp(28px, 4vw, 48px);
border-left-color: rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent),
var(--bg-surface);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-inner {
gap: 20px;
max-width: 420px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-head {
gap: 7px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__logo {
width: 54px;
height: 54px;
margin-bottom: 2px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.035);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__title {
font-size: 24px;
line-height: 1.2;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__subtitle {
color: var(--fg-soft);
line-height: 1.5;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__mode-tabs {
gap: 4px;
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.065);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.022);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__mode-tabs button {
min-height: 38px;
padding: 0 12px;
border: 0;
border-radius: 9px;
font-weight: 700;
transition: background var(--transition-fast), color var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__mode-tabs button.is-active {
background: rgba(var(--accent-rgb), 0.13);
color: var(--accent);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button {
min-height: 42px;
padding: 0 8px;
border-color: rgba(255, 255, 255, 0.07);
background: rgba(255, 255, 255, 0.018);
font-size: 12px;
font-weight: 600;
transition: border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button .anticon {
font-size: 14px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button.is-active {
border-color: rgba(var(--accent-rgb), 0.42);
background: rgba(var(--accent-rgb), 0.1);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form {
gap: 14px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field {
gap: 7px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field > span {
color: var(--fg-muted);
font-weight: 700;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field input,
.web-shell[data-ui-theme="dark-green"] .auth-page__phone-row {
border-color: rgba(255, 255, 255, 0.075);
background: rgba(255, 255, 255, 0.026);
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field input {
min-height: 44px;
padding: 0 14px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field input:focus,
.web-shell[data-ui-theme="dark-green"] .auth-page__phone-row:focus-within {
border-color: rgba(var(--accent-rgb), 0.55);
background: var(--bg-elevated);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.08);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field--error input,
.web-shell[data-ui-theme="dark-green"] .auth-page__field--error .auth-page__phone-row {
border-color: rgba(255, 90, 95, 0.64);
box-shadow: 0 0 0 3px rgba(255, 90, 95, 0.08);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-row {
gap: 10px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-btn {
min-height: 44px;
border-color: rgba(var(--accent-rgb), 0.42);
background: rgba(var(--accent-rgb), 0.08);
font-weight: 700;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-btn:disabled {
background: rgba(255, 255, 255, 0.018);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__submit {
min-height: 46px;
padding: 0 16px;
border-radius: var(--radius-sm);
font-size: 15px;
font-weight: 800;
transition: background var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__submit:hover {
transform: translateY(-1px);
box-shadow: 0 12px 28px rgba(var(--accent-rgb), 0.16);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__notice {
border-color: rgba(255, 90, 95, 0.28);
background: rgba(255, 90, 95, 0.09);
line-height: 1.45;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-box {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.022);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-title {
margin: 0;
color: var(--fg-body);
font-size: 13px;
font-weight: 800;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-input {
width: 100%;
min-height: 44px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.026);
color: var(--fg-body);
outline: none;
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-input:focus {
border-color: rgba(var(--accent-rgb), 0.55);
background: var(--bg-elevated);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.08);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-cancel,
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-confirm {
min-height: 38px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 750;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-cancel {
border: 1px solid rgba(255, 255, 255, 0.075);
background: rgba(255, 255, 255, 0.026);
color: var(--fg-muted);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-confirm {
border: 1px solid rgba(var(--accent-rgb), 0.42);
background: rgba(var(--accent-rgb), 0.12);
color: var(--accent);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__agreement {
line-height: 1.55;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__divider {
margin-top: 2px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__social-btn {
width: 42px;
height: 42px;
background: rgba(255, 255, 255, 0.018);
}
.web-shell[data-ui-theme="dark-green"] .profile-page {
height: 100%;
overflow-y: auto;
@@ -5411,12 +5043,6 @@
color: #fff;
box-shadow: none;
font-size: 11px;
opacity: 0;
transition: opacity 0.2s;
}
.web-shell[data-ui-theme="dark-green"] .profile-page__avatar-ring:hover .profile-page__avatar-edit {
opacity: 1;
}
.web-shell[data-ui-theme="dark-green"] .profile-page__avatar-edit:hover {
@@ -6710,14 +6336,6 @@
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit {
width: 82px;
height: 82px;
justify-content: flex-start;
padding-left: 30px;
opacity: 0;
transition: opacity 0.2s;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-ring:hover .profile-page__avatar-edit {
opacity: 1;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-badge {
@@ -6786,75 +6404,6 @@
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: stretch;
min-width: 0;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.055);
border-radius: 11px;
background:
linear-gradient(135deg, rgba(var(--accent-rgb), 0.055), transparent 62%),
rgba(255, 255, 255, 0.022);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary-main,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary-metric {
display: grid;
min-width: 0;
align-content: center;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary-main {
gap: 3px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary-metric {
min-width: 86px;
justify-items: end;
padding-left: 10px;
border-left: 1px solid rgba(255, 255, 255, 0.06);
text-align: right;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary small {
overflow: hidden;
color: rgba(225, 235, 231, 0.52);
font-size: 10px;
font-weight: 800;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary strong {
overflow: hidden;
color: var(--fg);
font-size: 16px;
font-weight: 850;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary-metric strong {
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary em {
overflow: hidden;
color: rgba(225, 235, 231, 0.42);
font-size: 10px;
font-style: normal;
font-weight: 650;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-card .profile-page__upload-card--meta {
grid-template-columns: 1fr;
gap: 8px;
@@ -7156,7 +6705,6 @@
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit {
width: 72px;
height: 72px;
padding-left: 24px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__counts {
@@ -8734,134 +8282,6 @@
color: var(--accent);
}
@media (max-width: 900px) {
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar {
top: 70px;
right: 12px;
left: 12px;
max-width: none;
overflow-x: auto;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-zoom-controls {
bottom: 14px;
left: 12px;
}
}
@media (max-width: 560px) {
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: center;
gap: 7px;
min-height: 82px;
padding: 8px;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__identity {
grid-column: 1 / 4;
max-width: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__status,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__autosave-status {
display: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__rename {
grid-column: 4;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent {
grid-column: 1;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__export {
grid-column: 2;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__save {
grid-column: 3;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__publish {
grid-column: 4;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar :is(
.studio-canvas-project-bar__rename,
.studio-canvas-project-bar__recent,
.studio-canvas-project-bar__export,
.studio-canvas-project-bar__save,
.studio-canvas-project-bar__publish
) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
width: 100%;
min-width: 0;
min-height: 32px;
padding: 0 7px;
border-radius: 10px;
font-size: 11px;
font-weight: 780;
line-height: 1;
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__rename::after {
content: "编辑";
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__export span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__publish span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__save span {
display: inline;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__export span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__publish span {
width: 0;
max-width: 0;
overflow: hidden;
font-size: 0;
opacity: 0;
pointer-events: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent::after {
font-size: 11px;
content: "最近";
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__export::after {
font-size: 11px;
content: "导出";
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__publish::after {
font-size: 11px;
content: "提交";
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent em {
display: inline-flex;
min-width: 16px;
height: 16px;
align-items: center;
justify-content: center;
padding: 0 4px;
border-radius: 999px;
background: rgba(var(--accent-rgb), 0.16);
color: var(--accent);
font-size: 10px;
}
}
/* Responsive floating navigation: prevent squeeze/warp on narrow workspaces. */
.web-shell[data-ui-theme="dark-green"] .floating-nav {
flex-shrink: 0;
@@ -9292,43 +8712,21 @@
@media (max-width: 900px) {
.web-shell[data-ui-theme="dark-green"] .auth-page {
grid-template-columns: 1fr;
grid-template-rows: 180px minmax(0, 1fr);
overflow-y: auto;
grid-template-rows: 200px 1fr;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-panel {
align-items: flex-start;
padding: 22px 20px 36px;
padding: 24px 20px;
border-top: 1px solid var(--border-weak);
border-left: 0;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__video-overlay {
align-items: flex-start;
justify-content: center;
padding: 22px 24px;
text-align: left;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-content {
gap: 9px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand {
font-size: 34px;
font-size: 32px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__tagline {
font-size: 15px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-stats {
display: none;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-inner {
max-width: 520px;
margin: 0 auto;
font-size: 16px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page__body {
@@ -9338,82 +8736,9 @@
}
@media (max-width: 560px) {
.web-shell[data-ui-theme="dark-green"] .auth-page {
grid-template-rows: 132px minmax(0, 1fr);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__video-overlay {
padding: 16px 18px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand-row {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand {
font-size: 28px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__tagline {
font-size: 13px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__features {
gap: 6px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__features span {
padding: 4px 8px;
font-size: 11px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-panel {
padding: 14px 14px 28px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-inner {
gap: 16px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__logo {
width: 46px;
height: 46px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__title {
font-size: 21px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button {
min-height: 38px;
padding: 0 4px;
font-size: 10px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button .anticon {
font-size: 13px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field input,
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-btn {
min-height: 42px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-row {
gap: 8px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-btn {
padding: 0 10px;
font-size: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.web-shell[data-ui-theme="dark-green"] .profile-page__body {
@@ -9537,27 +8862,6 @@
}
/* Canvas SaaS polish: refined production-tool surfaces without changing canvas behavior. */
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .web-shell__content,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .web-shell__page {
padding-bottom: 0;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .web-shell__page {
overflow: hidden;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .workspace-page-shell__content,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-tool-layout--canvas,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-canvas {
min-height: 0;
height: 100%;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-tool-layout--canvas-empty {
grid-template-rows: minmax(0, 1fr);
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-canvas {
background-image:
radial-gradient(circle at 18% 8%, rgba(var(--accent-rgb), 0.055), transparent 30%),
-11
View File
@@ -84,14 +84,3 @@
--danger: var(--error);
--shadow-soft: var(--shadow-panel);
}
/*
Responsive Breakpoints (standardized)
Reference-only CSS custom properties cannot be used
inside @media queries, but all pages should align to
these three breakpoints for consistency.
Phone : 560px (max-width portrait / landscape)
Tablet : 900px (max-width tablet / small desktop)
Desktop: 1180px (max-width large desktop)
*/
-1
View File
@@ -22,7 +22,6 @@ export type WebViewKey =
| "more"
| "watermarkRemoval"
| "subtitleRemoval"
| "dialogGenerator"
| "communityReview"
| "communityCaseAdd"
| "report"
-160
View File
@@ -1,160 +0,0 @@
import { classifyTaskError, type TaskErrorCategory } from "./translateTaskError";
export type GenerationLifecycleStatus =
| "creating"
| "queued"
| "running"
| "stopping"
| "failed"
| "completed"
| "local_timeout";
export type TaskRefundStatus = "not_charged" | "pending_refund" | "refunded" | "manual_review" | "unknown";
export interface TaskTimeoutPolicy {
submitTimeoutMs: number;
noProgressTimeoutMs: number;
maxRuntimeMs: number;
}
export interface TaskFailureInfo {
category: TaskErrorCategory;
message: string;
actionLabel: string;
retryable: boolean;
refundStatus: TaskRefundStatus;
refundHint: string;
}
export interface TextTokenUsage {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}
export const TEXT_INPUT_CREDITS_PER_MILLION = 2;
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5;
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 90_000,
noProgressTimeoutMs: 120_000,
maxRuntimeMs: 10 * 60_000,
};
const VIDEO_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 120_000,
maxRuntimeMs: 20 * 60_000,
};
const VIDEO_LONG_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 180_000,
maxRuntimeMs: 30 * 60_000,
};
const VIDEO_SUPER_RESOLUTION_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 180_000,
maxRuntimeMs: 15 * 60_000,
};
const TEXT_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 30_000,
noProgressTimeoutMs: 60_000,
maxRuntimeMs: 5 * 60_000,
};
export function getTaskTimeoutPolicy(input: {
kind?: "image" | "video" | "text";
model?: string | null;
operation?: string | null;
}): TaskTimeoutPolicy {
if (input.operation === "video-super-resolution") return VIDEO_SUPER_RESOLUTION_TIMEOUT_POLICY;
if (input.kind === "image") return IMAGE_TIMEOUT_POLICY;
if (input.kind === "text") return TEXT_TIMEOUT_POLICY;
const model = String(input.model || "").toLowerCase();
if (/kling|wan|veo|sora|hailuo|vidu|pixverse|happyhorse/.test(model)) return VIDEO_LONG_TIMEOUT_POLICY;
return VIDEO_TIMEOUT_POLICY;
}
export function isTaskLocallyTimedOut(input: {
startedAt: number;
lastProgressAt: number;
now?: number;
policy: TaskTimeoutPolicy;
progress?: number;
}): "no_progress" | "max_runtime" | null {
const now = input.now || Date.now();
const progress = Number(input.progress || 0);
if (now - input.startedAt >= input.policy.maxRuntimeMs) return "max_runtime";
if (progress > 0 && progress < 100 && now - input.lastProgressAt >= input.policy.noProgressTimeoutMs) {
return "no_progress";
}
if (progress <= 0 && now - input.startedAt >= input.policy.submitTimeoutMs) return "no_progress";
return null;
}
export function buildLocalTimeoutMessage(kind: "image" | "video" | "text" = "video"): string {
if (kind === "text") {
return "本地等待已超时,已停止前端动画。若服务端稍后返回,请以会话记录和积分流水为准。";
}
const label = kind === "image" ? "图片" : "视频";
return `${label}任务长时间没有进展,已停止本地等待并释放前端占用。服务端任务仍可能稍后完成,请到任务历史或资产页查看结果;如已扣费,系统会在失败结算后按积分流水退回。`;
}
export function buildTaskFailureInfo(
error: string | undefined | null,
options: { refundStatus?: TaskRefundStatus; charged?: boolean; submitted?: boolean } = {},
): TaskFailureInfo {
const classified = classifyTaskError(error);
const submitted = options.submitted !== false;
const refundStatus: TaskRefundStatus =
options.refundStatus ||
(submitted
? classified.category === "insufficient_balance" || classified.category === "auth_failure"
? "not_charged"
: "unknown"
: "not_charged");
const refundHint = getRefundHint(refundStatus);
return {
category: classified.category,
message: `${classified.message}${refundHint ? `\n\n${refundHint}` : ""}`,
actionLabel: classified.action,
retryable: !["auth_failure", "insufficient_balance", "content_policy"].includes(classified.category),
refundStatus,
refundHint,
};
}
export function getRefundHint(status: TaskRefundStatus): string {
switch (status) {
case "not_charged":
return "提交未进入扣费结算,未产生积分消耗。";
case "pending_refund":
return "任务已失败,若已扣费,系统会自动退回,请以积分流水为准。";
case "refunded":
return "失败扣费已退回,请在积分流水中核对。";
case "manual_review":
return "退款状态需要人工核对,请联系管理员并提供任务 ID。";
default:
return "如已扣费,系统将在任务失败后自动退回;请以积分流水为准。";
}
}
export function estimateTextTokenCredits(usage: TextTokenUsage): number {
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
return (promptTokens / 1_000_000) * TEXT_INPUT_CREDITS_PER_MILLION +
(completionTokens / 1_000_000) * TEXT_OUTPUT_CREDITS_PER_MILLION;
}
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
const rule = "文本计费规则:输入 Token 每百万 2 积分,输出 Token 每百万 5 积分,实际以服务端结算为准。";
if (!usage) return rule;
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
const estimatedCredits = estimateTextTokenCredits({ promptTokens, completionTokens });
return `本次 Token:输入 ${promptTokens},输出 ${completionTokens},预估 ${estimatedCredits.toFixed(4)} 积分。\n${rule}`;
}