merge: resolve EcommercePage.tsx conflict, integrate master into profile-account-polish
Keep master's EcommercePage.tsx (has more complete upload logic from prior conflict resolution). Accept all other master changes including canvas tool panels, task lifecycle, and workbench updates. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMatch(label, content, pattern) {
|
||||||
|
if (!pattern.test(content)) {
|
||||||
|
failures.push(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoMatch(label, content, pattern) {
|
||||||
|
if (pattern.test(content)) {
|
||||||
|
failures.push(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverConnection = read("src/api/serverConnection.ts");
|
||||||
|
const generationClient = read("src/api/aiGenerationClient.ts");
|
||||||
|
const ecommerceVideoService = read("src/features/ecommerce/ecommerceVideoService.ts");
|
||||||
|
const workbenchPersistence = read("src/features/workbench/workbenchResultPersistence.ts");
|
||||||
|
|
||||||
|
assertMatch(
|
||||||
|
"serverConnection must build same-origin /api URLs",
|
||||||
|
serverConnection,
|
||||||
|
/return\s+`\/api\/\$\{cleanPath\}`;/,
|
||||||
|
);
|
||||||
|
assertNoMatch(
|
||||||
|
"frontend generation flow must not use fixed VITE environment config",
|
||||||
|
`${serverConnection}\n${generationClient}`,
|
||||||
|
/\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/,
|
||||||
|
);
|
||||||
|
assertNoMatch(
|
||||||
|
"frontend generation flow must not call provider hosts directly",
|
||||||
|
generationClient,
|
||||||
|
/dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i,
|
||||||
|
);
|
||||||
|
assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/);
|
||||||
|
assertMatch("video generation must go through the app API", generationClient, /buildApiUrl\("ai\/video"\)/);
|
||||||
|
assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/);
|
||||||
|
assertMatch("URL uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-by-url"\)/);
|
||||||
|
assertMatch(
|
||||||
|
"ecommerce video history must durable-copy media before saving",
|
||||||
|
ecommerceVideoService,
|
||||||
|
/buildDurableVideoHistoryPayload\(payload\)/,
|
||||||
|
);
|
||||||
|
assertMatch(
|
||||||
|
"ecommerce video history must filter temporary provider URLs on read",
|
||||||
|
ecommerceVideoService,
|
||||||
|
/items:\s*history\.items\.map\(removeTemporaryHistoryUrls\)/,
|
||||||
|
);
|
||||||
|
assertMatch(
|
||||||
|
"workbench results must persist generated media through OSS",
|
||||||
|
workbenchPersistence,
|
||||||
|
/uploadAssetByUrl\(/,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error("Mocked generation smoke check failed:");
|
||||||
|
for (const failure of failures) {
|
||||||
|
console.error(`- ${failure}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Mocked generation smoke check passed.");
|
||||||
+5
-1
@@ -48,6 +48,7 @@ const CommunityCaseAddPage = lazy(() => import("./features/community-review/Comm
|
|||||||
const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage"));
|
const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage"));
|
||||||
const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage"));
|
const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage"));
|
||||||
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
|
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
|
||||||
|
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
|
||||||
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
|
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
|
||||||
const HomePage = lazy(() => import("./features/home/HomePage"));
|
const HomePage = lazy(() => import("./features/home/HomePage"));
|
||||||
const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage"));
|
const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage"));
|
||||||
@@ -110,6 +111,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
|||||||
"resolutionUpscale",
|
"resolutionUpscale",
|
||||||
"watermarkRemoval",
|
"watermarkRemoval",
|
||||||
"subtitleRemoval",
|
"subtitleRemoval",
|
||||||
|
"dialogGenerator",
|
||||||
"digitalHuman",
|
"digitalHuman",
|
||||||
"avatarConsole",
|
"avatarConsole",
|
||||||
"characterMix",
|
"characterMix",
|
||||||
@@ -123,7 +125,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
|||||||
"not-found",
|
"not-found",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]);
|
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]);
|
||||||
|
|
||||||
function normalizeViewKey(rawView: string): WebViewKey {
|
function normalizeViewKey(rawView: string): WebViewKey {
|
||||||
const normalized =
|
const normalized =
|
||||||
@@ -1159,6 +1161,8 @@ function App() {
|
|||||||
onSelectView={handleSetView}
|
onSelectView={handleSetView}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "dialogGenerator":
|
||||||
|
return <DialogGeneratorPage />;
|
||||||
case "report":
|
case "report":
|
||||||
return <ReportPage />;
|
return <ReportPage />;
|
||||||
case "providerHealth":
|
case "providerHealth":
|
||||||
|
|||||||
@@ -134,6 +134,12 @@ export interface ChatInput {
|
|||||||
temperature?: number;
|
temperature?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatUsage {
|
||||||
|
promptTokens?: number;
|
||||||
|
completionTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AiTaskStatus {
|
export interface AiTaskStatus {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -159,7 +165,7 @@ function normalizeTaskStatus(status: AiTaskStatus["status"]): WebGenerationPrevi
|
|||||||
function taskTitle(task: AiTaskStatus): string {
|
function taskTitle(task: AiTaskStatus): string {
|
||||||
const prompt = typeof task.params?.prompt === "string" ? task.params.prompt.trim() : "";
|
const prompt = typeof task.params?.prompt === "string" ? task.params.prompt.trim() : "";
|
||||||
if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt;
|
if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt;
|
||||||
return task.type === "video" ? "视频生成任务" : "图像生成任务";
|
return task.type === "video" ? "\u89c6\u9891\u751f\u6210\u4efb\u52a1" : "\u56fe\u50cf\u751f\u6210\u4efb\u52a1";
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPreviewTask(task: AiTaskStatus): WebGenerationPreviewTask {
|
function toPreviewTask(task: AiTaskStatus): WebGenerationPreviewTask {
|
||||||
@@ -500,6 +506,7 @@ export const aiGenerationClient = {
|
|||||||
input: ChatInput,
|
input: ChatInput,
|
||||||
onChunk: (text: string) => void,
|
onChunk: (text: string) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
onUsage?: (usage: ChatUsage) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -512,7 +519,7 @@ export const aiGenerationClient = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reader = res.body?.getReader();
|
const reader = res.body?.getReader();
|
||||||
if (!reader) throw new Error("无法读取响应流");
|
if (!reader) throw new Error("\u65e0\u6cd5\u8bfb\u53d6\u54cd\u5e94\u6d41");
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
@@ -529,8 +536,24 @@ export const aiGenerationClient = {
|
|||||||
const payload = line.slice(6).trim();
|
const payload = line.slice(6).trim();
|
||||||
if (!payload) continue;
|
if (!payload) continue;
|
||||||
try {
|
try {
|
||||||
const chunk = JSON.parse(payload) as { delta?: string; done?: boolean; error?: string };
|
const chunk = JSON.parse(payload) as {
|
||||||
|
delta?: string;
|
||||||
|
done?: boolean;
|
||||||
|
error?: string;
|
||||||
|
usage?: ChatUsage & {
|
||||||
|
prompt_tokens?: number;
|
||||||
|
completion_tokens?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
if (chunk.error) throw new Error(chunk.error);
|
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.delta) onChunk(chunk.delta);
|
||||||
if (chunk.done) return;
|
if (chunk.done) return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -913,7 +913,7 @@ export const keyServerClient = {
|
|||||||
async getProjectContent(projectId: string): Promise<WebCanvasWorkflow> {
|
async getProjectContent(projectId: string): Promise<WebCanvasWorkflow> {
|
||||||
const stored = readStoredSession();
|
const stored = readStoredSession();
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
throw new Error("闇€瑕佸厛鐧诲綍");
|
throw new Error("需要先登录");
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeProjectId = encodeURIComponent(projectId.trim());
|
const safeProjectId = encodeURIComponent(projectId.trim());
|
||||||
@@ -1000,7 +1000,7 @@ export const keyServerClient = {
|
|||||||
async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise<void> {
|
async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise<void> {
|
||||||
const stored = readStoredSession();
|
const stored = readStoredSession();
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
throw new Error("闇€瑕佸厛鐧诲綍");
|
throw new Error("需要先登录");
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`;
|
const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const modelCapabilitiesClient = {
|
|||||||
|
|
||||||
let payload: unknown;
|
let payload: unknown;
|
||||||
try {
|
try {
|
||||||
payload = await serverRequest<unknown>(`config/profile?name=${encodeURIComponent(name)}`);
|
payload = await serverRequest<unknown>(`public/config/profile?name=${encodeURIComponent(name)}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isOptionalApiRouteMissing(error)) {
|
if (isOptionalApiRouteMissing(error)) {
|
||||||
modelCapabilitiesRouteMissing = true;
|
modelCapabilitiesRouteMissing = true;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface ScriptEvalResult {
|
|||||||
totalScore: number;
|
totalScore: number;
|
||||||
grade: string;
|
grade: string;
|
||||||
dimensionScores: Record<string, number>;
|
dimensionScores: Record<string, number>;
|
||||||
|
subScores?: Record<string, Record<string, number>>;
|
||||||
|
evidence?: Record<string, string[]>;
|
||||||
summary: string;
|
summary: string;
|
||||||
issues: string[];
|
issues: string[];
|
||||||
highlights: string[];
|
highlights: string[];
|
||||||
@@ -12,6 +14,33 @@ export interface ScriptEvalResult {
|
|||||||
|
|
||||||
const MODEL = "qwen3.7-max";
|
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短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
|
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
|
||||||
|
|
||||||
【剧本类型识别】
|
【剧本类型识别】
|
||||||
@@ -46,10 +75,10 @@ const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有
|
|||||||
const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = {
|
const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = {
|
||||||
hook: { maxScore: 20 },
|
hook: { maxScore: 20 },
|
||||||
plot: { maxScore: 20 },
|
plot: { maxScore: 20 },
|
||||||
character: { maxScore: 18 },
|
character: { maxScore: 15 },
|
||||||
dialogue: { maxScore: 15 },
|
logic: { maxScore: 15 },
|
||||||
visual: { maxScore: 15 },
|
visual: { maxScore: 15 },
|
||||||
content: { maxScore: 12 },
|
content: { maxScore: 15 },
|
||||||
};
|
};
|
||||||
|
|
||||||
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
|
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
|
||||||
@@ -68,6 +97,48 @@ function extractJson(text: string): unknown {
|
|||||||
return JSON.parse(raw);
|
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> {
|
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
|
||||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -76,6 +147,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
|
|||||||
model: MODEL,
|
model: MODEL,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: EVAL_SYSTEM_PROMPT },
|
{ role: "system", content: EVAL_SYSTEM_PROMPT },
|
||||||
|
{ role: "system", content: EVAL_OUTPUT_CONTRACT },
|
||||||
{ role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` },
|
{ role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` },
|
||||||
],
|
],
|
||||||
stream: false,
|
stream: false,
|
||||||
@@ -101,8 +173,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
|
|||||||
if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常");
|
if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常");
|
||||||
|
|
||||||
for (const key of Object.keys(DIMENSION_WEIGHTS)) {
|
for (const key of Object.keys(DIMENSION_WEIGHTS)) {
|
||||||
const val = Number(rawScores[key] ?? 0);
|
const rawValue = key === "logic" ? rawScores.logic ?? rawScores.dialogue : rawScores[key];
|
||||||
dimensionScores[key] = Math.max(0, Math.min(DIMENSION_WEIGHTS[key].maxScore, val));
|
dimensionScores[key] = normalizeScoreValue(rawValue, DIMENSION_WEIGHTS[key].maxScore);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
|
const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
|
||||||
@@ -111,6 +183,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
|
|||||||
totalScore,
|
totalScore,
|
||||||
grade,
|
grade,
|
||||||
dimensionScores,
|
dimensionScores,
|
||||||
|
subScores: normalizeNestedScores(parsed.subScores),
|
||||||
|
evidence: normalizeEvidence(parsed.evidence),
|
||||||
summary: String(parsed.summary || ""),
|
summary: String(parsed.summary || ""),
|
||||||
issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [],
|
issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [],
|
||||||
highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [],
|
highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [],
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { aiGenerationClient } from "./aiGenerationClient";
|
import { aiGenerationClient } from "./aiGenerationClient";
|
||||||
|
import {
|
||||||
|
buildLocalTimeoutMessage,
|
||||||
|
getTaskTimeoutPolicy,
|
||||||
|
isTaskLocallyTimedOut,
|
||||||
|
} from "../utils/taskLifecycle";
|
||||||
|
|
||||||
export interface TaskProgressEvent {
|
export interface TaskProgressEvent {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -12,16 +17,28 @@ export interface WaitForTaskOptions {
|
|||||||
onProgress?: (event: TaskProgressEvent) => void;
|
onProgress?: (event: TaskProgressEvent) => void;
|
||||||
abortRef?: { current: boolean };
|
abortRef?: { current: boolean };
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
noProgressTimeoutMs?: number;
|
||||||
|
startedAt?: number;
|
||||||
|
kind?: "image" | "video" | "text";
|
||||||
|
model?: string | null;
|
||||||
|
operation?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const POLL_INTERVAL = 3000;
|
const POLL_INTERVAL = 3000;
|
||||||
const DEFAULT_TIMEOUT = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
export function waitForTask(
|
export function waitForTask(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
options: WaitForTaskOptions = {},
|
options: WaitForTaskOptions = {},
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const { onProgress, abortRef, timeoutMs = DEFAULT_TIMEOUT } = options;
|
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();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -29,6 +46,8 @@ export function waitForTask(
|
|||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
let sseConnected = false;
|
let sseConnected = false;
|
||||||
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
|
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let lastProgress = 0;
|
||||||
|
let lastProgressAt = startedAt;
|
||||||
|
|
||||||
const settle = (fn: () => void) => {
|
const settle = (fn: () => void) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
@@ -40,7 +59,7 @@ export function waitForTask(
|
|||||||
};
|
};
|
||||||
|
|
||||||
timeoutId = setTimeout(
|
timeoutId = setTimeout(
|
||||||
() => settle(() => reject(new Error("等待任务结果超时,请稍后在任务历史中查看"))),
|
() => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -50,19 +69,22 @@ export function waitForTask(
|
|||||||
settle(() => resolve(null));
|
settle(() => resolve(null));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const progress = Number(event.progress || 0);
|
||||||
|
if (progress > lastProgress || event.status === "completed") {
|
||||||
|
lastProgress = Math.max(lastProgress, progress);
|
||||||
|
lastProgressAt = Date.now();
|
||||||
|
}
|
||||||
onProgress?.(event);
|
onProgress?.(event);
|
||||||
if (event.status === "completed") {
|
if (event.status === "completed") {
|
||||||
settle(() => resolve(event.resultUrl || null));
|
settle(() => resolve(event.resultUrl || null));
|
||||||
} else if (event.status === "failed" || event.status === "cancelled") {
|
} else if (event.status === "failed" || event.status === "cancelled") {
|
||||||
settle(() => reject(new Error(event.error || "任务失败")));
|
settle(() => reject(new Error(event.error || "任务失败,请稍后重试")));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try SSE first
|
|
||||||
cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate);
|
cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate);
|
||||||
sseConnected = true;
|
sseConnected = true;
|
||||||
|
|
||||||
// Fallback: if SSE doesn't deliver any event within 5s, switch to polling
|
|
||||||
fallbackTimerId = setTimeout(() => {
|
fallbackTimerId = setTimeout(() => {
|
||||||
if (settled || !sseConnected) return;
|
if (settled || !sseConnected) return;
|
||||||
if (cleanup) cleanup();
|
if (cleanup) cleanup();
|
||||||
@@ -72,9 +94,22 @@ export function waitForTask(
|
|||||||
function startPolling() {
|
function startPolling() {
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
while (!settled) {
|
while (!settled) {
|
||||||
if (abortRef?.current) { settle(() => resolve(null)); return; }
|
if (abortRef?.current) {
|
||||||
|
settle(() => resolve(null));
|
||||||
|
return;
|
||||||
|
}
|
||||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL));
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL));
|
||||||
if (settled || abortRef?.current) return;
|
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 {
|
try {
|
||||||
const task = await aiGenerationClient.getTaskStatus(taskId);
|
const task = await aiGenerationClient.getTaskStatus(taskId);
|
||||||
handleUpdate({
|
handleUpdate({
|
||||||
@@ -89,7 +124,7 @@ export function waitForTask(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
poll();
|
void poll();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const webGenerationGateway = {
|
|||||||
prompt,
|
prompt,
|
||||||
createdAt,
|
createdAt,
|
||||||
source: "server",
|
source: "server",
|
||||||
errorMessage: err instanceof Error ? err.message : "请求失败",
|
errorMessage: err instanceof Error ? err.message : "请求失败,请稍后重试",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient";
|
import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient";
|
||||||
|
import { toast } from "./toast/toastStore";
|
||||||
import type { ServerConnectionHealth } from "../api/serverConnection";
|
import type { ServerConnectionHealth } from "../api/serverConnection";
|
||||||
import { ossAssets } from "../data/ossAssets";
|
import { ossAssets } from "../data/ossAssets";
|
||||||
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
|
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
|
||||||
@@ -86,6 +87,7 @@ function AppShell({
|
|||||||
"imageWorkbench",
|
"imageWorkbench",
|
||||||
"resolutionUpscale",
|
"resolutionUpscale",
|
||||||
"digitalHuman",
|
"digitalHuman",
|
||||||
|
"dialogGenerator",
|
||||||
"avatarConsole",
|
"avatarConsole",
|
||||||
"characterMix",
|
"characterMix",
|
||||||
] as WebViewKey[];
|
] as WebViewKey[];
|
||||||
@@ -369,7 +371,7 @@ function AppShell({
|
|||||||
className="member-button"
|
className="member-button"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`积分余额 ${displayedBalanceLabel}`}
|
aria-label={`积分余额 ${displayedBalanceLabel}`}
|
||||||
onClick={() => setRechargeOpen(true)}
|
onClick={() => toast.info("充值功能即将开放,敬请期待")}
|
||||||
>
|
>
|
||||||
<WalletOutlined />
|
<WalletOutlined />
|
||||||
<span className="member-button__label">{displayedBalanceLabel}</span>
|
<span className="member-button__label">{displayedBalanceLabel}</span>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const NAV_ORDER: string[] = [
|
|||||||
"resolutionUpscale",
|
"resolutionUpscale",
|
||||||
"watermarkRemoval",
|
"watermarkRemoval",
|
||||||
"subtitleRemoval",
|
"subtitleRemoval",
|
||||||
|
"dialogGenerator",
|
||||||
"digitalHuman",
|
"digitalHuman",
|
||||||
"avatarConsole",
|
"avatarConsole",
|
||||||
"characterMix",
|
"characterMix",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const ossAssets = {
|
|||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
backgroundVideo: muban("hero-bg.mp4"),
|
backgroundVideo: muban("hero-bg.mp4"),
|
||||||
heroSlides: [muban("hero-1.png"), muban("hero-2.png"), muban("hero-3.png")],
|
heroSlides: [oss("static/banners/light2_轮播1.jpg"), oss("static/banners/light2_轮播2.jpg"), oss("static/banners/light2_轮播3.jpg")],
|
||||||
features: {
|
features: {
|
||||||
ecommerce: muban("feature-ecommerce.jpg"),
|
ecommerce: muban("feature-ecommerce.jpg"),
|
||||||
script: muban("feature-script.jpg"),
|
script: muban("feature-script.jpg"),
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ import {
|
|||||||
} from "./canvasWorkflowDeserialize";
|
} from "./canvasWorkflowDeserialize";
|
||||||
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
|
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
|
||||||
import type { CanvasNodeToolbarAction } from "./canvasComponents";
|
import type { CanvasNodeToolbarAction } from "./canvasComponents";
|
||||||
|
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
|
||||||
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
|
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
|
||||||
|
|
||||||
const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({
|
const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({
|
||||||
@@ -336,6 +337,7 @@ function CanvasPage({
|
|||||||
const [imageFocusNodeId, setImageFocusNodeId] = useState<string | null>(null);
|
const [imageFocusNodeId, setImageFocusNodeId] = useState<string | null>(null);
|
||||||
const [imageFocusDraft, setImageFocusDraft] = useState<CanvasImageFocusSelection | null>(null);
|
const [imageFocusDraft, setImageFocusDraft] = useState<CanvasImageFocusSelection | null>(null);
|
||||||
const [imageFocusDrag, setImageFocusDrag] = useState<CanvasImageFocusDrag | 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 [stylePickerImageNodeId, setStylePickerImageNodeId] = useState<string | null>(null);
|
||||||
const [stylePickerCases, setStylePickerCases] = useState<CanvasStyleCase[]>([]);
|
const [stylePickerCases, setStylePickerCases] = useState<CanvasStyleCase[]>([]);
|
||||||
const [stylePickerLoading, setStylePickerLoading] = useState(false);
|
const [stylePickerLoading, setStylePickerLoading] = useState(false);
|
||||||
@@ -2824,7 +2826,7 @@ function CanvasPage({
|
|||||||
if (targetPort) {
|
if (targetPort) {
|
||||||
connectCanvasPorts(connectorDrag.port, targetPort);
|
connectCanvasPorts(connectorDrag.port, targetPort);
|
||||||
} else {
|
} else {
|
||||||
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, 0);
|
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, -40);
|
||||||
setConnectionDropMenu({
|
setConnectionDropMenu({
|
||||||
...menuPosition,
|
...menuPosition,
|
||||||
originLeft: event.clientX,
|
originLeft: event.clientX,
|
||||||
@@ -3750,12 +3752,12 @@ function CanvasPage({
|
|||||||
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
|
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
|
||||||
/>
|
/>
|
||||||
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
|
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
<button type="button" title="缩小" onClick={zoomCanvasOut}>−</button>
|
<button type="button" title="缩小" aria-label="缩小" onClick={zoomCanvasOut}>−</button>
|
||||||
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}>
|
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" aria-label="重置缩放" onClick={resetCanvasZoom}>
|
||||||
{Math.round(canvasViewport.zoom * 100)}%
|
{Math.round(canvasViewport.zoom * 100)}%
|
||||||
</button>
|
</button>
|
||||||
<button type="button" title="放大" onClick={zoomCanvasIn}>+</button>
|
<button type="button" title="放大" aria-label="放大" onClick={zoomCanvasIn}>+</button>
|
||||||
<button type="button" title="适应视图" onClick={fitCanvasView}>⊡</button>
|
<button type="button" title="适应视图" aria-label="适应视图" onClick={fitCanvasView}>⊡</button>
|
||||||
</div>
|
</div>
|
||||||
{(shouldShowEmptyProjectState || isWaitingForProjects) ? (
|
{(shouldShowEmptyProjectState || isWaitingForProjects) ? (
|
||||||
<div
|
<div
|
||||||
@@ -4264,7 +4266,7 @@ function CanvasPage({
|
|||||||
setSelectedExistingCategory("");
|
setSelectedExistingCategory("");
|
||||||
setSaveAssetOpen(true);
|
setSaveAssetOpen(true);
|
||||||
}
|
}
|
||||||
if (key === "upscale") void handleGenerateImageNode(imageNode.id);
|
if (key === "upscale") setCanvasToolModal({ tool: "upscale", imageNode });
|
||||||
}}
|
}}
|
||||||
moreActions={[
|
moreActions={[
|
||||||
{ key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl },
|
{ key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl },
|
||||||
@@ -4570,16 +4572,42 @@ function CanvasPage({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={imageNodeFocusActive ? "is-active" : ""}
|
title="多宫格生成"
|
||||||
title="框选聚焦区域"
|
disabled={!imageNode.imageUrl}
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
openImageFocusMode(imageNode);
|
setCanvasToolModal({ tool: "multiGrid", imageNode });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BarsOutlined /><span>聚焦</span>
|
<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>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开">↗</button>
|
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开">↗</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -5729,6 +5757,27 @@ function CanvasPage({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</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>
|
</WorkspacePageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -251,7 +251,7 @@ export function blobToDataUrl(blob: Blob) {
|
|||||||
|
|
||||||
export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
|
export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
|
||||||
const resultUrl = await waitForTask(taskId, {
|
const resultUrl = await waitForTask(taskId, {
|
||||||
timeoutMs: 10 * 60 * 1000,
|
kind: "image",
|
||||||
onProgress: (e) => {
|
onProgress: (e) => {
|
||||||
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
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) {
|
export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
|
||||||
const resultUrl = await waitForTask(taskId, {
|
const resultUrl = await waitForTask(taskId, {
|
||||||
timeoutMs: 30 * 60 * 1000,
|
kind: "video",
|
||||||
onProgress: (e) => {
|
onProgress: (e) => {
|
||||||
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
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>支持 JPG、PNG、WEBP 格式</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;
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
interface EcommerceVideoWorkspaceProps {
|
interface EcommerceVideoWorkspaceProps {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
productImageDataUrls: string[];
|
productImageDataUrls: string[];
|
||||||
|
productImageFiles?: Array<File | undefined>;
|
||||||
requirement: string;
|
requirement: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
aspectRatio: string;
|
aspectRatio: string;
|
||||||
@@ -97,6 +98,7 @@ function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress
|
|||||||
export default function EcommerceVideoWorkspace({
|
export default function EcommerceVideoWorkspace({
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
productImageDataUrls,
|
productImageDataUrls,
|
||||||
|
productImageFiles = [],
|
||||||
requirement,
|
requirement,
|
||||||
platform,
|
platform,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
@@ -376,8 +378,9 @@ export default function EcommerceVideoWorkspace({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
const productImageSources = productImageDataUrls.map((url, index) => productImageFiles[index] ?? url);
|
||||||
const result = await runVideoPlan(
|
const result = await runVideoPlan(
|
||||||
productImageDataUrls, requirement, buildConfig(),
|
productImageSources, requirement, buildConfig(),
|
||||||
{
|
{
|
||||||
onStepStart: (step) => setCurrentStep(step),
|
onStepStart: (step) => setCurrentStep(step),
|
||||||
onStepDone: (step) => {
|
onStepDone: (step) => {
|
||||||
|
|||||||
@@ -19,6 +19,102 @@ import type {
|
|||||||
PlanStep,
|
PlanStep,
|
||||||
} from "./ecommerceVideoTypes";
|
} from "./ecommerceVideoTypes";
|
||||||
|
|
||||||
|
type UploadAssetByUrl = typeof aiGenerationClient.uploadAssetByUrl;
|
||||||
|
|
||||||
|
interface DurableMediaUrl {
|
||||||
|
url: string | null;
|
||||||
|
originalUrl?: string | null;
|
||||||
|
ossKey?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMP_MEDIA_HOST_RE = /^file\d*\.aitohumanize\.com$/i;
|
||||||
|
const OSS_MEDIA_HOST_RE = /\.oss-[^.]+\.aliyuncs\.com$/i;
|
||||||
|
|
||||||
|
function isTemporaryProviderUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
return TEMP_MEDIA_HOST_RE.test(new URL(url).hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDurableOssUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.protocol === "https:" && OSS_MEDIA_HOST_RE.test(parsed.hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaExtension(url: string, mimeType: string): string {
|
||||||
|
const normalizedMime = mimeType.split(";")[0]?.trim().toLowerCase();
|
||||||
|
if (normalizedMime === "image/jpeg") return "jpg";
|
||||||
|
if (normalizedMime === "image/png") return "png";
|
||||||
|
if (normalizedMime === "image/webp") return "webp";
|
||||||
|
if (normalizedMime === "image/gif") return "gif";
|
||||||
|
if (normalizedMime === "video/mp4") return "mp4";
|
||||||
|
if (normalizedMime === "video/webm") return "webm";
|
||||||
|
if (normalizedMime === "video/quicktime") return "mov";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const matched = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i);
|
||||||
|
if (matched?.[1]) return matched[1].toLowerCase();
|
||||||
|
} catch {
|
||||||
|
// Keep mime fallback below.
|
||||||
|
}
|
||||||
|
|
||||||
|
return mimeType.startsWith("video/") ? "mp4" : "png";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDurableMediaName(prefix: string, url: string, mimeType: string): string {
|
||||||
|
const normalized = prefix
|
||||||
|
.trim()
|
||||||
|
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.slice(0, 80)
|
||||||
|
.trim();
|
||||||
|
return `${normalized || "ecommerce-video-media"}.${getMediaExtension(url, mimeType)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveDurableMediaUrl(
|
||||||
|
url: string | null | undefined,
|
||||||
|
options: {
|
||||||
|
mediaType: "image" | "video";
|
||||||
|
namePrefix: string;
|
||||||
|
scope?: string;
|
||||||
|
uploadAssetByUrl?: UploadAssetByUrl;
|
||||||
|
},
|
||||||
|
): Promise<DurableMediaUrl> {
|
||||||
|
const sourceUrl = String(url || "").trim();
|
||||||
|
if (!sourceUrl) return { url: null };
|
||||||
|
if (isDurableOssUrl(sourceUrl)) return { url: sourceUrl };
|
||||||
|
|
||||||
|
const mimeType = options.mediaType === "video" ? "video/mp4" : "image/png";
|
||||||
|
const uploadAssetByUrl = options.uploadAssetByUrl || aiGenerationClient.uploadAssetByUrl.bind(aiGenerationClient);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadAssetByUrl({
|
||||||
|
sourceUrl,
|
||||||
|
name: buildDurableMediaName(options.namePrefix, sourceUrl, mimeType),
|
||||||
|
mimeType,
|
||||||
|
scope: options.scope || "ecommerce-video-history",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
url: uploaded.url || null,
|
||||||
|
originalUrl: sourceUrl,
|
||||||
|
ossKey: uploaded.ossKey || null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || "");
|
||||||
|
console.warn("[ecommerce-video] history media persistence failed:", message);
|
||||||
|
if (isTemporaryProviderUrl(sourceUrl)) {
|
||||||
|
return { url: null, originalUrl: sourceUrl };
|
||||||
|
}
|
||||||
|
return { url: sourceUrl };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlanCallbacks {
|
export interface PlanCallbacks {
|
||||||
onStepStart: (step: PlanStep) => void;
|
onStepStart: (step: PlanStep) => void;
|
||||||
onStepDone: (step: PlanStep) => void;
|
onStepDone: (step: PlanStep) => void;
|
||||||
@@ -30,13 +126,61 @@ export interface PlanCallbacks {
|
|||||||
resumeFrom?: EcommerceVideoPlanProgress;
|
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.
|
* Run the full ad video planning pipeline.
|
||||||
* Supports resumption: if `resumeFrom` contains data for a step, that step is skipped.
|
* Supports resumption: if `resumeFrom` contains data for a step, that step is skipped.
|
||||||
* After each step, `onPartialProgress` fires so callers can persist intermediate state.
|
* After each step, `onPartialProgress` fires so callers can persist intermediate state.
|
||||||
*/
|
*/
|
||||||
export async function runVideoPlan(
|
export async function runVideoPlan(
|
||||||
imageDataUrls: string[],
|
imageSources: Array<string | Blob>,
|
||||||
manualText: string,
|
manualText: string,
|
||||||
config: AdVideoUserConfig,
|
config: AdVideoUserConfig,
|
||||||
callbacks: PlanCallbacks,
|
callbacks: PlanCallbacks,
|
||||||
@@ -45,41 +189,30 @@ export async function runVideoPlan(
|
|||||||
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
|
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
|
||||||
const emit = () => callbacks.onPartialProgress?.({ ...progress });
|
const emit = () => callbacks.onPartialProgress?.({ ...progress });
|
||||||
|
|
||||||
// ── Step: upload ──────────────────────────────────────
|
// Step: upload
|
||||||
if (!progress.imageUrls?.length) {
|
if (!progress.imageUrls?.length) {
|
||||||
onStepStart("upload");
|
onStepStart("upload");
|
||||||
const imageUrls: string[] = [];
|
const imageUrls: string[] = [];
|
||||||
const rejected: string[] = [];
|
const rejected: string[] = [];
|
||||||
for (const srcUrl of imageDataUrls) {
|
for (const source of imageSources) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(srcUrl);
|
imageUrls.push(await uploadProductImageSource(source));
|
||||||
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) {
|
} catch (err) {
|
||||||
rejected.push(err instanceof Error ? err.message : "图片上传失败");
|
rejected.push(err instanceof Error ? err.message : "Image upload failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (rejected.length) {
|
if (rejected.length) {
|
||||||
progress.uploadWarnings = rejected;
|
progress.uploadWarnings = rejected;
|
||||||
callbacks.onUploadRejected?.(rejected);
|
callbacks.onUploadRejected?.(rejected);
|
||||||
}
|
}
|
||||||
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
|
if (!imageUrls.length) throw new Error("Image upload failed. Please check the image format or network and try again.");
|
||||||
progress.imageUrls = imageUrls;
|
progress.imageUrls = imageUrls;
|
||||||
onStepDone("upload");
|
onStepDone("upload");
|
||||||
callbacks.onImagesUploaded?.(imageUrls);
|
callbacks.onImagesUploaded?.(imageUrls);
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step: analyze ─────────────────────────────────────
|
// Step: analyze
|
||||||
if (progress.imageDescription === undefined) {
|
if (progress.imageDescription === undefined) {
|
||||||
onStepStart("analyze");
|
onStepStart("analyze");
|
||||||
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
|
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
|
||||||
@@ -87,7 +220,7 @@ export async function runVideoPlan(
|
|||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step: summary ─────────────────────────────────────
|
// Step: summary
|
||||||
if (!progress.summary) {
|
if (!progress.summary) {
|
||||||
onStepStart("summary");
|
onStepStart("summary");
|
||||||
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
|
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
|
||||||
@@ -95,7 +228,7 @@ export async function runVideoPlan(
|
|||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step: selling ─────────────────────────────────────
|
// Step: selling
|
||||||
if (!progress.selling) {
|
if (!progress.selling) {
|
||||||
onStepStart("selling");
|
onStepStart("selling");
|
||||||
progress.selling = await extractSellingPoints(progress.summary, signal);
|
progress.selling = await extractSellingPoints(progress.summary, signal);
|
||||||
@@ -103,16 +236,16 @@ export async function runVideoPlan(
|
|||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step: creative ────────────────────────────────────
|
// Step: creative
|
||||||
if (!progress.creatives?.length) {
|
if (!progress.creatives?.length) {
|
||||||
onStepStart("creative");
|
onStepStart("creative");
|
||||||
progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
|
progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
|
||||||
if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
|
if (!progress.creatives.length) throw new Error("Failed to generate valid ad creatives.");
|
||||||
onStepDone("creative");
|
onStepDone("creative");
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step: storyboard ──────────────────────────────────
|
// Step: storyboard
|
||||||
if (!progress.storyboard) {
|
if (!progress.storyboard) {
|
||||||
onStepStart("storyboard");
|
onStepStart("storyboard");
|
||||||
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
|
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
|
||||||
@@ -120,7 +253,7 @@ export async function runVideoPlan(
|
|||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step: prompts ─────────────────────────────────────
|
// Step: prompts
|
||||||
if (!progress.videoPrompts) {
|
if (!progress.videoPrompts) {
|
||||||
onStepStart("prompts");
|
onStepStart("prompts");
|
||||||
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
|
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
|
||||||
@@ -128,7 +261,7 @@ export async function runVideoPlan(
|
|||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step: compliance ──────────────────────────────────
|
// Step: compliance
|
||||||
if (!progress.compliance) {
|
if (!progress.compliance) {
|
||||||
onStepStart("compliance");
|
onStepStart("compliance");
|
||||||
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
|
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
|
||||||
@@ -179,13 +312,15 @@ export async function renderSceneImage(
|
|||||||
|
|
||||||
const resultUrl = await waitForTask(taskId, {
|
const resultUrl = await waitForTask(taskId, {
|
||||||
abortRef,
|
abortRef,
|
||||||
|
kind: "image",
|
||||||
|
model: "gpt-image-2",
|
||||||
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
|
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resultUrl) {
|
if (resultUrl) {
|
||||||
callbacks.onSceneImageCompleted(input.sceneId, resultUrl);
|
callbacks.onSceneImageCompleted(input.sceneId, resultUrl);
|
||||||
} else {
|
} else {
|
||||||
callbacks.onSceneImageFailed(input.sceneId, "图片生成未返回结果");
|
callbacks.onSceneImageFailed(input.sceneId, "Image generation returned no result.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,13 +369,15 @@ export async function renderScene(
|
|||||||
|
|
||||||
const resultUrl = await waitForTask(taskId, {
|
const resultUrl = await waitForTask(taskId, {
|
||||||
abortRef,
|
abortRef,
|
||||||
|
kind: "video",
|
||||||
|
model,
|
||||||
onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress),
|
onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resultUrl) {
|
if (resultUrl) {
|
||||||
callbacks.onSceneCompleted(input.sceneId, resultUrl);
|
callbacks.onSceneCompleted(input.sceneId, resultUrl);
|
||||||
} else {
|
} else {
|
||||||
callbacks.onSceneFailed(input.sceneId, "任务未返回结果");
|
callbacks.onSceneFailed(input.sceneId, "Task returned no result.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +396,7 @@ export function buildSceneTasks(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Video History API ──────────────────────────────────
|
// Video History API
|
||||||
|
|
||||||
export interface VideoHistoryScene {
|
export interface VideoHistoryScene {
|
||||||
sceneId: number;
|
sceneId: number;
|
||||||
@@ -268,6 +405,15 @@ export interface VideoHistoryScene {
|
|||||||
videoUrl?: string | null;
|
videoUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SaveVideoHistoryPayload {
|
||||||
|
title: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
plan: Record<string, unknown>;
|
||||||
|
scenes: VideoHistoryScene[];
|
||||||
|
sourceImageUrls: string[];
|
||||||
|
uploadAssetByUrl?: UploadAssetByUrl;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VideoHistoryItem {
|
export interface VideoHistoryItem {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -293,22 +439,74 @@ function getAuthHeaders(): Record<string, string> {
|
|||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveVideoHistory(payload: {
|
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
|
||||||
title: string;
|
const uploadAssetByUrl = payload.uploadAssetByUrl;
|
||||||
config: Record<string, unknown>;
|
const scenes = await Promise.all(
|
||||||
plan: Record<string, unknown>;
|
payload.scenes.map(async (scene) => {
|
||||||
scenes: VideoHistoryScene[];
|
const [image, video] = await Promise.all([
|
||||||
sourceImageUrls: string[];
|
resolveDurableMediaUrl(scene.imageUrl, {
|
||||||
}): Promise<{ id: number; createdAt: string }> {
|
mediaType: "image",
|
||||||
|
namePrefix: `ecommerce-scene-${scene.sceneId}-image`,
|
||||||
|
uploadAssetByUrl,
|
||||||
|
}),
|
||||||
|
resolveDurableMediaUrl(scene.videoUrl, {
|
||||||
|
mediaType: "video",
|
||||||
|
namePrefix: `ecommerce-scene-${scene.sceneId}-video`,
|
||||||
|
uploadAssetByUrl,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
...scene,
|
||||||
|
imageUrl: image.url,
|
||||||
|
videoUrl: video.url,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sourceImageUrls = (
|
||||||
|
await Promise.all(
|
||||||
|
payload.sourceImageUrls.map((url, index) =>
|
||||||
|
resolveDurableMediaUrl(url, {
|
||||||
|
mediaType: "image",
|
||||||
|
namePrefix: `ecommerce-source-${index + 1}`,
|
||||||
|
uploadAssetByUrl,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map((item) => item.url)
|
||||||
|
.filter((url): url is string => Boolean(url));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
scenes,
|
||||||
|
sourceImageUrls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
|
||||||
|
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
|
||||||
const res = await fetch(API_BASE, {
|
const res = await fetch(API_BASE, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(historyPayload),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("保存历史记录失败");
|
if (!res.ok) throw new Error("Failed to save video history");
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
scenes: item.scenes.map((scene) => ({
|
||||||
|
...scene,
|
||||||
|
imageUrl: scene.imageUrl && !isTemporaryProviderUrl(scene.imageUrl) ? scene.imageUrl : null,
|
||||||
|
videoUrl: scene.videoUrl && !isTemporaryProviderUrl(scene.videoUrl) ? scene.videoUrl : null,
|
||||||
|
})),
|
||||||
|
sourceImageUrls: item.sourceImageUrls.filter((url) => !isTemporaryProviderUrl(url)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchVideoHistory(
|
export async function fetchVideoHistory(
|
||||||
limit = 20,
|
limit = 20,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
@@ -317,8 +515,12 @@ export async function fetchVideoHistory(
|
|||||||
`${API_BASE}?limit=${limit}&offset=${offset}`,
|
`${API_BASE}?limit=${limit}&offset=${offset}`,
|
||||||
{ headers: getAuthHeaders() },
|
{ headers: getAuthHeaders() },
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error("获取历史记录失败");
|
if (!res.ok) throw new Error("Failed to fetch video history");
|
||||||
return res.json();
|
const history = (await res.json()) as VideoHistoryListResponse;
|
||||||
|
return {
|
||||||
|
...history,
|
||||||
|
items: history.items.map(removeTemporaryHistoryUrls),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteVideoHistory(id: number): Promise<void> {
|
export async function deleteVideoHistory(id: number): Promise<void> {
|
||||||
@@ -326,5 +528,5 @@ export async function deleteVideoHistory(id: number): Promise<void> {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("删除失败");
|
if (!res.ok) throw new Error("Failed to delete video history");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ interface EcommerceClonePanelProps {
|
|||||||
clampCloneVideoDuration: (value: number) => number;
|
clampCloneVideoDuration: (value: number) => number;
|
||||||
setCloneVideoSmart: (updater: (current: boolean) => boolean) => void;
|
setCloneVideoSmart: (updater: (current: boolean) => boolean) => void;
|
||||||
handleGenerate: () => void;
|
handleGenerate: () => void;
|
||||||
|
onCancelGenerate: () => void;
|
||||||
formatRatioDisplayValue: (value: string) => string;
|
formatRatioDisplayValue: (value: string) => string;
|
||||||
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
|
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
|
||||||
onStartVideoPlan?: () => void;
|
onStartVideoPlan?: () => void;
|
||||||
@@ -200,6 +201,7 @@ export default function EcommerceClonePanel({
|
|||||||
clampCloneVideoDuration,
|
clampCloneVideoDuration,
|
||||||
setCloneVideoSmart,
|
setCloneVideoSmart,
|
||||||
handleGenerate,
|
handleGenerate,
|
||||||
|
onCancelGenerate,
|
||||||
formatRatioDisplayValue,
|
formatRatioDisplayValue,
|
||||||
setVideoOutfitFiles,
|
setVideoOutfitFiles,
|
||||||
onStartVideoPlan,
|
onStartVideoPlan,
|
||||||
@@ -746,6 +748,11 @@ export default function EcommerceClonePanel({
|
|||||||
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <ReloadOutlined /> : null}
|
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <ReloadOutlined /> : null}
|
||||||
{status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : cloneOutput === "video-outfit" ? "✦ 开始换装" : "✦ 开始生成"}
|
{status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : cloneOutput === "video-outfit" ? "✦ 开始换装" : "✦ 开始生成"}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface EcommerceDetailPanelProps {
|
|||||||
handleDetailAiWrite: () => void;
|
handleDetailAiWrite: () => void;
|
||||||
toggleDetailModule: (id: string) => void;
|
toggleDetailModule: (id: string) => void;
|
||||||
handleDetailGenerate: () => void;
|
handleDetailGenerate: () => void;
|
||||||
|
onCancelGenerate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EcommerceDetailPanel({
|
export default function EcommerceDetailPanel({
|
||||||
@@ -56,6 +57,7 @@ export default function EcommerceDetailPanel({
|
|||||||
handleDetailAiWrite,
|
handleDetailAiWrite,
|
||||||
toggleDetailModule,
|
toggleDetailModule,
|
||||||
handleDetailGenerate,
|
handleDetailGenerate,
|
||||||
|
onCancelGenerate,
|
||||||
}: EcommerceDetailPanelProps) {
|
}: EcommerceDetailPanelProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -162,6 +164,11 @@ export default function EcommerceDetailPanel({
|
|||||||
{detailStatus === "generating" ? <LoadingOutlined /> : null}
|
{detailStatus === "generating" ? <LoadingOutlined /> : null}
|
||||||
{detailPrimaryLabel}
|
{detailPrimaryLabel}
|
||||||
</button>
|
</button>
|
||||||
|
{detailStatus === "generating" ? (
|
||||||
|
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
|
||||||
|
{"\u53d6\u6d88\u751f\u6210"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface EcommerceTryOnPanelProps {
|
|||||||
setSmartScene: (updater: (current: boolean) => boolean) => void;
|
setSmartScene: (updater: (current: boolean) => boolean) => void;
|
||||||
setTryOnRatio: (value: string) => void;
|
setTryOnRatio: (value: string) => void;
|
||||||
handleTryOnGenerate: () => void;
|
handleTryOnGenerate: () => void;
|
||||||
|
onCancelGenerate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EcommerceTryOnPanel({
|
export default function EcommerceTryOnPanel({
|
||||||
@@ -70,6 +71,7 @@ export default function EcommerceTryOnPanel({
|
|||||||
setSmartScene,
|
setSmartScene,
|
||||||
setTryOnRatio,
|
setTryOnRatio,
|
||||||
handleTryOnGenerate,
|
handleTryOnGenerate,
|
||||||
|
onCancelGenerate,
|
||||||
}: EcommerceTryOnPanelProps) {
|
}: EcommerceTryOnPanelProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -213,6 +215,11 @@ export default function EcommerceTryOnPanel({
|
|||||||
{tryOnStatus === "generating" ? <LoadingOutlined /> : null}
|
{tryOnStatus === "generating" ? <LoadingOutlined /> : null}
|
||||||
{tryOnPrimaryLabel}
|
{tryOnPrimaryLabel}
|
||||||
</button>
|
</button>
|
||||||
|
{tryOnStatus === "generating" ? (
|
||||||
|
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
|
||||||
|
{"\u53d6\u6d88\u751f\u6210"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
HighlightOutlined,
|
HighlightOutlined,
|
||||||
|
MessageOutlined,
|
||||||
SwapOutlined,
|
SwapOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
@@ -42,6 +43,7 @@ const tools: MoreTool[] = [
|
|||||||
{ id: "camera", title: "镜头实验室", text: "角度、焦段和机位控制", icon: <CameraOutlined />, category: "image", imageTool: "camera", ready: true },
|
{ 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: "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: "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: "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: "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 },
|
{ id: "characterMix", title: "角色迁移", text: "人物图迁移到参考视频动作", icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true },
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface EvalResult {
|
|||||||
totalScore: number;
|
totalScore: number;
|
||||||
grade: string;
|
grade: string;
|
||||||
dimensionScores: Record<string, number>;
|
dimensionScores: Record<string, number>;
|
||||||
|
subScores?: Record<string, Record<string, number>>;
|
||||||
|
evidence?: Record<string, string[]>;
|
||||||
summary: string;
|
summary: string;
|
||||||
issues: string[];
|
issues: string[];
|
||||||
highlights: string[];
|
highlights: string[];
|
||||||
@@ -192,6 +194,60 @@ const SCORE_DIMENSIONS: ScoreDimension[] = [
|
|||||||
{ key: "content", label: "内容深度", maxScore: 15, hint: "主题表达·情感共鸣·思想内核", detail: "核心设定将科技伦理与人性困境紧密结合,主题表达深刻有力。" },
|
{ 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 {
|
function formatReportMarkdown(result: EvalResult, script: string): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`# 剧本评测报告`);
|
lines.push(`# 剧本评测报告`);
|
||||||
@@ -203,9 +259,16 @@ function formatReportMarkdown(result: EvalResult, script: string): string {
|
|||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`## 六维评分`);
|
lines.push(`## 六维评分`);
|
||||||
for (const dim of SCORE_DIMENSIONS) {
|
for (const dim of SCORE_DIMENSIONS) {
|
||||||
const score = result.dimensionScores[dim.key] ?? 0;
|
const score = getDimensionScore(result, dim);
|
||||||
const pct = Math.round((score / dim.maxScore) * 100);
|
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(`- **${dim.label}**: ${score}/${dim.maxScore} (${pct}%) — ${dim.hint}`);
|
||||||
|
lines.push(...nestedReportLines);
|
||||||
}
|
}
|
||||||
if (result.highlights.length > 0) {
|
if (result.highlights.length > 0) {
|
||||||
lines.push("");
|
lines.push("");
|
||||||
@@ -636,7 +699,7 @@ function ScriptTokensPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="script-eval-report__chart-grid">
|
<div className="script-eval-report__chart-grid">
|
||||||
{SCORE_DIMENSIONS.map((dim, dimIndex) => {
|
{SCORE_DIMENSIONS.map((dim, dimIndex) => {
|
||||||
const score = result.dimensionScores[dim.key] ?? 0;
|
const score = getDimensionScore(result, dim);
|
||||||
const pct = Math.max(0, Math.min(1, score / dim.maxScore));
|
const pct = Math.max(0, Math.min(1, score / dim.maxScore));
|
||||||
const lossPct = 1 - pct;
|
const lossPct = 1 - pct;
|
||||||
const isPerfect = score === dim.maxScore;
|
const isPerfect = score === dim.maxScore;
|
||||||
@@ -676,6 +739,51 @@ function ScriptTokensPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<div className="script-eval-report__findings">
|
||||||
{result.highlights.length > 0 ? (
|
{result.highlights.length > 0 ? (
|
||||||
<section className="script-eval-report__finding-group is-highlight">
|
<section className="script-eval-report__finding-group is-highlight">
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ import {
|
|||||||
import { renderMarkdownBlocks } from "./markdownRenderer";
|
import { renderMarkdownBlocks } from "./markdownRenderer";
|
||||||
import { downloadResultAsset } from "./workbenchDownload";
|
import { downloadResultAsset } from "./workbenchDownload";
|
||||||
import { translateTaskError } from "../../utils/translateTaskError";
|
import { translateTaskError } from "../../utils/translateTaskError";
|
||||||
|
import {
|
||||||
|
buildLocalTimeoutMessage,
|
||||||
|
formatTextTokenUsage,
|
||||||
|
getTaskTimeoutPolicy,
|
||||||
|
isTaskLocallyTimedOut,
|
||||||
|
} from "../../utils/taskLifecycle";
|
||||||
import { detectMentionTrigger } from "../../utils/mentionTrigger";
|
import { detectMentionTrigger } from "../../utils/mentionTrigger";
|
||||||
import {
|
import {
|
||||||
isHappyHorseModel,
|
isHappyHorseModel,
|
||||||
@@ -103,6 +109,7 @@ import {
|
|||||||
VIDEO_MODEL_OPTIONS,
|
VIDEO_MODEL_OPTIONS,
|
||||||
RATIO_OPTIONS,
|
RATIO_OPTIONS,
|
||||||
GRID_MODE_OPTIONS,
|
GRID_MODE_OPTIONS,
|
||||||
|
GRID_SUPPORTED_MODELS,
|
||||||
VIDEO_FRAME_OPTIONS,
|
VIDEO_FRAME_OPTIONS,
|
||||||
VIDEO_DURATION_OPTIONS,
|
VIDEO_DURATION_OPTIONS,
|
||||||
MESSAGE_STORAGE_KEY,
|
MESSAGE_STORAGE_KEY,
|
||||||
@@ -250,6 +257,8 @@ function WorkbenchPage({
|
|||||||
const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null);
|
const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null);
|
||||||
const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]);
|
const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]);
|
||||||
const [referencePreviewOpen, setReferencePreviewOpen] = useState(false);
|
const [referencePreviewOpen, setReferencePreviewOpen] = useState(false);
|
||||||
|
const [isComposerDragging, setIsComposerDragging] = useState(false);
|
||||||
|
const composerDragCounterRef = useRef(0);
|
||||||
const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null);
|
const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null);
|
||||||
const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null);
|
const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null);
|
||||||
const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]);
|
const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]);
|
||||||
@@ -863,6 +872,9 @@ function WorkbenchPage({
|
|||||||
|
|
||||||
let lastKnownProgress = Math.max(0, Number(task.progress || 0));
|
let lastKnownProgress = Math.max(0, Number(task.progress || 0));
|
||||||
let taskPollFailures = 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();
|
const abortController = new AbortController();
|
||||||
taskAbortControllersRef.current.set(task.taskId, abortController);
|
taskAbortControllersRef.current.set(task.taskId, abortController);
|
||||||
if (activeConversationIdRef.current === task.conversationId) {
|
if (activeConversationIdRef.current === task.conversationId) {
|
||||||
@@ -909,6 +921,9 @@ function WorkbenchPage({
|
|||||||
const progress = status.status === "completed"
|
const progress = status.status === "completed"
|
||||||
? 100
|
? 100
|
||||||
: Math.min(99, Math.max(10, lastKnownProgress, currentMessageProgress, Math.round(baseProgress)));
|
: 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);
|
lastKnownProgress = Math.max(lastKnownProgress, progress);
|
||||||
const isSuperResolveTask = task.operation === "video-super-resolution";
|
const isSuperResolveTask = task.operation === "video-super-resolution";
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
@@ -933,6 +948,28 @@ function WorkbenchPage({
|
|||||||
setGenerationProgress(progress);
|
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) {
|
if (status.status === "completed" && status.resultUrl) {
|
||||||
const completedPatch: Partial<ChatMessage> = {
|
const completedPatch: Partial<ChatMessage> = {
|
||||||
body: isSuperResolveTask
|
body: isSuperResolveTask
|
||||||
@@ -1459,9 +1496,22 @@ function WorkbenchPage({
|
|||||||
setReferenceItems(nextItems);
|
setReferenceItems(nextItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
const handleReferenceUploadClick = () => {
|
||||||
const files = Array.from(event.target.files || []);
|
if (referenceItems.length > 0) {
|
||||||
event.target.value = "";
|
setToolbarMenuId(null);
|
||||||
|
setReferencePreviewOpen((current) => !current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
referenceInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReferenceAddMore = () => {
|
||||||
|
setToolbarMenuId(null);
|
||||||
|
setReferencePreviewOpen(true);
|
||||||
|
referenceInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const processReferenceFiles = async (files: File[]) => {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
|
|
||||||
const existingFingerprints = new Set(
|
const existingFingerprints = new Set(
|
||||||
@@ -1548,20 +1598,46 @@ function WorkbenchPage({
|
|||||||
window.requestAnimationFrame(() => textareaRef.current?.focus());
|
window.requestAnimationFrame(() => textareaRef.current?.focus());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReferenceUploadClick = () => {
|
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
if (referenceItems.length > 0) {
|
const files = Array.from(event.target.files || []);
|
||||||
setToolbarMenuId(null);
|
event.target.value = "";
|
||||||
setReferencePreviewOpen((current) => !current);
|
await processReferenceFiles(files);
|
||||||
return;
|
|
||||||
}
|
|
||||||
referenceInputRef.current?.click();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReferenceAddMore = () => {
|
const handleComposerDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
setToolbarMenuId(null);
|
e.preventDefault();
|
||||||
setReferencePreviewOpen(true);
|
e.stopPropagation();
|
||||||
referenceInputRef.current?.click();
|
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 insertPromptMention = (token: string) => {
|
const insertPromptMention = (token: string) => {
|
||||||
const rawBefore = inputValue.slice(0, cursorIndex);
|
const rawBefore = inputValue.slice(0, cursorIndex);
|
||||||
@@ -1941,6 +2017,7 @@ function WorkbenchPage({
|
|||||||
runKeepalivePoll(keepaliveTask);
|
runKeepalivePoll(keepaliveTask);
|
||||||
} else {
|
} else {
|
||||||
let streamedText = "";
|
let streamedText = "";
|
||||||
|
let chatUsage: ChatMessage["taskUsage"] | undefined;
|
||||||
setGenerationProgress(36);
|
setGenerationProgress(36);
|
||||||
setGenerationStatus("正在回复");
|
setGenerationStatus("正在回复");
|
||||||
updateAssistantMessage(assistantMessageId, {
|
updateAssistantMessage(assistantMessageId, {
|
||||||
@@ -1973,6 +2050,9 @@ function WorkbenchPage({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
|
(usage) => {
|
||||||
|
chatUsage = usage;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (abortController.signal.aborted) return;
|
if (abortController.signal.aborted) return;
|
||||||
@@ -1981,6 +2061,7 @@ function WorkbenchPage({
|
|||||||
const completedMessages = updateAssistantMessage(assistantMessageId, {
|
const completedMessages = updateAssistantMessage(assistantMessageId, {
|
||||||
body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。",
|
body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
|
taskUsage: chatUsage,
|
||||||
});
|
});
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
const conv = await conversationClient.create(
|
const conv = await conversationClient.create(
|
||||||
@@ -2108,6 +2189,38 @@ 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) => {
|
const handleSuperResolveVideo = async (message: ChatMessage) => {
|
||||||
if (!message.resultUrl || message.resultType !== "video") {
|
if (!message.resultUrl || message.resultType !== "video") {
|
||||||
setProjectError("仅支持对视频结果进行超分");
|
setProjectError("仅支持对视频结果进行超分");
|
||||||
@@ -2561,6 +2674,11 @@ function WorkbenchPage({
|
|||||||
>
|
>
|
||||||
<ReferencePreview item={item} label={getReferenceKindLabel(item.kind)} />
|
<ReferencePreview item={item} label={getReferenceKindLabel(item.kind)} />
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="wb-composer__ref-remove"
|
className="wb-composer__ref-remove"
|
||||||
@@ -2612,7 +2730,7 @@ function WorkbenchPage({
|
|||||||
isOpen={toolbarMenuId === "image-model"}
|
isOpen={toolbarMenuId === "image-model"}
|
||||||
onToggle={() => toggleToolbarMenu("image-model")}
|
onToggle={() => toggleToolbarMenu("image-model")}
|
||||||
onClose={closeToolbarMenus}
|
onClose={closeToolbarMenus}
|
||||||
onChange={setImageModel}
|
onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
<CompoundSelectChip
|
<CompoundSelectChip
|
||||||
@@ -2624,6 +2742,7 @@ function WorkbenchPage({
|
|||||||
onToggle={() => toggleToolbarMenu("image-settings")}
|
onToggle={() => toggleToolbarMenu("image-settings")}
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
|
{GRID_SUPPORTED_MODELS.has(imageModel) && (
|
||||||
<SelectChip
|
<SelectChip
|
||||||
chipId="image-grid-mode"
|
chipId="image-grid-mode"
|
||||||
value={imageGridMode}
|
value={imageGridMode}
|
||||||
@@ -2635,6 +2754,7 @@ function WorkbenchPage({
|
|||||||
onChange={setImageGridMode}
|
onChange={setImageGridMode}
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{activeMode === "video" && (
|
{activeMode === "video" && (
|
||||||
@@ -2818,7 +2938,14 @@ function WorkbenchPage({
|
|||||||
<h1 className="wb-home__title">今天想生成什么?</h1>
|
<h1 className="wb-home__title">今天想生成什么?</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="wb-home__composer" ref={toolbarRef}>
|
<div
|
||||||
|
className={`wb-home__composer${isComposerDragging ? " wb-composer--drag-active" : ""}`}
|
||||||
|
ref={toolbarRef}
|
||||||
|
onDragEnter={handleComposerDragEnter}
|
||||||
|
onDragLeave={handleComposerDragLeave}
|
||||||
|
onDragOver={handleComposerDragOver}
|
||||||
|
onDrop={handleComposerDrop}
|
||||||
|
>
|
||||||
<div className="wb-composer__content">
|
<div className="wb-composer__content">
|
||||||
<div className="wb-composer__input-row">
|
<div className="wb-composer__input-row">
|
||||||
{renderComposerReferences(false)}
|
{renderComposerReferences(false)}
|
||||||
@@ -2954,7 +3081,7 @@ function WorkbenchPage({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{message.status === "failed" && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
|
{(message.status === "failed" || message.status === "local_timeout") && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
|
||||||
<div className="ai-chat-failed-actions">
|
<div className="ai-chat-failed-actions">
|
||||||
<button type="button" className="ai-chat-failed-actions__retry" onClick={() => handleRegenerate(message)}>
|
<button type="button" className="ai-chat-failed-actions__retry" onClick={() => handleRegenerate(message)}>
|
||||||
<ReloadOutlined /> 重试
|
<ReloadOutlined /> 重试
|
||||||
@@ -2962,9 +3089,12 @@ function WorkbenchPage({
|
|||||||
<button type="button" className="ai-chat-failed-actions__switch" onClick={() => { setToolbarMenuId(message.mode === "video" ? "video-model" : "image-model"); scrollMessagesSurface("bottom"); }}>
|
<button type="button" className="ai-chat-failed-actions__switch" onClick={() => { setToolbarMenuId(message.mode === "video" ? "video-model" : "image-model"); scrollMessagesSurface("bottom"); }}>
|
||||||
<AppstoreOutlined /> 切换模型
|
<AppstoreOutlined /> 切换模型
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="ai-chat-failed-actions__release" onClick={() => handleReleaseStuckTask(message)}>
|
||||||
|
<StopOutlined /> 释放卡住任务
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{message.status === "thinking" && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
|
{(message.status === "thinking" || message.status === "stopping") && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
|
||||||
<GenerationPendingCard message={message} onStop={() => handleStopSingleTask(message.id)} />
|
<GenerationPendingCard message={message} onStop={() => handleStopSingleTask(message.id)} />
|
||||||
)}
|
)}
|
||||||
{message.status === "thinking" && message.mode === "chat" && (
|
{message.status === "thinking" && message.mode === "chat" && (
|
||||||
@@ -2972,6 +3102,11 @@ function WorkbenchPage({
|
|||||||
<span>{message.taskStatusLabel || generationStatus}</span>
|
<span>{message.taskStatusLabel || generationStatus}</span>
|
||||||
</div>
|
</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")) && (
|
{(message.resultUrl || (message.result && message.status !== "thinking")) && (
|
||||||
<ResultCard
|
<ResultCard
|
||||||
message={message}
|
message={message}
|
||||||
@@ -2993,7 +3128,14 @@ function WorkbenchPage({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={`wb-composer${composerHidden ? " is-hidden" : ""}`} ref={toolbarRef}>
|
<section
|
||||||
|
className={`wb-composer${composerHidden ? " is-hidden" : ""}${isComposerDragging ? " wb-composer--drag-active" : ""}`}
|
||||||
|
ref={toolbarRef}
|
||||||
|
onDragEnter={handleComposerDragEnter}
|
||||||
|
onDragLeave={handleComposerDragLeave}
|
||||||
|
onDragOver={handleComposerDragOver}
|
||||||
|
onDrop={handleComposerDrop}
|
||||||
|
>
|
||||||
<div className="wb-composer__content">
|
<div className="wb-composer__content">
|
||||||
<div className="wb-composer__input-row">
|
<div className="wb-composer__input-row">
|
||||||
{renderComposerReferences(false)}
|
{renderComposerReferences(false)}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
|
||||||
|
|
||||||
export type WorkbenchMode = "chat" | "image" | "video";
|
export type WorkbenchMode = "chat" | "image" | "video";
|
||||||
|
|
||||||
export interface WorkbenchChatAttachment {
|
export interface WorkbenchChatAttachment {
|
||||||
@@ -16,7 +18,10 @@ export interface WorkbenchChatMessage {
|
|||||||
body: string;
|
body: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
status?: "thinking" | "queued" | "completed" | "failed";
|
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
|
||||||
|
taskLifecycleStatus?: GenerationLifecycleStatus;
|
||||||
|
taskRefundStatus?: TaskRefundStatus;
|
||||||
|
taskUsage?: TextTokenUsage;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
conversationId?: number;
|
conversationId?: number;
|
||||||
taskProgress?: number;
|
taskProgress?: number;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isServerRequestError } from "../../api/serverConnection";
|
import { isServerRequestError } from "../../api/serverConnection";
|
||||||
import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy";
|
import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy";
|
||||||
import type { WebGenerationPreviewTask } from "../../types";
|
import type { WebGenerationPreviewTask } from "../../types";
|
||||||
|
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export type WorkbenchMode = "chat" | "image" | "video";
|
export type WorkbenchMode = "chat" | "image" | "video";
|
||||||
@@ -71,7 +72,10 @@ export interface ChatMessage {
|
|||||||
body: string;
|
body: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
status?: "thinking" | "queued" | "completed" | "failed";
|
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
|
||||||
|
taskLifecycleStatus?: GenerationLifecycleStatus;
|
||||||
|
taskRefundStatus?: TaskRefundStatus;
|
||||||
|
taskUsage?: TextTokenUsage;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
conversationId?: number;
|
conversationId?: number;
|
||||||
taskProgress?: number;
|
taskProgress?: number;
|
||||||
@@ -232,6 +236,13 @@ export const GRID_MODE_OPTIONS: WorkbenchOption[] = [
|
|||||||
{ value: "grid-25", label: "25 宫格" },
|
{ 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[] = [
|
export const VIDEO_FRAME_OPTIONS: WorkbenchOption[] = [
|
||||||
{ value: "omni", label: "全能参考" },
|
{ value: "omni", label: "全能参考" },
|
||||||
{ value: "start-end", label: "首尾帧" },
|
{ value: "start-end", label: "首尾帧" },
|
||||||
@@ -366,11 +377,16 @@ export function shouldPersistPatch(patch: Partial<ChatMessage>): boolean {
|
|||||||
return (
|
return (
|
||||||
patch.status === "completed" ||
|
patch.status === "completed" ||
|
||||||
patch.status === "failed" ||
|
patch.status === "failed" ||
|
||||||
|
patch.status === "local_timeout" ||
|
||||||
|
patch.status === "stopping" ||
|
||||||
typeof patch.taskId === "string" ||
|
typeof patch.taskId === "string" ||
|
||||||
typeof patch.resultUrl === "string" ||
|
typeof patch.resultUrl === "string" ||
|
||||||
typeof patch.resultOssKey === "string" ||
|
typeof patch.resultOssKey === "string" ||
|
||||||
typeof patch.resultOriginalUrl === "string" ||
|
typeof patch.resultOriginalUrl === "string" ||
|
||||||
typeof patch.resultMimeType === "string"
|
typeof patch.resultMimeType === "string" ||
|
||||||
|
typeof patch.taskRefundStatus === "string" ||
|
||||||
|
typeof patch.taskLifecycleStatus === "string" ||
|
||||||
|
typeof patch.taskUsage === "object"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
|
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
|
||||||
import { aiGenerationClient } from "../api/aiGenerationClient";
|
import { aiGenerationClient } from "../api/aiGenerationClient";
|
||||||
|
import {
|
||||||
|
buildLocalTimeoutMessage,
|
||||||
|
buildTaskFailureInfo,
|
||||||
|
getTaskTimeoutPolicy,
|
||||||
|
isTaskLocallyTimedOut,
|
||||||
|
} from "../utils/taskLifecycle";
|
||||||
|
|
||||||
type PollCallback = (item: GenerationQueueItem) => void;
|
type PollCallback = (item: GenerationQueueItem) => void;
|
||||||
|
|
||||||
@@ -7,7 +13,7 @@ const activePollers = new Map<string, ReturnType<typeof setInterval>>();
|
|||||||
const pollCallbacks = new Set<PollCallback>();
|
const pollCallbacks = new Set<PollCallback>();
|
||||||
|
|
||||||
const POLL_INTERVAL = 3000;
|
const POLL_INTERVAL = 3000;
|
||||||
const MAX_POLL_ATTEMPTS = 200; // 10 minutes max per task
|
const MAX_POLL_ATTEMPTS = 200; // Keep the previous 10-minute guard as a fallback.
|
||||||
|
|
||||||
export function subscribeToTaskUpdates(callback: PollCallback): () => void {
|
export function subscribeToTaskUpdates(callback: PollCallback): () => void {
|
||||||
pollCallbacks.add(callback);
|
pollCallbacks.add(callback);
|
||||||
@@ -18,10 +24,25 @@ function notifyCallbacks(item: GenerationQueueItem): void {
|
|||||||
pollCallbacks.forEach((cb) => cb(item));
|
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 {
|
function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void {
|
||||||
const key = `poll-${item.id}`;
|
const key = `poll-${item.id}`;
|
||||||
if (activePollers.has(key)) return;
|
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 interval = setInterval(async () => {
|
||||||
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
|
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
|
||||||
if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") {
|
if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") {
|
||||||
@@ -30,18 +51,31 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
|
|||||||
}
|
}
|
||||||
|
|
||||||
attemptsRef.current++;
|
attemptsRef.current++;
|
||||||
if (attemptsRef.current > MAX_POLL_ATTEMPTS) {
|
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);
|
||||||
useGenerationStore.getState().updateTask(item.id, {
|
useGenerationStore.getState().updateTask(item.id, {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
error: "任务超时,请重新提交",
|
error,
|
||||||
});
|
});
|
||||||
notifyCallbacks({ ...item, status: "failed", error: "任务超时,请重新提交" });
|
notifyCallbacks({ ...item, status: "failed", error });
|
||||||
cleanupPoll(key);
|
cleanupPoll(key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || "");
|
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> = {
|
const patch: Partial<GenerationQueueItem> = {
|
||||||
progress: status.progress,
|
progress: status.progress,
|
||||||
resultUrl: status.resultUrl || current.resultUrl,
|
resultUrl: status.resultUrl || current.resultUrl,
|
||||||
@@ -55,6 +89,7 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
|
|||||||
cleanupPoll(key);
|
cleanupPoll(key);
|
||||||
} else if (status.status === "failed" || status.status === "cancelled") {
|
} else if (status.status === "failed" || status.status === "cancelled") {
|
||||||
patch.status = "failed";
|
patch.status = "failed";
|
||||||
|
patch.error = buildTaskFailureInfo(status.error).message;
|
||||||
useGenerationStore.getState().updateTask(item.id, patch);
|
useGenerationStore.getState().updateTask(item.id, patch);
|
||||||
notifyCallbacks({ ...item, ...patch, status: "failed" });
|
notifyCallbacks({ ...item, ...patch, status: "failed" });
|
||||||
cleanupPoll(key);
|
cleanupPoll(key);
|
||||||
@@ -64,7 +99,7 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
|
|||||||
notifyCallbacks({ ...item, ...patch, status: "running" });
|
notifyCallbacks({ ...item, ...patch, status: "running" });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Network error during poll — keep trying
|
// Network errors during polling are retried until the lifecycle guard trips.
|
||||||
}
|
}
|
||||||
}, POLL_INTERVAL);
|
}, POLL_INTERVAL);
|
||||||
|
|
||||||
@@ -105,24 +140,20 @@ export function stopAllPolling(): void {
|
|||||||
activePollers.clear();
|
activePollers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Recovery on page load ──────────────────────────
|
|
||||||
export function recoverAndResumeTasks(): void {
|
export function recoverAndResumeTasks(): void {
|
||||||
const pendingTasks = useGenerationStore.getState().getRunningTasks();
|
const pendingTasks = useGenerationStore.getState().getRunningTasks();
|
||||||
if (!pendingTasks.length) return;
|
if (!pendingTasks.length) return;
|
||||||
|
|
||||||
pendingTasks.forEach((task) => {
|
pendingTasks.forEach((task) => {
|
||||||
if (task.taskId) {
|
if (task.taskId) {
|
||||||
// Mark as pending so the workbench/ecommerce can re-submit to polling
|
|
||||||
useGenerationStore.getState().updateTask(task.id, { status: "pending" });
|
useGenerationStore.getState().updateTask(task.id, { status: "pending" });
|
||||||
} else {
|
} else {
|
||||||
// No taskId means it was queued but never submitted — mark failed
|
|
||||||
useGenerationStore.getState().updateTask(task.id, {
|
useGenerationStore.getState().updateTask(task.id, {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
error: "页面刷新后任务丢失,请重新提交",
|
error: "页面刷新后任务没有服务端 ID,已释放本地占用,请重新提交。",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start polling recovered tasks
|
|
||||||
setTimeout(() => startBackgroundPolling(), 500);
|
setTimeout(() => startBackgroundPolling(), 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
@import "./pages/studio-layout.css";
|
@import "./pages/studio-layout.css";
|
||||||
@import "./pages/image-workbench.css";
|
@import "./pages/image-workbench.css";
|
||||||
@import "./pages/subtitle-removal.css";
|
@import "./pages/subtitle-removal.css";
|
||||||
|
@import "./pages/dialog-generator.css";
|
||||||
@import "./pages/size-template.css";
|
@import "./pages/size-template.css";
|
||||||
@import "./pages/script-tokens-v5.css";
|
@import "./pages/script-tokens-v5.css";
|
||||||
@import "./pages/script-tokens.css";
|
@import "./pages/script-tokens.css";
|
||||||
|
|||||||
@@ -722,3 +722,191 @@
|
|||||||
right: -9999px;
|
right: -9999px;
|
||||||
height: 1px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,580 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -418,6 +418,15 @@
|
|||||||
cursor: not-allowed;
|
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 {
|
.product-clone-page[data-tool="set"] .product-clone-help {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -4255,6 +4264,7 @@
|
|||||||
.product-clone-panel__footer {
|
.product-clone-panel__footer {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
@@ -4279,6 +4289,11 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-clone-primary--cancel {
|
||||||
|
background: #303540;
|
||||||
|
color: #eef2f6;
|
||||||
|
}
|
||||||
|
|
||||||
.product-clone-preview {
|
.product-clone-preview {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
@@ -5209,6 +5224,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-set-main-card {
|
.product-set-main-card {
|
||||||
|
position: relative;
|
||||||
height: 380px;
|
height: 380px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
transition: transform 250ms ease, box-shadow 250ms ease;
|
transition: transform 250ms ease, box-shadow 250ms ease;
|
||||||
@@ -9045,6 +9061,17 @@
|
|||||||
filter: none;
|
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 {
|
.product-clone-page[data-tool="clone"] .clone-ai-settings-toggle {
|
||||||
border-color: var(--ecm-line-strong);
|
border-color: var(--ecm-line-strong);
|
||||||
background: rgba(20, 23, 25, 0.86);
|
background: rgba(20, 23, 25, 0.86);
|
||||||
@@ -9270,6 +9297,17 @@
|
|||||||
box-shadow: none;
|
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 {
|
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) .product-clone-preview {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 50% 40%, rgba(var(--ecm-accent-rgb), 0.032), transparent 40%),
|
radial-gradient(circle at 50% 40%, rgba(var(--ecm-accent-rgb), 0.032), transparent 40%),
|
||||||
|
|||||||
@@ -2802,6 +2802,10 @@
|
|||||||
color: #e9fff5;
|
color: #e9fff5;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.script-eval-v5-uf-size {
|
.script-eval-v5-uf-size {
|
||||||
@@ -3230,6 +3234,141 @@
|
|||||||
color: var(--report-green);
|
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 {
|
.script-eval-report__findings {
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
@@ -3278,6 +3417,10 @@
|
|||||||
.script-eval-report--inside .script-eval-report__body {
|
.script-eval-report--inside .script-eval-report__body {
|
||||||
padding-inline: 24px;
|
padding-inline: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.script-eval-report__detail-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
@@ -3297,6 +3440,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
@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 {
|
.script-eval-v5 {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,9 +202,9 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
grid-template-rows: 1fr 1fr;
|
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-height: clamp(360px, 40vw, 520px);
|
min-height: clamp(560px, 52vw, 760px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Tool Cards ===== */
|
/* ===== Tool Cards ===== */
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
|
|
||||||
.wb-prompt-cases__grid {
|
.wb-prompt-cases__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
grid-auto-flow: dense;
|
grid-auto-flow: dense;
|
||||||
grid-auto-rows: 10px;
|
grid-auto-rows: 10px;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-prompt-case-card {
|
.wb-prompt-case-card {
|
||||||
@@ -34,22 +34,22 @@
|
|||||||
|
|
||||||
.wb-prompt-case-card--ratio-wide {
|
.wb-prompt-case-card--ratio-wide {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
grid-row: span 8;
|
grid-row: span 13;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-prompt-case-card--ratio-tall {
|
.wb-prompt-case-card--ratio-tall {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
grid-row: span 23;
|
grid-row: span 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-prompt-case-card--ratio-square {
|
.wb-prompt-case-card--ratio-square {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
grid-row: span 13;
|
grid-row: span 18;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-prompt-case-card--ratio-portrait {
|
.wb-prompt-case-card--ratio-portrait {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
grid-row: span 16;
|
grid-row: span 24;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-prompt-case-card img {
|
.wb-prompt-case-card img {
|
||||||
@@ -328,7 +328,7 @@
|
|||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.wb-prompt-cases__grid {
|
.wb-prompt-cases__grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
grid-auto-rows: 8px;
|
grid-auto-rows: 8px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
@@ -387,7 +387,7 @@
|
|||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.wb-prompt-cases__grid {
|
.wb-prompt-cases__grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
grid-auto-rows: 8px;
|
grid-auto-rows: 8px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2410,6 +2410,48 @@
|
|||||||
border-color: rgba(239, 68, 68, 0.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__bar,
|
||||||
.web-shell[data-ui-theme="dark-green"] .ai-generation-pending-card.is-video .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;
|
position: absolute;
|
||||||
@@ -4951,6 +4993,332 @@
|
|||||||
color: var(--accent);
|
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 {
|
.web-shell[data-ui-theme="dark-green"] .profile-page {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -5077,6 +5445,12 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-size: 11px;
|
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 {
|
.web-shell[data-ui-theme="dark-green"] .profile-page__avatar-edit:hover {
|
||||||
@@ -6370,6 +6744,14 @@
|
|||||||
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit {
|
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit {
|
||||||
width: 82px;
|
width: 82px;
|
||||||
height: 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 {
|
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-badge {
|
||||||
@@ -6808,6 +7190,7 @@
|
|||||||
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit {
|
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
|
padding-left: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__counts {
|
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__counts {
|
||||||
@@ -8472,6 +8855,134 @@
|
|||||||
color: var(--accent);
|
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. */
|
/* Responsive floating navigation: prevent squeeze/warp on narrow workspaces. */
|
||||||
.web-shell[data-ui-theme="dark-green"] .floating-nav {
|
.web-shell[data-ui-theme="dark-green"] .floating-nav {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -8902,21 +9413,43 @@
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.web-shell[data-ui-theme="dark-green"] .auth-page {
|
.web-shell[data-ui-theme="dark-green"] .auth-page {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: 200px 1fr;
|
grid-template-rows: 180px minmax(0, 1fr);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.web-shell[data-ui-theme="dark-green"] .auth-page__form-panel {
|
.web-shell[data-ui-theme="dark-green"] .auth-page__form-panel {
|
||||||
padding: 24px 20px;
|
align-items: flex-start;
|
||||||
|
padding: 22px 20px 36px;
|
||||||
border-top: 1px solid var(--border-weak);
|
border-top: 1px solid var(--border-weak);
|
||||||
border-left: 0;
|
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 {
|
.web-shell[data-ui-theme="dark-green"] .auth-page__brand {
|
||||||
font-size: 32px;
|
font-size: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.web-shell[data-ui-theme="dark-green"] .auth-page__tagline {
|
.web-shell[data-ui-theme="dark-green"] .auth-page__tagline {
|
||||||
font-size: 16px;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.web-shell[data-ui-theme="dark-green"] .profile-page__body {
|
.web-shell[data-ui-theme="dark-green"] .profile-page__body {
|
||||||
@@ -8926,9 +9459,82 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@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 {
|
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.web-shell[data-ui-theme="dark-green"] .profile-page__body {
|
.web-shell[data-ui-theme="dark-green"] .profile-page__body {
|
||||||
@@ -9052,6 +9658,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Canvas SaaS polish: refined production-tool surfaces without changing canvas behavior. */
|
/* 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 {
|
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-canvas {
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(circle at 18% 8%, rgba(var(--accent-rgb), 0.055), transparent 30%),
|
radial-gradient(circle at 18% 8%, rgba(var(--accent-rgb), 0.055), transparent 30%),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type WebViewKey =
|
|||||||
| "more"
|
| "more"
|
||||||
| "watermarkRemoval"
|
| "watermarkRemoval"
|
||||||
| "subtitleRemoval"
|
| "subtitleRemoval"
|
||||||
|
| "dialogGenerator"
|
||||||
| "communityReview"
|
| "communityReview"
|
||||||
| "communityCaseAdd"
|
| "communityCaseAdd"
|
||||||
| "report"
|
| "report"
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
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}`;
|
||||||
|
}
|
||||||
+1
-1
@@ -25,7 +25,7 @@ export default defineConfig(() => ({
|
|||||||
drop: ["console", "debugger"],
|
drop: ["console", "debugger"],
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
sourcemap: "hidden",
|
sourcemap: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks(id: string) {
|
manualChunks(id: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user