Compare commits

...

17 Commits

Author SHA1 Message Date
stringadmin 85b2016e69 fix: 电商页面 KeepAlive 容器使用绝对定位铺满视口,解决底部留白问题
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 23:25:10 +08:00
stringadmin 0fc180637c feat: 电商页面 KeepAlive 保活机制,切换页面不再丢失生成状态
通过 display:none 模式实现轻量 KeepAlive,电商页面首次访问后保持挂载,
切换到其他页面再切回时所有右侧面板状态(上传图片、生成进度、结果)完整保留。
同时清理项目中的临时文件和本地冗余图片。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 23:20:57 +08:00
stringadmin fdf9c43731 merge master, accept theirs for known conflict files 2026-06-03 21:53:47 +08:00
stringadmin f86ca99548 Merge pull request '首页功能页更改' (#9) from 首页功能页更改 into master
Reviewed-on: #9
2026-06-03 13:44:57 +00:00
OmniAI Developer 4e95555bda merge: 解决与 master 的冲突,保留双方改动 2026-06-03 21:43:11 +08:00
OmniAI Developer 8d7f5d9a8a feat: 图片工作台/镜头实验室/局部重绘/数字人/去水印页面UI优化 2026-06-03 20:36:07 +08:00
stringadmin db79ee2c80 Revert "merge: re-merge origin/master after rollback, resolve same conflicts"
This reverts commit 1546644dec, reversing
changes made to f5a75074a4.
2026-06-03 20:34:43 +08:00
stringadmin 1546644dec merge: re-merge origin/master after rollback, resolve same conflicts 2026-06-03 20:33:28 +08:00
stringadmin 611ca5539d Merge pull request 'Fix/ecommerce video 400 bug' (#10) from fix/ecommerce-video-400-bug into master
Reviewed-on: #10
2026-06-03 12:24:19 +00:00
stringadmin 1998eb21c5 merge: resolve conflicts with origin/master 2026-06-03 20:23:27 +08:00
stringadmin f5a75074a4 feat: 邮箱注册验证 + 9项功能修复与优化
【认证系统】
- 新增邮箱验证码注册/登录流程 (sendEmailCode / verifyEmail / forgotPassword / resetPassword)
- register-email 现在需要验证码
- 服务端新增 email_verification_codes 表 + patch-email-verification.js
- App.tsx 登录后 emailVerified 检查提醒
- keyServerClient token 显式传递修复 401 错误

【电商模块】
- 自动推进: 策划完成后自动生成分镜图/视频
- 模特图选项 (性别/年龄/种族/体型/场景) 注入 AI 提示词
- 任务持久化指纹修复 (图片数量替代 blob URL)
- 新增「视频换装」入口 (happyhorse-1.0-video-edit)

【剧本评分】
- 新增 .docx/.doc Word 文档支持 (ZIP解压+XML提取)
- 历史记录支持点击查看/恢复评测结果

【画布】
- ReactFlow 节点禁止内置拖拽避免冲突
- 连接线拖拽弹窗优化 (预览线不消失, 弹窗跟踪鼠标)

【页面修复】
- 首页轮播图改为 aspect-ratio: 16/9 解决尺寸问题
- 资产库新增悬停删除按钮
- scriptEvalClient 改用服务端 /api/ai/chat 端点
- TokenUsagePage 未登录跳过 API 调用
2026-06-03 20:19:07 +08:00
stringadmin 7a62ccc1ca Merge pull request 'Feat/commercial saas polish' (#8) from feat/commercial-saas-polish into master
Reviewed-on: #8
2026-06-03 12:16:10 +00:00
OmniAI Developer 7afcfa54c2 feat: 个人中心侧边栏显示、代表作滚动、图片工作台预览样式、首页轮播尺寸调整 2026-06-03 19:15:45 +08:00
ludan 7be4e65e1e feat: 管理中心Token用量页面SaaS商业化精修
【页面状态感知】
- 新增页面级状态类名(is-syncing/has-sync-error/has-low-balance/is-healthy)
- 状态指示灯根据加载/错误/正常动态切换样式与动画
- 加载中禁用刷新按钮,防止重复请求

【工具栏视觉升级】
- 标题增加副标题("用量、成员与模型调用监控")
- 状态胶囊 pill 增加圆点动画(绿色脉冲=在线,琥珀色=异常)
- 按钮增加 hover 品牌绿高亮、禁用态半透明
- 成员管理按钮增加 is-muted-action 弱化样式

【指标卡片精修】
- 四张指标卡片增加序号角标(01-04),左侧彩色竖线标识
- 主卡片(可用额度)增加品牌绿光晕背景 + 绿色竖线
- 卡片增加渐变背景、顶部光泽内阴影、圆角阴影

【数据展示增强】
- 模型消耗分布/系统状态/调用记录标题增加图标
- 模型消耗列表项增加 hover 浮起 + 品牌绿边框
- 调用记录表格增加表头背景、行 hover 高亮、状态标签徽章
- 分页按钮增加 hover 品牌绿填充

【响应式适配】
- 1180px/900px/560px 三级断点完整适配
- 900px:工具栏吸顶、指标卡单列、图表区单列
- 560px:工具栏按钮 2 列网格、状态胶囊全宽、卡片间距缩小
2026-06-03 18:52:14 +08:00
ludan 73a6043310 feat: 剧本评分SaaS化精修、画布视觉升级、电商克隆预览响应式修复
【剧本评分页面 SaaS 商业化精修】
- 上传区增加玻璃拟态渐变边框,hover 时高亮为品牌绿
- 已上传文件显示文件名+文件大小,重新上传按钮优化
- 上传按钮文案从"+ 上传剧本"改为"选择剧本",增加图标
- 评测按钮增加 LoadingOutlined/ThunderboltOutlined 图标动画
- 评测等待态增加分步加载提示(结构识别/冲突评估/商业潜力)
- 六维评分柱状图增加 hover/focus 交互:悬停维度高亮,其余维度 dim,底部显示当前维度详细说明
- 评分卡片、报告面板、历史记录项增加渐变背景与阴影层次
- 新增 script-tokens-v5 设计 Token 变量体系
- 响应式断点适配 1180px/900px/680px,移动端左右面板上下堆叠

【画布页面视觉升级】
- 画布背景增加网点纹理 + 径向渐变,增强空间感
- 项目栏/缩放控件增加玻璃拟态毛玻璃效果
- 节点卡片增加渐变背景、内阴影、边框高亮
- 选中节点增加品牌绿外圈光环 + 投影
- 连线 connector 增加 hover 品牌绿高亮
- 节点连线/选区框/缩放手柄统一品牌绿主题色
- 所有编辑器/菜单面板统一玻璃拟态风格
- 移动端 560px 项目栏改为 4 列网格布局,按钮显示中文标签(编辑/最近/导出/提交)

【电商克隆预览响应式修复】
- 短视口(高度≤760px)下预览面板内 header/空状态/底部输入改为静态流式布局
- 平板(≤860px)下预览面板取消绝对定位,改为 grid 流式布局
- 手机(≤620px)缩小间距与最小高度

【其他】
- 个人中心登录注册表单移除 form-kicker 标签
- 品牌色(auth-page__brand)调整为强调绿色
2026-06-03 18:21:10 +08:00
OmniAI Developer 7b41cf3e87 feat: 首页功能页样式与组件更新 2026-06-03 17:28:44 +08:00
stringadmin 56dabf1f7d fix: 电商视频生成链路稳定性 — AI超时/重试/断点续传 + 404页面 + DashScope Key移除
- adVideoPlanClient: 模型级联降级(qwen-max→plus→turbo), 5xx/网络错误可重试, 超时延长至180s, 错误信息包含上游响应体
- 服务端ai/chat: 超时60s→120s, AbortError返回504(非500), PM2已热重载
- EcommerceVideoWorkspace: 策划失败后支持从断点继续(保留已完成步骤的中间产物), 分镜图/视频生成仅重做失败场景
- scriptEvalClient: 移除客户端DASHSCOPE_API_KEY引用(Nginx代理注入)
- NotFoundPage: 未知路由显示404页面(替代兜底跳首页)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:16:33 +08:00
59 changed files with 8170 additions and 4977 deletions
+59 -25
View File
@@ -14,12 +14,13 @@ import {
ToolOutlined,
WalletOutlined,
} from "@ant-design/icons";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from "react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import ErrorBoundary from "./components/ErrorBoundary";
import { reportError } from "./utils/errorReporting";
import { initNotificationPermission } from "./utils/generationNotifier";
import PageTransition from "./components/PageTransition";
import ToastContainer from "./components/toast/ToastContainer";
import { toast } from "./components/toast/toastStore";
import { aiGenerationClient } from "./api/aiGenerationClient";
import { keyServerClient } from "./api/keyServerClient";
import { notificationClient } from "./api/notificationClient";
@@ -32,7 +33,10 @@ import {
} from "./api/serverConnection";
import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway";
import { translateTaskError } from "./utils/translateTaskError";
import { recoverAndResumeTasks } from "./services/backgroundTaskRunner";
import AppShell from "./components/AppShell";
const NotFoundPage = lazy(() => import("./components/NotFoundPage"));
const CompliancePage = lazy(() => import("./features/compliance/CompliancePage"));
import { cloneWorkflow, createBlankWorkflow } from "./data/workflows";
const AgentPage = lazy(() => import("./features/agent/AgentPage"));
const AssetsPage = lazy(() => import("./features/assets/AssetsPage"));
@@ -55,7 +59,6 @@ const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/Wat
const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage"));
const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage"));
const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage"));
import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage";
import {
@@ -102,7 +105,6 @@ const VIEW_KEYS = new Set<WebViewKey>([
"ecommerce",
"scriptTokens",
"tokenUsage",
"settings",
"imageWorkbench",
"resolutionUpscale",
"watermarkRemoval",
@@ -115,22 +117,29 @@ const VIEW_KEYS = new Set<WebViewKey>([
"communityCaseAdd",
"report",
"providerHealth",
"userAgreement",
"privacyPolicy",
"not-found",
]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more"]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]);
function normalizeViewKey(rawView: string): WebViewKey {
const normalized =
rawView === "profile" || rawView === "auth"
? "login"
: rawView === "ecommerceHub"
? "ecommerce"
: rawView === "ecommerceHub"
? "ecommerce"
: rawView === "terms" || rawView === "agreement" || rawView === "user-agreement"
? "userAgreement"
: rawView === "privacy" || rawView === "privacy-policy"
? "privacyPolicy"
: rawView === "community-review"
? "communityReview"
: rawView === "community-case-add"
? "communityCaseAdd"
: rawView;
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "home";
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found";
}
function readViewFromHash(): WebViewKey {
@@ -146,7 +155,8 @@ function isWorkspaceView(view: WebViewKey): boolean {
view !== "ecommerceHub" &&
view !== "ecommerce" &&
view !== "scriptTokens" &&
view !== "login"
view !== "login" &&
view !== "not-found"
);
}
@@ -274,6 +284,12 @@ function App() {
const markAllNotificationsRead = useAppStore((s) => s.markAllNotificationsRead);
const clearAppState = useAppStore((s) => s.clearAppState);
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
useEffect(() => {
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
}, [isEcommerceActive]); // eslint-disable-line react-hooks/exhaustive-deps
// Dismiss boot splash after first render
useEffect(() => {
const splash = document.getElementById("app-boot-splash");
@@ -318,6 +334,11 @@ function App() {
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Recover background tasks on app start ──────────
useEffect(() => {
recoverAndResumeTasks();
}, []);
const navItems = useMemo<WebNavItem[]>(
() => [
{ key: "home", label: "首页", hint: "项目入口", icon: <HomeOutlined /> },
@@ -835,6 +856,10 @@ function App() {
setSession(nextSession);
await hydrateAccountData(nextSession);
if (nextSession.user.email && !nextSession.user.emailVerified) {
toast.info("邮箱尚未验证,部分功能可能受限,请在登录页通过邮箱验证码完成验证");
}
const action = pendingAction;
closeLoginPrompt();
if (action) {
@@ -1056,20 +1081,7 @@ function App() {
return <AssetsPage isAuthenticated={Boolean(session)} onOpenLogin={handleOpenLogin} />;
case "ecommerce":
case "ecommerceHub":
return (
<EcommercePage
projects={projects}
isAuthenticated={Boolean(session)}
onStartCreate={handleStartCreate}
onOpenProject={handleOpenProject}
onDeleteProject={handleDeleteProject}
onImportWorkflow={handleImportWorkflow}
onCreateTask={handleCreateTask}
onRequireLogin={handleRequireTaskLogin}
initialTemplate={pendingEcommerceTemplate}
onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)}
/>
);
return null;
case "digitalHuman":
return (
<DigitalHumanPage
@@ -1109,8 +1121,6 @@ function App() {
onSelectView={handleSetView}
/>
);
case "settings":
return <SettingsPage />;
case "imageWorkbench":
return (
<ImageWorkbenchPage
@@ -1150,6 +1160,10 @@ function App() {
return <ReportPage />;
case "providerHealth":
return <ProviderHealthPage session={session} onOpenLogin={handleOpenLogin} />;
case "userAgreement":
return <CompliancePage kind="agreement" />;
case "privacyPolicy":
return <CompliancePage kind="privacy" />;
case "communityReview":
return (
<CommunityReviewPage
@@ -1178,7 +1192,6 @@ function App() {
/>
);
case "home":
default:
return (
<HomePage
onOpenGenerate={() => handleSetView("workbench")}
@@ -1190,6 +1203,9 @@ function App() {
onOpenImageTool={handleOpenImageWorkbenchTool}
/>
);
case "not-found":
default:
return <NotFoundPage onGoHome={() => handleSetView("home")} />;
}
})();
@@ -1218,6 +1234,24 @@ function App() {
<PageTransition viewKey={activeView}>
{activePage}
</PageTransition>
{/* KeepAlive: EcommercePage stays mounted once visited */}
{ecommerceEverMounted && (
<div style={{ display: isEcommerceActive ? undefined : "none", position: "absolute", inset: 0, zIndex: 1 }}>
<EcommercePage
projects={projects}
isAuthenticated={Boolean(session)}
onStartCreate={handleStartCreate}
onOpenProject={handleOpenProject}
onDeleteProject={handleDeleteProject}
onImportWorkflow={handleImportWorkflow}
onCreateTask={handleCreateTask}
onRequireLogin={handleRequireTaskLogin}
initialTemplate={pendingEcommerceTemplate}
onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)}
/>
</div>
)}
</Suspense>
</ErrorBoundary>
+70 -40
View File
@@ -1,8 +1,7 @@
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
const TEXT_MODEL = "qwen-max";
const VISION_MODEL = "qwen3.7-plus";
const VISION_FALLBACK_MODEL = "qwen-vl-plus";
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
export interface AdVideoUserConfig {
platform: string;
@@ -110,27 +109,41 @@ interface ChatMessage {
const MAX_RETRIES = 3;
const RETRY_BASE_MS = 2000;
const CHAT_TIMEOUT_MS = 120_000; // 2 minutes per AI call
const CHAT_TIMEOUT_MS = 180_000; // 3 minutes per AI call (server times out at 120s + network slack)
// 5xx, 429, network failures, timeouts, and AbortError-from-timeout are all retryable
function isTransientError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return /\b429\b/.test(msg) || msg.includes("signal timed out") || msg.includes("aborted") || msg.includes("timeout");
if (/\b(429|500|502|503|504|520|521|522|524)\b/.test(msg)) return true;
if (msg.includes("signal timed out") || msg.includes("timeout")) return true;
if (msg.includes("failed to fetch") || msg.includes("networkerror") || msg.includes("network error")) return true;
if (msg.includes("ai 调用失败") || msg.includes("图片理解调用失败")) return true; // generic upstream failures
return false;
}
async function retryOnTransient<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (signal?.aborted) throw err;
// External AbortError caused by our timeoutSignal — retryable
if (err instanceof Error && err.name === "AbortError" && !signal?.aborted) {
if (attempt === MAX_RETRIES) throw err;
const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000;
await new Promise((r) => setTimeout(r, delay));
continue;
}
if (attempt === MAX_RETRIES) throw err;
if (!isTransientError(err)) throw err;
const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000;
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error("unreachable");
throw lastErr instanceof Error ? lastErr : new Error("AI 调用失败:已重试多次");
}
async function chat(
@@ -138,33 +151,45 @@ async function chat(
userContent: string,
options?: { model?: string; signal?: AbortSignal },
): Promise<string> {
return retryOnTransient(async () => {
const messages: ChatMessage[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent },
];
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = options?.signal
? AbortSignal.any([options.signal, timeoutSignal])
: timeoutSignal;
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({
model: options?.model ?? TEXT_MODEL,
messages,
stream: false,
temperature: 0.4,
}),
signal: combinedSignal,
});
if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`);
const payload = await res.json();
const content: string =
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!content) throw new Error("模型未返回有效内容");
return content;
}, options?.signal);
const candidateModels = options?.model ? [options.model] : TEXT_MODELS;
let lastError: Error | null = null;
for (const model of candidateModels) {
try {
return await retryOnTransient(async () => {
const messages: ChatMessage[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent },
];
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = options?.signal
? AbortSignal.any([options.signal, timeoutSignal])
: timeoutSignal;
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({ model, messages, stream: false, temperature: 0.4 }),
signal: combinedSignal,
});
if (!res.ok) {
const errBody = await res.text().catch(() => "");
throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
}
const payload = await res.json();
const content: string =
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!content) throw new Error("模型未返回有效内容");
return content;
}, options?.signal);
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (options?.signal?.aborted) throw lastError;
// If user pinned a specific model, don't fall back to others
if (options?.model) throw lastError;
// Try next model in fallback chain
}
}
throw lastError ?? new Error("所有候选模型均不可用");
}
async function visionChat(
@@ -182,7 +207,8 @@ async function visionChat(
{ role: "user", content },
];
for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) {
let lastError: Error | null = null;
for (const model of VISION_MODELS) {
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal])
@@ -197,8 +223,8 @@ async function visionChat(
});
if (!res.ok) {
const errBody = await res.text().catch(() => "");
if (model === VISION_MODEL && errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
throw new Error(`图片理解调用失败 (${res.status})`);
if (errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
}
const payload = await res.json();
const result: string =
@@ -208,12 +234,16 @@ async function visionChat(
}, signal);
return out;
} catch (err) {
if (err instanceof Error && err.message === "IMAGE_FORMAT_FALLBACK") continue;
if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue;
throw err;
lastError = err instanceof Error ? err : new Error(String(err));
if (signal?.aborted) throw lastError;
// Continue trying next vision model on transient failures, image format errors, or upstream errors
if (lastError.message === "IMAGE_FORMAT_FALLBACK") continue;
if (lastError.message.includes("图片理解调用失败")) continue;
if (isTransientError(lastError)) continue;
throw lastError;
}
}
throw new Error("图片理解调用失败,所有模型均不可用");
throw lastError ?? new Error("图片理解调用失败,所有模型均不可用");
}
const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`;
+23
View File
@@ -63,6 +63,17 @@ export interface VideoGenInput {
style?: "speech" | "sing" | "performance" | string;
}
export interface VideoEditInput {
projectId?: string;
conversationId?: number;
videoUrl: string;
referenceUrls: string[];
prompt?: string;
model?: string;
ratio?: string;
resolution?: string;
}
export interface VideoSuperResolveInput {
projectId?: string;
conversationId?: number;
@@ -290,6 +301,18 @@ export const aiGenerationClient = {
return readJsonResponse<{ taskId: string }>(res, "Subtitle removal response failed");
},
async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/video/edit"), {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({ ...input, model: input.model || "happyhorse-1.0-video-edit" }),
});
if (!res.ok) {
await throwResponseError(res, "Video edit request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Video edit response failed");
},
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/image/super-resolve"), {
method: "POST",
+85 -5
View File
@@ -30,9 +30,26 @@ interface EmailAuthInput {
email: string;
password: string;
username?: string;
code?: string;
betaCode?: string;
}
interface EmailCodeInput {
email: string;
code: string;
purpose?: "register" | "login";
}
interface ForgotPasswordInput {
email: string;
}
interface ResetPasswordInput {
email: string;
code: string;
newPassword: string;
}
interface PhoneAuthInput {
phone: string;
code: string;
@@ -52,6 +69,19 @@ interface DeleteProjectOptions {
cleanupUserData?: boolean;
}
export interface RechargeOrderInput {
planId: string;
paymentMethod: "wechat" | "alipay" | "bank";
}
export interface RechargeOrderResult {
orderId: string;
status: string;
payUrl?: string | null;
qrCodeUrl?: string | null;
message?: string | null;
}
export interface WechatLoginTicket {
configured: boolean;
url?: string;
@@ -624,6 +654,21 @@ function normalizeEnterpriseUsageSummary(payload: unknown): WebEnterpriseUsageSu
};
}
function normalizeRechargeOrder(payload: unknown): RechargeOrderResult {
const raw = unwrapApiPayload(payload);
if (!isRecord(raw)) {
return { orderId: `local-${Date.now()}`, status: "pending", message: "订单已提交,请联系客服确认到账。" };
}
return {
orderId: toStringValue(raw.orderId ?? raw.order_id ?? raw.id, `local-${Date.now()}`),
status: toStringValue(raw.status, "pending"),
payUrl: toNullableString(raw.payUrl ?? raw.pay_url ?? raw.checkoutUrl ?? raw.checkout_url),
qrCodeUrl: toNullableString(raw.qrCodeUrl ?? raw.qr_code_url ?? raw.qrcodeUrl),
message: toNullableString(raw.message ?? raw.notice),
};
}
function buildProjectUpsertPayload(workflow: WebCanvasWorkflow, session: WebUserSession): Record<string, unknown> {
const userId = String(session.user.id).replace(/[^a-zA-Z0-9_-]/g, "");
const projectId = workflow.id.trim();
@@ -714,6 +759,7 @@ export const keyServerClient = {
email: input.email.trim(),
username: input.username?.trim() || undefined,
password: input.password,
code: input.code?.trim() || undefined,
betaCode: input.betaCode?.trim() || undefined,
},
}),
@@ -731,6 +777,30 @@ export const keyServerClient = {
body: { phone: phone.trim(), purpose, betaCode: betaCode?.trim() || undefined },
});
},
async sendEmailCode(email: string, purpose: "login" | "register" | "reset", betaCode?: string): Promise<{ cooldownSeconds?: number; ttlSeconds?: number; devCode?: string }> {
return request<{ cooldownSeconds?: number; ttlSeconds?: number; devCode?: string }>("/auth/email/send-code", {
method: "POST",
body: { email: email.trim(), purpose, betaCode: betaCode?.trim() || undefined },
});
},
async verifyEmail(input: EmailCodeInput): Promise<{ success: boolean }> {
return request<{ success: boolean }>("/auth/email/verify", {
method: "POST",
body: { email: input.email.trim(), code: input.code.trim(), purpose: input.purpose || "register" },
});
},
async forgotPassword(input: ForgotPasswordInput): Promise<{ success: boolean; message?: string }> {
return request<{ success: boolean; message?: string }>("/auth/forgot-password", {
method: "POST",
body: { email: input.email.trim() },
});
},
async resetPassword(input: ResetPasswordInput): Promise<{ success: boolean; message?: string }> {
return request<{ success: boolean; message?: string }>("/auth/reset-password", {
method: "POST",
body: { email: input.email.trim(), code: input.code.trim(), newPassword: input.newPassword },
});
},
async loginPhone(input: PhoneAuthInput): Promise<WebUserSession> {
const session = normalizeLoginResult(
await request<unknown>("/auth/login-phone", {
@@ -855,13 +925,23 @@ export const keyServerClient = {
return normalizeProjectContent(response, projectId);
},
async getUsageSummary(): Promise<WebUsageSummary> {
return normalizeUsageSummary(await request<unknown>("/user/usage/summary"));
const stored = readStoredSession();
return normalizeUsageSummary(await request<unknown>("/user/usage/summary", { token: stored?.token }));
},
async getEnterpriseUsageSummary(): Promise<WebEnterpriseUsageSummary> {
return normalizeEnterpriseUsageSummary(await request<unknown>("/enterprise/usage/summary"));
const stored = readStoredSession();
return normalizeEnterpriseUsageSummary(await request<unknown>("/enterprise/usage/summary", { token: stored?.token }));
},
async getPersonalUsageSummary(): Promise<WebEnterpriseUsageSummary> {
return normalizeEnterpriseUsageSummary(await request<unknown>("/user/usage/credits"));
const stored = readStoredSession();
return normalizeEnterpriseUsageSummary(await request<unknown>("/user/usage/credits", { token: stored?.token }));
},
async createRechargeOrder(input: RechargeOrderInput): Promise<RechargeOrderResult> {
const response = await request<unknown>("/payments/recharge-orders", {
method: "POST",
body: input,
});
return normalizeRechargeOrder(response);
},
async createProjectSpace(workflow: WebCanvasWorkflow): Promise<WebProjectSummary> {
const stored = readStoredSession();
@@ -929,8 +1009,8 @@ export const keyServerClient = {
});
},
async getClientErrors(page = 1): Promise<{ items: unknown[]; total: number }> {
const data = await request<{ items: unknown[]; total: number }>(`/client-errors?page=${page}`);
async getClientErrors(page = 1): Promise<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }> {
const data = await request<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }>(`/client-errors?page=${page}`);
return data;
},
};
+5 -16
View File
@@ -1,3 +1,5 @@
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
export interface ScriptEvalResult {
totalScore: number;
grade: string;
@@ -8,8 +10,6 @@ export interface ScriptEvalResult {
suggestions: string[];
}
const DASHSCOPE_API_KEY = import.meta.env.VITE_DASHSCOPE_API_KEY || "";
const DASHSCOPE_ENDPOINT = "/dashscope-api/chat/completions";
const MODEL = "qwen3.7-max";
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
@@ -69,16 +69,9 @@ function extractJson(text: string): unknown {
}
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
if (!DASHSCOPE_API_KEY) {
throw new Error("DashScope API key 未配置,请在 .env.local 中设置 VITE_DASHSCOPE_API_KEY");
}
const res = await fetch(DASHSCOPE_ENDPOINT, {
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${DASHSCOPE_API_KEY}`,
},
headers: buildAuthHeaders(),
body: JSON.stringify({
model: MODEL,
messages: [
@@ -98,11 +91,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
}
const payload = await res.json();
const content: string = payload?.choices?.[0]?.message?.content
?? payload?.result?.content
?? payload?.content
?? payload?.text
?? (typeof payload === "string" ? payload : "");
const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!content) throw new Error("模型未返回有效内容");
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { keyServerClient } from "../api/keyServerClient";
interface ClientErrorItem {
export interface ClientErrorItem {
id: number;
message: string;
stack?: string;
+9 -6
View File
@@ -22,6 +22,7 @@ import NotificationCenter from "./NotificationCenter";
import { RechargeModal } from "./RechargeModal/RechargeModal";
import { AnimatedPanel } from "./AnimatedPanel";
import AdminMonitor from "./AdminMonitor";
import CookieConsentBanner from "./CookieConsentBanner";
interface AppShellProps {
activeView: WebViewKey;
@@ -40,6 +41,7 @@ interface AppShellProps {
}
const BRAND_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png";
const CLIENT_ERROR_MONITOR_ENABLED = import.meta.env.VITE_ENABLE_CLIENT_ERROR_MONITOR === "1";
function formatBalance(cents: number): string {
const value = Math.max(0, cents) / 100;
@@ -73,7 +75,7 @@ function AppShell({
const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null);
const isAuthView = activeView === "login";
const isImmersiveView = activeView === "agent" || activeView === "avatarConsole";
const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home";
const showFloatingNav = (!isAuthView || !!session) && !isImmersiveView && activeView !== "home";
const toolSurfaceViews = [
"workbench",
"canvas",
@@ -88,7 +90,7 @@ function AppShell({
"avatarConsole",
"characterMix",
] as WebViewKey[];
const showPageScrollActions = false;
const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView);
const visibleNavItems = useMemo(
() => {
@@ -344,8 +346,8 @@ function AppShell({
<dd>15155073618</dd>
</dl>
<div className="info-popover__links">
<a href="#" onClick={(e) => { e.preventDefault(); setInfoOpen(false); }}></a>
<a href="#" onClick={(e) => { e.preventDefault(); setInfoOpen(false); }}></a>
<a href="#/userAgreement" onClick={() => setInfoOpen(false)}></a>
<a href="#/privacyPolicy" onClick={() => setInfoOpen(false)}></a>
</div>
</AnimatedPanel>
</div>
@@ -356,7 +358,7 @@ function AppShell({
onClick={() => setRechargeOpen(true)}
>
<WalletOutlined />
{displayedBalanceLabel}
<span className="member-button__label">{displayedBalanceLabel}</span>
</button>
<div className="profile-popover-anchor" ref={profileRef}>
<button
@@ -471,8 +473,9 @@ function AppShell({
<div className="web-shell__page">{children}</div>
</main>
</div>
{session?.user.role === "admin" ? <AdminMonitor /> : null}
{CLIENT_ERROR_MONITOR_ENABLED && session?.user.role === "admin" ? <AdminMonitor /> : null}
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
<CookieConsentBanner />
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
const COOKIE_CONSENT_KEY = "omniai:cookie-consent:v1";
export default function CookieConsentBanner() {
const [visible, setVisible] = useState(false);
useEffect(() => {
setVisible(localStorage.getItem(COOKIE_CONSENT_KEY) !== "accepted");
}, []);
const accept = () => {
localStorage.setItem(COOKIE_CONSENT_KEY, "accepted");
setVisible(false);
};
if (!visible) return null;
return (
<section className="cookie-consent" role="dialog" aria-live="polite" aria-label="Cookie 使用提示">
<div>
<strong>Cookie </strong>
<p>使 Cookie 稿</p>
</div>
<div className="cookie-consent__actions">
<a href="#/privacyPolicy"></a>
<button type="button" onClick={accept}></button>
</div>
</section>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { HomeOutlined } from "@ant-design/icons";
import { useCallback } from "react";
interface NotFoundPageProps {
onGoHome: () => void;
}
function NotFoundPage({ onGoHome }: NotFoundPageProps) {
return (
<section className="not-found-page page-motion">
<div className="not-found-page__content">
<div className="not-found-page__code">404</div>
<h1></h1>
<p>访</p>
<button type="button" className="not-found-page__button" onClick={onGoHome}>
<HomeOutlined />
</button>
</div>
</section>
);
}
export default NotFoundPage;
+2 -1
View File
@@ -27,7 +27,6 @@ const NAV_ORDER: string[] = [
"avatarConsole",
"characterMix",
"agent",
"settings",
"login",
"profile",
"report",
@@ -81,6 +80,8 @@ export default function PageTransition({ viewKey, children }: PageTransitionProp
const dirClass = exitDirection === "forward" ? " is-forward" : exitDirection === "backward" ? " is-backward" : "";
if (!displayedChildren) return null;
return (
<div className={phase === "exit" ? `page-transition-wrap page-motion--exit${dirClass}` : "page-transition-wrap"}>
{displayedChildren}
@@ -1,7 +1,10 @@
import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons";
import { useMemo, useState, type ReactNode } from "react";
import { keyServerClient, type RechargeOrderResult } from "../../api/keyServerClient";
import { toast } from "../toast/toastStore";
type RechargeAudience = "personal" | "enterprise";
type PaymentMethod = "wechat" | "alipay" | "bank";
interface MembershipPlan {
id: string;
@@ -107,6 +110,12 @@ const rechargeRules = [
"退费规则:充值积分到账后不支持退换、折现,仅限平台内消费",
];
const paymentMethods: Array<{ id: PaymentMethod; label: string; hint: string }> = [
{ id: "wechat", label: "微信支付", hint: "生成支付链接或二维码" },
{ id: "alipay", label: "支付宝", hint: "生成支付链接或二维码" },
{ id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" },
];
interface RechargeModalProps {
open: boolean;
onClose: () => void;
@@ -116,14 +125,43 @@ interface RechargeModalProps {
export function RechargeModal({ open, onClose, currentBalance }: RechargeModalProps) {
const [activeAudience, setActiveAudience] = useState<RechargeAudience>("personal");
const [selectedPlanIds, setSelectedPlanIds] = useState<Record<RechargeAudience, string>>(defaultSelectedPlanIds);
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("wechat");
const [submitting, setSubmitting] = useState(false);
const [order, setOrder] = useState<RechargeOrderResult | null>(null);
const visiblePlans = useMemo(() => membershipPlans.filter((plan) => plan.audience === activeAudience), [activeAudience]);
const selectedPlanId = selectedPlanIds[activeAudience];
const selectedPlan = membershipPlans.find((plan) => plan.id === selectedPlanId) ?? visiblePlans[0];
const handlePlanSelect = (plan: MembershipPlan) => {
setSelectedPlanIds((current) => ({
...current,
[plan.audience]: plan.id,
}));
setOrder(null);
};
const handleCreateOrder = async () => {
if (!selectedPlan || submitting) return;
setSubmitting(true);
try {
const nextOrder = await keyServerClient.createRechargeOrder({ planId: selectedPlan.id, paymentMethod });
setOrder(nextOrder);
if (nextOrder.payUrl) {
window.open(nextOrder.payUrl, "_blank", "noopener,noreferrer");
}
toast.success("充值订单已创建");
} catch (error) {
const message = error instanceof Error ? error.message : "订单创建失败,请联系客服处理。";
toast.error(message);
setOrder({
orderId: `support-${Date.now()}`,
status: "manual-review",
message: "支付接口暂不可用,请通过页面联系方式联系客服完成充值。",
});
} finally {
setSubmitting(false);
}
};
if (!open) return null;
@@ -224,6 +262,44 @@ export function RechargeModal({ open, onClose, currentBalance }: RechargeModalPr
))}
</ol>
</footer>
<section className="recharge-modal__checkout" aria-label="支付方式">
<div>
<span className="recharge-modal__checkout-eyebrow"></span>
<h3>{selectedPlan.name} · {selectedPlan.period}</h3>
<p>{selectedPlan.price}{selectedPlan.grant}</p>
</div>
<div className="recharge-modal__payment-methods" role="radiogroup" aria-label="选择支付方式">
{paymentMethods.map((method) => (
<button
key={method.id}
type="button"
role="radio"
aria-checked={paymentMethod === method.id}
className={paymentMethod === method.id ? "is-active" : ""}
onClick={() => {
setPaymentMethod(method.id);
setOrder(null);
}}
>
<strong>{method.label}</strong>
<span>{method.hint}</span>
</button>
))}
</div>
<button type="button" className="recharge-modal__pay" onClick={() => void handleCreateOrder()} disabled={submitting}>
{submitting ? "创建订单中..." : "立即充值"}
</button>
{order ? (
<div className="recharge-modal__order" role="status">
<strong>{order.orderId}</strong>
<span>{order.status}</span>
{order.qrCodeUrl ? <img src={order.qrCodeUrl} alt="支付二维码" /> : null}
{order.payUrl ? <a href={order.payUrl} target="_blank" rel="noreferrer"></a> : null}
<p>{order.message || "支付完成后积分将自动入账,如长时间未到账请联系客服。"}</p>
</div>
) : null}
</section>
</section>
</div>
);
+40 -30
View File
@@ -100,14 +100,14 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
setContextMenu({ x: e.clientX, y: e.clientY, asset });
}, []);
const handleDeleteAsset = useCallback(async () => {
if (!contextMenu) return;
const { asset } = contextMenu;
const handleDeleteAsset = useCallback(async (asset?: LibraryAssetItem) => {
const target = asset || contextMenu?.asset;
if (!target) return;
setContextMenu(null);
try {
await assetClient.delete(asset.id);
setServerAssets((prev) => prev.filter((a) => a.id !== asset.id));
setServerNotice(`已删除 ${asset.name}`);
await assetClient.delete(target.id);
setServerAssets((prev) => prev.filter((a) => a.id !== target.id));
setServerNotice(`已删除 ${target.name}`);
} catch (err) {
setServerNotice(err instanceof Error ? err.message : "删除失败");
}
@@ -287,32 +287,42 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
{visibleAssets.length ? (
<div className="asset-grid asset-grid--desktop motion-stagger">
{visibleAssets.map((asset) => (
<button
key={asset.id}
type="button"
className="asset-card asset-card--desktop"
onClick={() => setPreviewAsset(asset)}
onContextMenu={(e) => handleContextMenu(e, asset)}
aria-label={`预览素材 ${asset.name}`}
>
<div className={`asset-card__thumb ${asset.thumbClass}`}>
{asset.imageUrl ? <OptimizedImage src={asset.imageUrl} alt={asset.name} /> : null}
</div>
<div className="asset-card__body">
<div className="asset-card__head">
<strong>{asset.name}</strong>
<span className={`studio-status-bar__badge ${statusBadgeClass[asset.status]}`}>
{statusLabel[asset.status]}
</span>
<div key={asset.id} className="asset-card-wrapper">
<button
type="button"
className="asset-card asset-card--desktop"
onClick={() => setPreviewAsset(asset)}
onContextMenu={(e) => handleContextMenu(e, asset)}
aria-label={`预览素材 ${asset.name}`}
>
<div className={`asset-card__thumb ${asset.thumbClass}`}>
{asset.imageUrl ? <OptimizedImage src={asset.imageUrl} alt={asset.name} /> : null}
</div>
<p className="asset-card__desc">{asset.description}</p>
<div className="asset-card__tags">
{asset.tags.slice(0, 2).map((tag) => (
<span key={tag}>{tag}</span>
))}
<div className="asset-card__body">
<div className="asset-card__head">
<strong>{asset.name}</strong>
<span className={`studio-status-bar__badge ${statusBadgeClass[asset.status]}`}>
{statusLabel[asset.status]}
</span>
</div>
<p className="asset-card__desc">{asset.description}</p>
<div className="asset-card__tags">
{asset.tags.slice(0, 2).map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
</div>
</div>
</button>
</button>
<button
type="button"
className="asset-card__delete"
title="删除素材"
onClick={(e) => { e.stopPropagation(); void handleDeleteAsset(asset); }}
aria-label={`删除 ${asset.name}`}
>
<DeleteOutlined />
</button>
</div>
))}
</div>
) : isLoading ? (
+8 -6
View File
@@ -3717,6 +3717,9 @@ function CanvasPage({
<ReactFlow
nodes={[]}
edges={[]}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
minZoom={0.3}
maxZoom={1.6}
panOnDrag={false}
@@ -5531,6 +5534,11 @@ function CanvasPage({
role="menu"
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
onMouseMove={(event) => {
if (pendingLinkPort) {
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
}
}}
>
<div className="studio-canvas-add-node-menu__title"></div>
<button
@@ -5542,8 +5550,6 @@ function CanvasPage({
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
addTextNode(undefined, pos);
setPendingLinkPort(null);
setPendingLinkPreviewPoint(null);
setConnectionDropMenu(null);
}}
>
@@ -5559,8 +5565,6 @@ function CanvasPage({
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
addImageNode("", "图片节点", pos);
setPendingLinkPort(null);
setPendingLinkPreviewPoint(null);
setConnectionDropMenu(null);
}}
>
@@ -5576,8 +5580,6 @@ function CanvasPage({
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
addVideoNode(pos);
setPendingLinkPort(null);
setPendingLinkPreviewPoint(null);
setConnectionDropMenu(null);
}}
>
@@ -0,0 +1,98 @@
import { FileTextOutlined, SafetyOutlined } from "@ant-design/icons";
type ComplianceKind = "agreement" | "privacy";
interface CompliancePageProps {
kind: ComplianceKind;
}
const companyName = "OmniAI";
const contactPhone = "15155073618";
const address = "江苏省南京市江北新区扬子江数字视听产业园9栋A楼501";
const agreementSections = [
{
title: "服务范围",
body: "平台提供 AI 图片、视频、脚本、数字人及相关创作辅助服务。具体功能、模型能力、消耗规则以页面展示和平台公告为准。",
},
{
title: "账号与使用",
body: "用户应保证注册信息真实有效,妥善保管账号与登录凭证,不得出租、转让账号或以自动化方式恶意占用平台资源。",
},
{
title: "内容合规",
body: "用户不得上传、生成、发布违法违规、侵权、涉政敏感、暴恐、色情、赌博、诈骗或侵犯他人合法权益的内容。平台有权对违规内容采取删除、限制功能、封禁账号等措施。",
},
{
title: "积分与付费",
body: "积分仅限平台内消费,不支持提现、转让或折现。充值、套餐、赠送积分的有效期、消耗顺序和退费规则以充值页面展示为准。",
},
{
title: "责任限制",
body: "AI 生成结果可能存在偏差,用户应自行审核输出内容并承担使用后果。因不可抗力、第三方服务异常、网络故障造成的服务中断,平台将在合理范围内修复。",
},
];
const privacySections = [
{
title: "收集的信息",
body: "我们会收集账号信息、登录状态、联系方式、创作输入、生成结果、用量记录、设备与网络日志,用于提供服务、安全审计和问题排查。",
},
{
title: "Cookie 与本地存储",
body: "我们使用 Cookie、localStorage 和 sessionStorage 保存登录状态、偏好设置、Cookie 同意状态、创作草稿和断点续传数据。",
},
{
title: "信息使用",
body: "信息用于身份验证、生成任务处理、资产管理、积分计费、客服支持、风控合规、服务优化和法律法规要求的备案审计。",
},
{
title: "第三方处理",
body: "为完成 AI 生成、对象存储、短信邮件、支付或错误监控,我们可能向必要的第三方服务提供最小范围数据,并要求其按约定保护数据安全。",
},
{
title: "用户权利",
body: "你可以通过平台账号功能或联系方式申请访问、更正、删除个人信息,或撤回非必要授权。法律法规另有要求的记录可能需按规定保留。",
},
];
export default function CompliancePage({ kind }: CompliancePageProps) {
const isPrivacy = kind === "privacy";
const sections = isPrivacy ? privacySections : agreementSections;
const title = isPrivacy ? "隐私政策" : "用户协议";
const Icon = isPrivacy ? SafetyOutlined : FileTextOutlined;
return (
<section className="compliance-page">
<div className="compliance-page__inner">
<header className="compliance-hero">
<span className="compliance-hero__icon"><Icon /></span>
<div>
<span className="compliance-hero__eyebrow"></span>
<h1>{title}</h1>
<p>{companyName} 2026 6 3 </p>
</div>
</header>
<div className="compliance-card">
{sections.map((section, index) => (
<article key={section.title} className="compliance-section">
<span>{String(index + 1).padStart(2, "0")}</span>
<div>
<h2>{section.title}</h2>
<p>{section.body}</p>
</div>
</article>
))}
</div>
<footer className="compliance-contact">
<strong></strong>
<span>{address}</span>
<span>{contactPhone}</span>
<span>ICP备2026021747号-1</span>
</footer>
</div>
</section>
);
}
@@ -567,7 +567,17 @@ function DigitalHumanPage({
</button>
)}
{resultVideoUrl && (
<div className="studio-result-actions studio-result-actions--with-clear">
<button type="button" className="studio-generate-btn" onClick={() => {
setResultVideoUrl("");
setActiveTaskId("");
setTaskProgress(0);
setNotice("已清空工作区");
}}>
</button>
)}
{resultVideoUrl && (
<div className="studio-result-actions">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
@@ -576,14 +586,6 @@ function DigitalHumanPage({
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
<button type="button" onClick={() => {
setResultVideoUrl("");
setActiveTaskId("");
setTaskProgress(0);
setNotice("已清空工作区");
}}>
</button>
</div>
)}
</div>
File diff suppressed because it is too large Load Diff
+308 -149
View File
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CopyOutlined,
DownloadOutlined,
@@ -15,6 +15,7 @@ import {
PLAN_STEPS_DISPLAY,
type EcommerceVideoStage,
type EcommerceVideoSceneTask,
type EcommerceVideoPlanProgress,
type EcommerceVideoPlanResult,
type PlanStep,
} from "./ecommerceVideoTypes";
@@ -22,6 +23,7 @@ import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
import { ServerRequestError } from "../../api/serverConnection";
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
import { useAppStore } from "../../stores";
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import {
saveEcommerceVideoState,
loadEcommerceVideoState,
@@ -44,10 +46,51 @@ const ALL_STEPS: PlanStep[] = [
"creative", "storyboard", "prompts", "compliance",
];
function hashString(value: string): string {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
}
function buildInputFingerprint(input: {
productImageDataUrls: string[];
requirement: string;
platform: string;
aspectRatio: string;
durationSeconds: number;
resolution: string;
}): string {
const imageCount = input.productImageDataUrls.length;
return hashString([
String(imageCount),
input.requirement.trim(),
input.platform,
input.aspectRatio,
input.durationSeconds,
input.resolution,
].join("::"));
}
function mapResolutionToQuality(res: string): "720P" | "1080P" {
return res.includes("720") ? "720P" : "1080P";
}
function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress): boolean {
switch (step) {
case "upload": return Boolean(p.imageUrls?.length);
case "analyze": return p.imageDescription !== undefined;
case "summary": return Boolean(p.summary);
case "selling": return Boolean(p.selling);
case "creative": return Boolean(p.creatives?.length);
case "storyboard": return Boolean(p.storyboard);
case "prompts": return Boolean(p.videoPrompts);
case "compliance": return Boolean(p.compliance);
}
}
export default function EcommerceVideoWorkspace({
isAuthenticated,
productImageDataUrls,
@@ -60,38 +103,67 @@ export default function EcommerceVideoWorkspace({
}: EcommerceVideoWorkspaceProps) {
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
const [planProgress, setPlanProgress] = useState<EcommerceVideoPlanProgress | null>(null);
const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]);
const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]);
const [sourceImageUrls, setSourceImageUrls] = useState<string[]>([]);
const [currentStep, setCurrentStep] = useState<PlanStep | null>(null);
const [failedStep, setFailedStep] = useState<PlanStep | null>(null);
const [error, setError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false });
const setView = useAppStore((s) => s.setView);
const keepaliveRestoredRef = useRef(false);
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
const keepalivePollingStartedRef = useRef(false);
const generation = useGenerationTasks({ sourceView: "ecommerce" });
const sceneStoreIdMap = useRef<Map<number, string>>(new Map());
const inputFingerprint = useMemo(
() => buildInputFingerprint({ productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution }),
[productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution],
);
// ── Keep-alive: restore saved state on mount ─────────────
useEffect(() => {
if (keepaliveRestoredRef.current) return;
keepaliveRestoredRef.current = true;
const saved = loadEcommerceVideoState();
if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return;
keepaliveRestoredFingerprintRef.current = inputFingerprint;
const saved = loadEcommerceVideoState(inputFingerprint);
if (!saved) return;
if (saved.stage === "idle" || saved.stage === "cancelled") return;
// Restore completed / in-progress states — results persist across page switches
setStage(saved.stage);
setCompletedSteps(saved.completedSteps || []);
setPlanResult(saved.planResult);
setPlanProgress((saved as { planProgress?: EcommerceVideoPlanProgress | null }).planProgress || null);
setScenes(saved.scenes || []);
setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []);
}, []);
}, [inputFingerprint]);
// ── Keep-alive: save state on changes ───────────────────
useEffect(() => {
if (stage === "idle" || stage === "cancelled") return;
saveEcommerceVideoState({ stage, completedSteps, planResult, scenes, sourceImageUrls });
}, [stage, completedSteps, planResult, scenes, sourceImageUrls]);
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
// ── Auto-advance: skip manual "next step" clicks ─────────
const autoAdvanceTriggeredRef = useRef(false);
useEffect(() => {
if (autoAdvanceTriggeredRef.current) return;
const delay = 600;
if (stage === "planned" && planResult && scenes.length > 0) {
autoAdvanceTriggeredRef.current = true;
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
return () => clearTimeout(timer);
}
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
autoAdvanceTriggeredRef.current = true;
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
return () => clearTimeout(timer);
}
if (stage === "idle" || stage === "cancelled") {
autoAdvanceTriggeredRef.current = false;
}
}, [stage, scenes, planResult]);
// ── Keep-alive: resume polling for running tasks ──────────
useEffect(() => {
@@ -253,40 +325,89 @@ export default function EcommerceVideoWorkspace({
// ── Phase 1: Planning ──────────────────────────────────────
const handlePlan = async () => {
if (!isAuthenticated) { onRequestLogin?.(); return; }
if (!productImageDataUrls.length && !requirement.trim()) {
setError("请先上传产品图片或填写商品说明"); return;
}
const runPlanFlow = async (resume: EcommerceVideoPlanProgress | null) => {
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setStage("planning"); setError(null);
setCompletedSteps([]); setCurrentStep(null);
setPlanResult(null); setScenes([]); setSourceImageUrls([]);
setStage("planning"); setError(null); setFailedStep(null);
if (!resume) {
setCompletedSteps([]); setPlanResult(null); setScenes([]); setSourceImageUrls([]); setPlanProgress(null);
}
setCurrentStep(null);
// Mutable snapshot — async handlers must persist to localStorage directly since the component may unmount
let livePlanProgress: EcommerceVideoPlanProgress = resume ? { ...resume } : {};
let liveCompletedSteps: PlanStep[] = resume
? ALL_STEPS.filter((s) => stepCompletedFromProgress(s, resume))
: [];
const persist = (stageNow: EcommerceVideoStage) => {
saveEcommerceVideoState({
inputFingerprint,
stage: stageNow,
completedSteps: liveCompletedSteps,
planResult: null,
planProgress: livePlanProgress,
scenes: [],
sourceImageUrls: livePlanProgress.imageUrls || [],
});
};
try {
const result = await runVideoPlan(
productImageDataUrls, requirement, buildConfig(),
{
onStepStart: (step) => setCurrentStep(step),
onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]),
onImagesUploaded: (urls) => { setSourceImageUrls(urls); saveEcommerceVideoState({ stage: "planning", completedSteps: ["upload"], planResult: null, scenes: [], sourceImageUrls: urls }); },
onStepDone: (step) => {
liveCompletedSteps = [...liveCompletedSteps, step];
setCompletedSteps((prev) => [...prev, step]);
},
onImagesUploaded: (urls) => {
setSourceImageUrls(urls);
livePlanProgress = { ...livePlanProgress, imageUrls: urls };
persist("planning");
},
onUploadRejected: (messages) => {
if (messages.length) showNotice(`已跳过 ${messages.length} 张上传失败的图片`);
},
onPartialProgress: (progress) => {
livePlanProgress = progress;
setPlanProgress(progress);
persist("planning");
},
resumeFrom: resume || undefined,
signal: controller.signal,
},
);
const builtScenes = buildSceneTasks(result);
setPlanResult(result);
setPlanProgress(null);
setScenes(builtScenes);
setStage("planned");
// Persist immediately — component may be unmounted by the time React re-renders
saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, scenes: builtScenes, sourceImageUrls: result.imageUrls });
saveEcommerceVideoState({ inputFingerprint, stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, scenes: builtScenes, sourceImageUrls: result.imageUrls });
} catch (err) {
if ((err as Error).name === "AbortError") return;
setError(err instanceof Error ? err.message : "策划失败");
if ((err as Error).name === "AbortError" && controller.signal.aborted) return;
const message = err instanceof Error ? err.message : "策划失败";
setError(message);
// Mark the step that was in-progress as failed so user can resume
setFailedStep((prev) => prev || currentStep);
setStage("idle");
// Persist partial progress so the user can resume after a page switch
persist("idle");
} finally { setCurrentStep(null); }
};
const handlePlan = async () => {
if (!isAuthenticated) { onRequestLogin?.(); return; }
if (!productImageDataUrls.length && !requirement.trim()) {
setError("请先上传产品图片或填写商品说明"); return;
}
await runPlanFlow(null);
};
const handleResumePlan = async () => {
if (!isAuthenticated) { onRequestLogin?.(); return; }
if (!planProgress) { void handlePlan(); return; }
await runPlanFlow(planProgress);
};
// ── Phase 2: Image generation per scene ──────────────────────
const handleGenerateImages = async () => {
@@ -300,19 +421,34 @@ export default function EcommerceVideoWorkspace({
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
currentScenes = next;
setScenes(next);
saveEcommerceVideoState({ stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
saveEcommerceVideoState({ inputFingerprint, stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
};
for (const scene of currentScenes) {
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
if (!scenesToProcess.length) { setStage("imaged"); return; }
for (const scene of scenesToProcess) {
if (renderAbortRef.current.current) break;
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
try {
await renderSceneImage(
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
{
onSceneImageSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)),
onSceneImageSubmitted: (id, taskId) => {
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s));
const storeId = generation.submitTask({ title: `分镜${id}图片`, type: "image", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "imaging" } });
sceneStoreIdMap.current.set(id, storeId);
},
onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneImageCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)),
onSceneImageFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)),
onSceneImageCompleted: (id, url) => {
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s));
const sid = sceneStoreIdMap.current.get(id);
if (sid) generation.markCompleted(sid, url);
},
onSceneImageFailed: (id, err2) => {
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s));
const sid = sceneStoreIdMap.current.get(id);
if (sid) generation.markFailed(sid, err2);
},
},
renderAbortRef.current,
);
@@ -324,15 +460,14 @@ export default function EcommerceVideoWorkspace({
const allHaveImages = currentScenes.every((s) => s.imageUrl);
const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const;
setStage(finalStage);
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
};
// ── Phase 3: Video rendering from generated images ──────────
const handleRenderVideos = async () => {
if (!scenes.length) return;
const firstImage = scenes[0]?.imageUrl;
if (!firstImage) { setError("请先生成分镜图片"); return; }
if (!scenes.some((s) => s.imageUrl)) { setError("请先生成分镜图片"); return; }
setStage("rendering"); setError(null);
renderAbortRef.current = { current: false };
const quality = mapResolutionToQuality(resolution);
@@ -340,20 +475,35 @@ export default function EcommerceVideoWorkspace({
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
currentScenes = next;
setScenes(next);
saveEcommerceVideoState({ stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
saveEcommerceVideoState({ inputFingerprint, stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
};
for (const scene of currentScenes) {
// Only render scenes that haven't completed yet — preserves successful videos on partial retry
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
if (!scenesToProcess.length) { setStage(currentScenes.every((s) => s.status === "completed") ? "completed" : "partial_failed"); return; }
for (const scene of scenesToProcess) {
if (renderAbortRef.current.current) break;
if (!scene.imageUrl) continue;
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
try {
await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality },
{
onSceneSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
onSceneSubmitted: (id, taskId) => {
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s));
const storeId = generation.submitTask({ title: `分镜${id}视频`, type: "video", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "rendering" } });
sceneStoreIdMap.current.set(id, storeId);
},
onSceneProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
onSceneFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
onSceneCompleted: (id, url) => {
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s));
const sid = sceneStoreIdMap.current.get(id);
if (sid) generation.markCompleted(sid, url);
},
onSceneFailed: (id, err2) => {
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s));
const sid = sceneStoreIdMap.current.get(id);
if (sid) generation.markFailed(sid, err2);
},
},
renderAbortRef.current,
);
@@ -369,7 +519,7 @@ export default function EcommerceVideoWorkspace({
const finalStage = allDone ? (hasFailed ? "partial_failed" as const : "completed" as const) : "rendering" as const;
setScenes(currentScenes);
setStage(finalStage);
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
};
const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); };
@@ -424,26 +574,32 @@ export default function EcommerceVideoWorkspace({
<div className="ecom-video-flowbar__actions">
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
{stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleResumePlan()} title={`从「${failedStep ? PLAN_STEP_LABELS[failedStep] : "已中断处"}」继续策划`}>
<ReloadOutlined />
</button>
) : null}
{stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
<button type="button" className="ecom-video-flow-action"
onClick={() => void handlePlan()} title="一键策划">
onClick={() => void handlePlan()} title={planProgress ? "从头重新策划" : "一键策划"}>
<PlayCircleOutlined />
</button>
) : null}
{stage === "planned" ? (
{stage === "planned" || stage === "imaged" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleGenerateImages()} title="生成图片">
<SendOutlined />
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
{stage === "imaged" ? <ReloadOutlined /> : <SendOutlined />}
</button>
) : null}
{stage === "imaged" ? (
{stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleRenderVideos()} title="生成视频">
onClick={() => void handleRenderVideos()} title={stage === "partial_failed" ? "重新生成失败的视频" : "生成视频"}>
<SendOutlined />
</button>
) : null}
{stage === "planning" ? (
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span>
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> {currentStep ? PLAN_STEP_LABELS[currentStep] : "策划中"}</span>
) : null}
{stage === "imaging" ? (
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span>
@@ -463,123 +619,126 @@ export default function EcommerceVideoWorkspace({
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
{!sourceImage ? (
<div className="ecom-video-empty">
<span>"一键策划"</span>
<span></span>
</div>
) : (
<div className="ecom-video-flow-map">
{/* Source image node */}
<article className="ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label="商品图节点">
<div className="ecom-video-flow-node__media">
<img src={sourceImage} alt="商品图" />
<div className="ecom-video-tree">
{/* Source Node — 附件原图 */}
<div className="ecom-video-tree__source">
<article className="ecom-video-tree-node ecom-video-tree-node--source">
<img src={sourceImage} alt="商品图" />
</article>
<span className="ecom-video-tree-node__label"></span>
</div>
{/* Branch Connector — 分支连接线 */}
<div className="ecom-video-tree__trunk" aria-hidden="true">
<div className="ecom-video-tree__trunk-line" />
<div className="ecom-video-tree__branches-line">
{scenes.length > 0 ? scenes.map((s) => (
<div key={`trunk-${s.sceneId}`} className="ecom-video-tree__branch-tap" />
)) : (
<>
<div className="ecom-video-tree__branch-tap" />
<div className="ecom-video-tree__branch-tap" />
<div className="ecom-video-tree__branch-tap" />
</>
)}
</div>
<span className="ecom-video-flow-node__label"></span>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
</div>
{/* Connector: source → plan text nodes */}
{visiblePlanSteps.length > 0 ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null}
{/* Branches — 每个场景一条分支 */}
<div className="ecom-video-tree__rows">
{scenes.length > 0 ? scenes.map((scene, idx) => {
const planDone = completedSteps.length >= ALL_STEPS.length;
const imgReady = !!scene.imageUrl;
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
const vidReady = scene.status === "completed" && scene.resultUrl;
const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending");
const vidFailed = scene.status === "failed";
{/* Plan text nodes — side by side */}
{visiblePlanSteps.length > 0 ? (
<div className="ecom-video-scene-strip ecom-video-scene-strip--text" aria-label="策划节点">
{visiblePlanSteps.map((step, idx) => (
<Fragment key={step}>
<article className={`ecom-video-flow-node ecom-video-flow-node--text is-completed${currentStep === step ? " is-pulsing" : ""}`}
aria-label={PLAN_STEP_LABELS[step]} title={PLAN_STEP_LABELS[step]}>
<span className="ecom-video-flow-node__text-icon">
{currentStep === step ? <LoadingOutlined /> : "✓"}
</span>
<span className="ecom-video-flow-node__label">{PLAN_STEP_LABELS[step]}</span>
return (
<div key={scene.sceneId} className="ecom-video-tree__row" style={{ animationDelay: `${idx * 120}ms` }}>
<article className={`ecom-video-tree-node ecom-video-tree-node--text${planDone ? " is-completed" : currentStep ? " is-active" : ""}`}>
<div className="ecom-video-tree-node__inner">
<span className="ecom-video-tree-node__title">{scene.sceneId}</span>
<span className="ecom-video-tree-node__desc">
{planDone ? "已完成" : stage === "planning" ? "策划中..." : "等待策划"}
</span>
</div>
</article>
{idx < visiblePlanSteps.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
))}
</div>
) : null}
{/* Connector: plan → images */}
{hasImaging ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null}
<div className="ecom-video-tree__arrow" aria-hidden="true">
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
{/* Storyboard image nodes — side by side per scene */}
{hasImaging ? (
<div className="ecom-video-scene-strip" aria-label="分镜图片节点">
{scenes.map((scene, idx) => {
const imgReady = !!scene.imageUrl;
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
const cls = imgReady ? "is-completed" : imgRunning ? "is-active" : "";
return (
<Fragment key={`img-${scene.sceneId}`}>
<article className={`ecom-video-flow-node ecom-video-flow-node--image ${cls}`}
aria-label={`分镜 ${scene.sceneId}`} title={`分镜 ${scene.sceneId}`}>
<div className="ecom-video-flow-node__media">
{imgReady ? <img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
: imgRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
: <div className="ecom-video-flow-node__placeholder"></div>}
<article className={`ecom-video-tree-node ecom-video-tree-node--image${imgReady ? " is-completed" : imgRunning ? " is-active" : ""}`}>
{imgReady ? (
<img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
) : (
<div className="ecom-video-tree-node__placeholder">
{imgRunning ? <LoadingOutlined /> : <span></span>}
</div>
{imgRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
<span className="ecom-video-flow-node__label">{scene.sceneId}</span>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{idx < scenes.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
);
})}
</div>
) : null}
)}
{imgRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
<span className="ecom-video-tree-node__tag">{scene.sceneId}</span>
</article>
{/* Connector: images → videos */}
{hasRendering ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null}
<div className="ecom-video-tree__arrow" aria-hidden="true">
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
{/* Video nodes — side by side per scene */}
{hasRendering ? (
<div className="ecom-video-scene-strip" aria-label="视频分镜节点">
{scenes.map((scene, idx) => {
const vidReady = scene.status === "completed" && scene.resultUrl;
const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending");
const vidFailed = scene.status === "failed";
const cls = vidReady ? "is-completed" : vidRunning ? "is-active" : vidFailed ? "is-failed" : "";
return (
<Fragment key={`vid-${scene.sceneId}`}>
<article className={`ecom-video-flow-node ecom-video-flow-node--video ${cls}`}
aria-label={`镜头 ${scene.sceneId}`} title={`镜头 ${scene.sceneId}`}>
<div className="ecom-video-flow-node__media">
{vidReady ? <video src={scene.resultUrl!} muted playsInline loop autoPlay />
: vidRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
: vidFailed ? <div className="ecom-video-flow-node__placeholder"></div>
: <div className="ecom-video-flow-node__placeholder"></div>}
<article className={`ecom-video-tree-node ecom-video-tree-node--video${vidReady ? " is-completed" : vidRunning ? " is-active" : vidFailed ? " is-failed" : ""}`}>
{vidReady ? (
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
) : (
<div className="ecom-video-tree-node__placeholder">
{vidRunning ? <LoadingOutlined /> : vidFailed ? <span></span> : <span></span>}
</div>
{vidRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
<span className="ecom-video-flow-node__label">{scene.sceneId}</span>
{vidFailed ? (
<button type="button" className="ecom-video-flow-node__retry"
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
title="重试此镜头">
<ReloadOutlined />
</button>
) : null}
{vidFailed && scene.error ? (
<span className="ecom-video-flow-node__error" title={scene.error}>{scene.error.slice(0, 20)}</span>
) : null}
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{idx < scenes.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
)}
{vidRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
<span className="ecom-video-tree-node__tag">{scene.sceneId}</span>
{vidFailed ? (
<button type="button" className="ecom-video-tree-node__retry"
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
title="重试此镜头">
<ReloadOutlined />
</button>
) : null}
</Fragment>
);
})}
</div>
) : null}
</article>
</div>
);
}) : (
[1, 2, 3].map((n) => (
<div key={n} className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`} style={{ animationDelay: `${n * 120}ms` }}>
<article className="ecom-video-tree-node ecom-video-tree-node--text">
<div className="ecom-video-tree-node__inner">
<span className="ecom-video-tree-node__title">{n}</span>
<span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "等待策划"}</span>
</div>
</article>
<div className="ecom-video-tree__arrow" aria-hidden="true">
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
<article className="ecom-video-tree-node ecom-video-tree-node--image">
<div className="ecom-video-tree-node__placeholder">
{stage === "planning" ? <LoadingOutlined /> : <span></span>}
</div>
<span className="ecom-video-tree-node__tag">{n}</span>
</article>
<div className="ecom-video-tree__arrow" aria-hidden="true">
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
<article className="ecom-video-tree-node ecom-video-tree-node--video">
<div className="ecom-video-tree-node__placeholder">
{stage === "planning" ? <LoadingOutlined /> : <span></span>}
</div>
<span className="ecom-video-tree-node__tag">{n}</span>
</article>
</div>
))
)}
</div>
</div>
)}
@@ -0,0 +1,37 @@
export const ECOMMERCE_SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
export const ECOMMERCE_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
export interface EcommerceImageValidationResult {
accepted: File[];
rejected: Array<{ name: string; reason: string }>;
}
export function validateEcommerceImageFiles(files: File[]): EcommerceImageValidationResult {
const accepted: File[] = [];
const rejected: EcommerceImageValidationResult["rejected"] = [];
files.forEach((file) => {
if (!ECOMMERCE_SUPPORTED_IMAGE_TYPES.has(file.type)) {
rejected.push({ name: file.name, reason: "不支持的图片格式" });
return;
}
if (file.size > ECOMMERCE_MAX_IMAGE_BYTES) {
rejected.push({ name: file.name, reason: "图片超过 10MB" });
return;
}
accepted.push(file);
});
return { accepted, rejected };
}
export function summarizeRejectedImages(rejected: EcommerceImageValidationResult["rejected"]): string {
if (!rejected.length) return "";
const first = rejected[0];
const suffix = rejected.length > 1 ? `${rejected.length} 个文件` : "";
return `${first.name}${suffix} 已跳过:${first.reason}`;
}
export function normalizeEcommerceImageMime(type: string): string {
return ECOMMERCE_SUPPORTED_IMAGE_TYPES.has(type) ? type : "image/png";
}
@@ -1,6 +1,7 @@
import type {
EcommerceVideoStage,
EcommerceVideoSceneTask,
EcommerceVideoPlanProgress,
EcommerceVideoPlanResult,
PlanStep,
} from "./ecommerceVideoTypes";
@@ -8,18 +9,22 @@ import type {
const KEEPALIVE_KEY = "omniai:ecommerce-video-workspace";
interface EcommerceVideoKeepalive {
inputFingerprint: string;
stage: EcommerceVideoStage;
completedSteps: PlanStep[];
planResult: EcommerceVideoPlanResult | null;
planProgress?: EcommerceVideoPlanProgress | null;
scenes: EcommerceVideoSceneTask[];
sourceImageUrls: string[];
savedAt: number;
}
export function saveEcommerceVideoState(state: {
inputFingerprint: string;
stage: EcommerceVideoStage;
completedSteps: PlanStep[];
planResult: EcommerceVideoPlanResult | null;
planProgress?: EcommerceVideoPlanProgress | null;
scenes: EcommerceVideoSceneTask[];
sourceImageUrls?: string[];
}): void {
@@ -35,7 +40,7 @@ export function saveEcommerceVideoState(state: {
}
}
export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null {
export function loadEcommerceVideoState(inputFingerprint: string): EcommerceVideoKeepalive | null {
try {
const raw = window.localStorage.getItem(KEEPALIVE_KEY);
if (!raw) return null;
@@ -45,6 +50,7 @@ export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null {
clearEcommerceVideoState();
return null;
}
if (parsed.inputFingerprint !== inputFingerprint) return null;
return parsed;
} catch {
return null;
+105 -40
View File
@@ -11,7 +11,9 @@ import {
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
import type {
EcommerceVideoPlanProgress,
EcommerceVideoPlanResult,
EcommerceVideoSceneTask,
PlanStep,
@@ -21,66 +23,129 @@ export interface PlanCallbacks {
onStepStart: (step: PlanStep) => void;
onStepDone: (step: PlanStep) => void;
onImagesUploaded?: (urls: string[]) => void;
onUploadRejected?: (messages: string[]) => void;
onPartialProgress?: (progress: EcommerceVideoPlanProgress) => void;
signal?: AbortSignal;
/** Partial state from a previous run; steps with existing data are skipped. */
resumeFrom?: EcommerceVideoPlanProgress;
}
/**
* Run the full ad video planning pipeline.
* Supports resumption: if `resumeFrom` contains data for a step, that step is skipped.
* After each step, `onPartialProgress` fires so callers can persist intermediate state.
*/
export async function runVideoPlan(
imageDataUrls: string[],
manualText: string,
config: AdVideoUserConfig,
callbacks: PlanCallbacks,
): Promise<EcommerceVideoPlanResult> {
const { onStepStart, onStepDone, signal } = callbacks;
const { onStepStart, onStepDone, signal, resumeFrom = {} } = callbacks;
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
const emit = () => callbacks.onPartialProgress?.({ ...progress });
onStepStart("upload");
const imageUrls: string[] = [];
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
for (const srcUrl of imageDataUrls) {
try {
const resp = await fetch(srcUrl);
const rawBlob = await resp.blob();
const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png";
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const result = await aiGenerationClient.uploadAssetBinary(blob, { mimeType, scope: "ecommerce-product" });
imageUrls.push(result.url);
} catch {
// skip images that fail to upload
// ── Step: upload ──────────────────────────────────────
if (!progress.imageUrls?.length) {
onStepStart("upload");
const imageUrls: string[] = [];
const rejected: string[] = [];
for (const srcUrl of imageDataUrls) {
try {
const resp = await fetch(srcUrl);
const rawBlob = await resp.blob();
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsDataURL(blob);
});
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
imageUrls.push(result.url);
} catch (err) {
rejected.push(err instanceof Error ? err.message : "图片上传失败");
}
}
if (rejected.length) {
progress.uploadWarnings = rejected;
callbacks.onUploadRejected?.(rejected);
}
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
progress.imageUrls = imageUrls;
onStepDone("upload");
callbacks.onImagesUploaded?.(imageUrls);
emit();
}
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
onStepDone("upload");
callbacks.onImagesUploaded?.(imageUrls);
onStepStart("analyze");
const imageDesc = await analyzeProductImages(imageUrls, signal);
onStepDone("analyze");
// ── Step: analyze ─────────────────────────────────────
if (progress.imageDescription === undefined) {
onStepStart("analyze");
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
onStepDone("analyze");
emit();
}
onStepStart("summary");
const summary = await buildProductSummary(imageDesc, manualText, signal);
onStepDone("summary");
// ── Step: summary ─────────────────────────────────────
if (!progress.summary) {
onStepStart("summary");
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
onStepDone("summary");
emit();
}
onStepStart("selling");
const selling = await extractSellingPoints(summary, signal);
onStepDone("selling");
// ── Step: selling ─────────────────────────────────────
if (!progress.selling) {
onStepStart("selling");
progress.selling = await extractSellingPoints(progress.summary, signal);
onStepDone("selling");
emit();
}
onStepStart("creative");
const creatives = await generateCreativeOptions(selling, config, signal);
if (!creatives.length) throw new Error("未能生成有效的广告创意");
onStepDone("creative");
// ── Step: creative ────────────────────────────────────
if (!progress.creatives?.length) {
onStepStart("creative");
progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
onStepDone("creative");
emit();
}
onStepStart("storyboard");
const storyboard = await generateStoryboard(creatives[0], summary, config, signal);
onStepDone("storyboard");
// ── Step: storyboard ──────────────────────────────────
if (!progress.storyboard) {
onStepStart("storyboard");
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
onStepDone("storyboard");
emit();
}
onStepStart("prompts");
const videoPrompts = await generateVideoPrompts(storyboard, summary, signal);
onStepDone("prompts");
// ── Step: prompts ─────────────────────────────────────
if (!progress.videoPrompts) {
onStepStart("prompts");
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
onStepDone("prompts");
emit();
}
onStepStart("compliance");
const compliance = await checkCompliance(summary, selling, storyboard, signal);
onStepDone("compliance");
// ── Step: compliance ──────────────────────────────────
if (!progress.compliance) {
onStepStart("compliance");
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
onStepDone("compliance");
emit();
}
return { imageUrls, summary, selling, creatives, storyboard, videoPrompts, compliance };
return {
imageUrls: progress.imageUrls!,
imageDescription: progress.imageDescription,
summary: progress.summary!,
selling: progress.selling!,
creatives: progress.creatives!,
storyboard: progress.storyboard!,
videoPrompts: progress.videoPrompts!,
compliance: progress.compliance!,
};
}
export interface RenderSceneImageInput {
@@ -36,6 +36,7 @@ export interface EcommerceVideoSceneTask {
export interface EcommerceVideoPlanResult {
imageUrls: string[];
imageDescription?: string;
summary: ProductSummary;
selling: SellingPointResult;
creatives: CreativeOption[];
@@ -44,6 +45,19 @@ export interface EcommerceVideoPlanResult {
compliance: ComplianceCheck;
}
/** Partial plan state — used as resume input when an earlier run failed mid-flow. */
export interface EcommerceVideoPlanProgress {
imageUrls?: string[];
imageDescription?: string;
uploadWarnings?: string[];
summary?: ProductSummary;
selling?: SellingPointResult;
creatives?: CreativeOption[];
storyboard?: Storyboard;
videoPrompts?: VideoPrompt[];
compliance?: ComplianceCheck;
}
export interface EcommerceVideoDelivery {
planResult: EcommerceVideoPlanResult | null;
scenes: EcommerceVideoSceneTask[];
@@ -0,0 +1,740 @@
import {
CloudUploadOutlined,
CloseOutlined,
FileImageOutlined,
LoadingOutlined,
QuestionCircleOutlined,
ReloadOutlined,
SettingOutlined,
} from "@ant-design/icons";
import type { ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react";
import { useRef, useState } from "react";
type CloneOutputKey = string;
type CloneSetCountKey = string;
type CloneModelPanelTab = "scene" | "model";
type CloneReferenceMode = "upload" | "link";
type CloneReplicateLevelKey = string;
type CloneVideoQualityKey = string;
interface CloneImageItem {
id: string;
src: string;
name: string;
}
interface CloneBasicSelectItem {
key: string;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}
interface CloneModelSelectItem {
key: string;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}
interface CloneSetCountOption {
key: CloneSetCountKey;
title: string;
desc: string;
}
interface CloneOutputOption {
key: CloneOutputKey;
label: string;
}
interface CloneReplicateLevelOption {
key: CloneReplicateLevelKey;
title: string;
desc: string;
}
interface CloneVideoQualityOption {
key: CloneVideoQualityKey;
label: string;
desc: string;
}
interface CloneDetailModule {
id: string;
title: string;
desc: string;
}
interface EcommerceClonePanelProps {
productInputRef: RefObject<HTMLInputElement>;
cloneReferenceInputRef: RefObject<HTMLInputElement>;
productImages: CloneImageItem[];
isProductUploadDragging: boolean;
cloneOutput: CloneOutputKey;
cloneOutputOptions: CloneOutputOption[];
cloneBasicSelects: CloneBasicSelectItem[];
openCloneBasicSelect: string | null;
cloneReferenceMode: CloneReferenceMode;
cloneReferenceImages: CloneImageItem[];
maxCloneReferenceImages: number;
cloneReplicateLevel: CloneReplicateLevelKey;
cloneReplicateLevelOptions: CloneReplicateLevelOption[];
cloneSetCounts: Record<CloneSetCountKey, number>;
cloneSetCountOptions: CloneSetCountOption[];
cloneSetTotal: number;
minCloneSetTotal: number;
maxCloneSetTotal: number;
selectedCloneDetailModules: string[];
cloneDetailModules: CloneDetailModule[];
cloneModelPanelTab: CloneModelPanelTab;
tryOnScenes: string[];
selectedCloneModelScenes: string[];
cloneModelCustomScene: string;
cloneModelSelects: CloneModelSelectItem[];
openCloneModelSelect: string | null;
cloneModelSelectDropUp: boolean;
cloneModelAppearance: string;
cloneVideoQuality: CloneVideoQualityKey;
cloneVideoQualityOptions: CloneVideoQualityOption[];
cloneVideoDuration: number;
cloneVideoDurationMin: number;
cloneVideoDurationMax: number;
cloneVideoDurationStyle: { [key: string]: number | string };
cloneVideoSmart: boolean;
canGenerate: boolean;
status: string;
lastFailedActionRef: MutableRefObject<(() => void) | null>;
setIsProductUploadDragging: (value: boolean) => void;
handleProductDrop: (event: DragEvent<HTMLElement>) => void;
removeProductImage: (id: string) => void;
handleProductUpload: (event: ChangeEvent<HTMLInputElement>) => void;
handleCloneOutputChange: (value: CloneOutputKey) => void;
setOpenCloneBasicSelect: (value: string | null) => void;
setCloneReferenceMode: (value: CloneReferenceMode) => void;
handleCloneReferenceUpload: (event: ChangeEvent<HTMLInputElement>) => void;
setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void;
startCloneSetCountHold: (key: CloneSetCountKey, delta: number, disabled: boolean) => void;
clearCloneSetCountHold: () => void;
toggleCloneDetailModule: (id: string) => void;
setCloneModelPanelTab: (value: CloneModelPanelTab) => void;
toggleCloneModelScene: (scene: string) => void;
setCloneModelCustomScene: (value: string) => void;
setOpenCloneModelSelect: (value: string | null) => void;
setCloneModelSelectDropUp: (value: boolean) => void;
setCloneModelAppearance: (value: string) => void;
setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
setCloneVideoDuration: (value: number) => void;
clampCloneVideoDuration: (value: number) => number;
setCloneVideoSmart: (updater: (current: boolean) => boolean) => void;
handleGenerate: () => void;
formatRatioDisplayValue: (value: string) => string;
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
}
export default function EcommerceClonePanel({
productInputRef,
cloneReferenceInputRef,
productImages,
isProductUploadDragging,
cloneOutput,
cloneOutputOptions,
cloneBasicSelects,
openCloneBasicSelect,
cloneReferenceMode,
cloneReferenceImages,
maxCloneReferenceImages,
cloneReplicateLevel,
cloneReplicateLevelOptions,
cloneSetCounts,
cloneSetCountOptions,
cloneSetTotal,
minCloneSetTotal,
maxCloneSetTotal,
selectedCloneDetailModules,
cloneDetailModules,
cloneModelPanelTab,
tryOnScenes,
selectedCloneModelScenes,
cloneModelCustomScene,
cloneModelSelects,
openCloneModelSelect,
cloneModelSelectDropUp,
cloneModelAppearance,
cloneVideoQuality,
cloneVideoQualityOptions,
cloneVideoDuration,
cloneVideoDurationMin,
cloneVideoDurationMax,
cloneVideoDurationStyle,
cloneVideoSmart,
canGenerate,
status,
lastFailedActionRef,
setIsProductUploadDragging,
handleProductDrop,
removeProductImage,
handleProductUpload,
handleCloneOutputChange,
setOpenCloneBasicSelect,
setCloneReferenceMode,
handleCloneReferenceUpload,
setCloneReplicateLevel,
startCloneSetCountHold,
clearCloneSetCountHold,
toggleCloneDetailModule,
setCloneModelPanelTab,
toggleCloneModelScene,
setCloneModelCustomScene,
setOpenCloneModelSelect,
setCloneModelSelectDropUp,
setCloneModelAppearance,
setCloneVideoQuality,
setCloneVideoDuration,
clampCloneVideoDuration,
setCloneVideoSmart,
handleGenerate,
formatRatioDisplayValue,
setVideoOutfitFiles,
}: EcommerceClonePanelProps) {
const videoOutfitVideoRef = useRef<HTMLInputElement>(null);
const videoOutfitRefRef = useRef<HTMLInputElement>(null);
const [videoOutfitVideoUrl, setVideoOutfitVideoUrl] = useState<string | null>(null);
const [videoOutfitRefUrl, setVideoOutfitRefUrl] = useState<string | null>(null);
const handleVideoOutfitVideoChange = () => {
const file = videoOutfitVideoRef.current?.files?.[0] || null;
if (file) setVideoOutfitVideoUrl(URL.createObjectURL(file));
setVideoOutfitFiles?.(file, videoOutfitRefRef.current?.files?.[0] || null);
};
const handleVideoOutfitRefChange = () => {
const file = videoOutfitRefRef.current?.files?.[0] || null;
if (file) setVideoOutfitRefUrl(URL.createObjectURL(file));
setVideoOutfitFiles?.(videoOutfitVideoRef.current?.files?.[0] || null, file);
};
return (
<>
<div className="product-clone-panel__scroll clone-ai-panel">
<header className="clone-ai-logo">
<span className="clone-ai-logo__mark">AI</span>
<strong></strong>
</header>
<section className="clone-ai-card">
<h2>
<CloudUploadOutlined />
</h2>
<div
role="button"
tabIndex={0}
className={`clone-ai-upload-zone${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => productInputRef.current?.click()}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
productInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsProductUploadDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsProductUploadDragging(false)}
onDrop={handleProductDrop}
>
<div className="clone-ai-upload-main">
<span className="clone-ai-upload-icon">
<FileImageOutlined />
</span>
<span className="clone-ai-upload-title"></span>
<strong>
<span aria-hidden="true">+</span>
</strong>
<span className="clone-ai-upload-hint"> 7 </span>
</div>
{productImages.length ? (
<div className="clone-ai-uploaded-files" aria-label="已上传商品原图">
{productImages.map((item) => (
<figure key={item.id} className="clone-ai-uploaded-file">
<img src={item.src} alt={item.name} />
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
removeProductImage(item.id);
}}
aria-label={`删除${item.name}`}
>
<CloseOutlined />
</button>
</figure>
))}
</div>
) : null}
</div>
<input ref={productInputRef} type="file" accept="image/*" multiple onChange={handleProductUpload} />
</section>
<section className="clone-ai-card">
<h2>
<SettingOutlined />
</h2>
<div className="clone-ai-settings-section">
<span className="clone-ai-settings-label"></span>
<div className="clone-ai-tag-group" role="radiogroup" aria-label="生成内容">
{cloneOutputOptions.map((option) => (
<button
key={option.key}
type="button"
className={cloneOutput === option.key ? "is-active" : ""}
aria-pressed={cloneOutput === option.key}
onClick={() => handleCloneOutputChange(option.key)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="clone-ai-settings-section">
<span className="clone-ai-settings-label"></span>
<div className="clone-ai-select-group">
{cloneBasicSelects.map((item) => {
const hasMultipleOptions = item.options.length > 1;
const isOpen = hasMultipleOptions && openCloneBasicSelect === item.key;
return (
<div key={item.key} className="clone-ai-basic-select" data-clone-basic-select>
<button
type="button"
className={`${isOpen ? "is-open" : ""}${hasMultipleOptions ? "" : " is-static"}`}
aria-expanded={hasMultipleOptions ? isOpen : undefined}
aria-haspopup={hasMultipleOptions ? "listbox" : undefined}
aria-controls={hasMultipleOptions ? `clone-basic-select-${item.key}` : undefined}
onClick={() => setOpenCloneBasicSelect(hasMultipleOptions ? (isOpen ? null : item.key) : null)}
>
<span>{item.label}</span>
<strong>{item.key === "ratio" ? formatRatioDisplayValue(item.value) : item.value}</strong>
{hasMultipleOptions ? <i aria-hidden="true" /> : null}
</button>
{hasMultipleOptions && isOpen ? (
<div id={`clone-basic-select-${item.key}`} className="clone-ai-basic-select__menu" role="listbox">
{item.options.map((option) => (
<button
key={option}
type="button"
className={item.value === option ? "is-active" : ""}
role="option"
aria-selected={item.value === option}
onClick={() => {
item.onChange(option);
setOpenCloneBasicSelect(null);
}}
>
{item.key === "ratio" ? formatRatioDisplayValue(option) : option}
</button>
))}
</div>
) : null}
</div>
);
})}
</div>
</div>
</section>
{cloneOutput === "hot" ? (
<section className="clone-ai-replicate-panel" aria-label="爆款图复刻设置">
<div className="clone-ai-replicate-section">
<span className="clone-ai-replicate-title"></span>
<div className="clone-ai-replicate-tabs" role="tablist" aria-label="参考内容来源">
<button
type="button"
className={cloneReferenceMode === "upload" ? "is-active" : ""}
aria-selected={cloneReferenceMode === "upload"}
onClick={() => setCloneReferenceMode("upload")}
>
</button>
<button
type="button"
className={cloneReferenceMode === "link" ? "is-active" : ""}
aria-selected={cloneReferenceMode === "link"}
onClick={() => setCloneReferenceMode("link")}
>
</button>
</div>
{cloneReferenceMode === "upload" ? (
<button type="button" className="clone-ai-replicate-upload" onClick={() => cloneReferenceInputRef.current?.click()}>
<span>
<CloudUploadOutlined />
<span className="clone-ai-replicate-upload-text"></span>
</span>
<em>{cloneReferenceImages.length ? `已选 ${cloneReferenceImages.length}/${maxCloneReferenceImages}` : `最多 ${maxCloneReferenceImages}`}</em>
{cloneReferenceImages.length ? (
<div className="clone-ai-replicate-preview" aria-hidden="true">
{cloneReferenceImages.slice(0, 4).map((item) => (
<figure key={item.id}>
<img src={item.src} alt="" />
<span className="uploaded-image-zoom">
<img src={item.src} alt="" />
</span>
</figure>
))}
{cloneReferenceImages.length > 4 ? <b>+{cloneReferenceImages.length - 4}</b> : null}
</div>
) : null}
</button>
) : (
<label className="clone-ai-replicate-link">
<input placeholder="粘贴商品图或详情页链接" />
</label>
)}
<input
ref={cloneReferenceInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
onChange={handleCloneReferenceUpload}
/>
</div>
<div className="clone-ai-replicate-section">
<span className="clone-ai-replicate-title"></span>
<div className="clone-ai-replicate-levels" role="radiogroup" aria-label="复刻程度">
{cloneReplicateLevelOptions.map((option) => (
<button
key={option.key}
type="button"
className={cloneReplicateLevel === option.key ? "is-active" : ""}
aria-pressed={cloneReplicateLevel === option.key}
onClick={() => setCloneReplicateLevel(option.key)}
>
<strong>{option.title}</strong>
<span>{option.desc}</span>
</button>
))}
</div>
</div>
</section>
) : null}
{cloneOutput === "set" ? (
<section className="clone-ai-count-panel" aria-label="套图图片数量">
<p> 1-16 </p>
<div className="clone-ai-count-list">
{cloneSetCountOptions.map((item) => {
const count = cloneSetCounts[item.key];
const decrementDisabled = count <= 0 || cloneSetTotal <= minCloneSetTotal;
const incrementDisabled = cloneSetTotal >= maxCloneSetTotal;
return (
<div key={item.key} className="clone-ai-count-row">
<div className="clone-ai-count-copy">
<strong>{item.title}</strong>
<span>{item.desc}</span>
</div>
<div className="clone-ai-count-stepper" aria-label={`${item.title}数量`}>
<button
type="button"
disabled={decrementDisabled}
onPointerDown={(event) => {
event.preventDefault();
startCloneSetCountHold(item.key, -1, decrementDisabled);
}}
onPointerUp={clearCloneSetCountHold}
onPointerLeave={clearCloneSetCountHold}
onPointerCancel={clearCloneSetCountHold}
onBlur={clearCloneSetCountHold}
aria-label={`减少${item.title}`}
>
-
</button>
<b>{count}</b>
<button
type="button"
disabled={incrementDisabled}
onPointerDown={(event) => {
event.preventDefault();
startCloneSetCountHold(item.key, 1, incrementDisabled);
}}
onPointerUp={clearCloneSetCountHold}
onPointerLeave={clearCloneSetCountHold}
onPointerCancel={clearCloneSetCountHold}
onBlur={clearCloneSetCountHold}
aria-label={`增加${item.title}`}
>
+
</button>
</div>
</div>
);
})}
</div>
</section>
) : null}
{cloneOutput === "detail" ? (
<section className="clone-ai-module-panel" aria-label="详情图包含模块">
<p>
<QuestionCircleOutlined />
</p>
<div className="clone-ai-module-list">
{cloneDetailModules.map((module) => {
const isSelected = selectedCloneDetailModules.includes(module.id);
return (
<button
key={module.id}
type="button"
className={isSelected ? "is-active" : ""}
aria-pressed={isSelected}
onClick={() => toggleCloneDetailModule(module.id)}
>
<strong>{module.title}</strong>
<span>{module.desc}</span>
</button>
);
})}
</div>
</section>
) : null}
{cloneOutput === "model" ? (
<section className="clone-ai-model-panel" aria-label="模特图设置">
<div className="clone-ai-model-tabs" role="tablist" aria-label="模特图设置类型">
<button
type="button"
className={cloneModelPanelTab === "scene" ? "is-active" : ""}
aria-selected={cloneModelPanelTab === "scene"}
onClick={() => setCloneModelPanelTab("scene")}
>
</button>
<button
type="button"
className={cloneModelPanelTab === "model" ? "is-active" : ""}
aria-selected={cloneModelPanelTab === "model"}
onClick={() => setCloneModelPanelTab("model")}
>
</button>
</div>
<div className="clone-ai-model-scroll">
{cloneModelPanelTab === "scene" ? (
<div className="clone-ai-model-scenes">
<div className="clone-ai-model-scene-grid">
{tryOnScenes.map((scene) => {
const isSelected = selectedCloneModelScenes.includes(scene);
return (
<button
key={scene}
type="button"
className={isSelected ? "is-active" : ""}
aria-pressed={isSelected}
onClick={() => toggleCloneModelScene(scene)}
>
<span aria-hidden="true" />
{scene}
</button>
);
})}
</div>
<label className="clone-ai-model-textarea">
<strong></strong>
<textarea
value={cloneModelCustomScene}
onChange={(event) => setCloneModelCustomScene(event.target.value)}
placeholder="描述你想要的场景:如秋季枫叶小径、暖色调午后阳光、模特倚靠树干..."
/>
</label>
</div>
) : (
<div className="clone-ai-model-profile">
<div className="clone-ai-model-select-grid">
{cloneModelSelects.map((item) => {
const isOpen = openCloneModelSelect === item.key;
return (
<div
key={item.key}
className={`clone-ai-model-select${isOpen ? " is-open" : ""}${
isOpen && cloneModelSelectDropUp ? " is-drop-up" : ""
}`}
data-clone-model-select
>
<span>{item.label}</span>
<button
type="button"
className={isOpen ? "is-open" : ""}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={`clone-model-select-${item.key}`}
onClick={(event) => {
setOpenCloneBasicSelect(null);
if (!isOpen) {
event.currentTarget.scrollIntoView({ block: "center", inline: "nearest" });
const triggerRect = event.currentTarget.getBoundingClientRect();
const scrollRect = event.currentTarget.closest(".clone-ai-model-scroll")?.getBoundingClientRect();
const lowerBoundary = Math.min(window.innerHeight, scrollRect?.bottom ?? window.innerHeight);
const upperBoundary = Math.max(0, scrollRect?.top ?? 0);
const estimatedMenuHeight = Math.min(150, item.options.length * 36 + 12);
const belowSpace = lowerBoundary - triggerRect.bottom;
const aboveSpace = triggerRect.top - upperBoundary;
setCloneModelSelectDropUp(belowSpace < estimatedMenuHeight && aboveSpace > belowSpace);
} else {
setCloneModelSelectDropUp(false);
}
setOpenCloneModelSelect(isOpen ? null : item.key);
}}
>
<strong>{item.value}</strong>
<i aria-hidden="true" />
</button>
{isOpen ? (
<div id={`clone-model-select-${item.key}`} className="clone-ai-model-select__menu" role="listbox">
{item.options.map((option) => (
<button
key={option}
type="button"
className={item.value === option ? "is-active" : ""}
role="option"
aria-selected={item.value === option}
onClick={() => {
item.onChange(option);
setOpenCloneModelSelect(null);
setCloneModelSelectDropUp(false);
}}
>
{option}
</button>
))}
</div>
) : null}
</div>
);
})}
</div>
<label className="clone-ai-model-textarea">
<strong></strong>
<textarea
value={cloneModelAppearance}
onChange={(event) => setCloneModelAppearance(event.target.value)}
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
/>
</label>
</div>
)}
</div>
</section>
) : null}
{cloneOutput === "video" ? (
<section className="clone-ai-video-panel" aria-label="短视频设置">
<div className="clone-ai-video-section">
<span className="clone-ai-video-title"></span>
<div className="clone-ai-video-options">
{cloneVideoQualityOptions.map((option) => (
<button
key={option.key}
type="button"
className={cloneVideoQuality === option.key ? "is-active" : ""}
aria-pressed={cloneVideoQuality === option.key}
onClick={() => setCloneVideoQuality(option.key)}
>
<strong>{option.label}</strong>
<span>{option.desc}</span>
</button>
))}
</div>
</div>
<div className="clone-ai-video-section">
<div className="clone-ai-video-title-row">
<span className="clone-ai-video-title"></span>
<strong>{cloneVideoDuration}</strong>
</div>
<div className="clone-ai-duration-control" style={cloneVideoDurationStyle}>
<input
type="range"
min={cloneVideoDurationMin}
max={cloneVideoDurationMax}
step={1}
value={cloneVideoDuration}
onChange={(event) => setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))}
aria-label="短视频时长"
/>
<div className="clone-ai-duration-scale" aria-hidden="true">
<span>5</span>
<span>10</span>
<span>15</span>
</div>
</div>
</div>
<button
type="button"
className={`clone-ai-video-smart${cloneVideoSmart ? " is-on" : ""}`}
aria-pressed={cloneVideoSmart}
onClick={() => setCloneVideoSmart((current) => !current)}
>
<span>
<strong></strong>
<em></em>
</span>
<i aria-hidden="true" />
</button>
</section>
) : null}
{cloneOutput === "video-outfit" ? (
<section className="clone-ai-video-panel" aria-label="视频换装">
<div className="clone-ai-video-section">
<span className="clone-ai-video-title"></span>
<div className="clone-ai-video-outfit-upload">
<input
ref={videoOutfitVideoRef}
type="file"
accept="video/*"
onChange={handleVideoOutfitVideoChange}
style={{ display: "none" }}
/>
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitVideoRef.current?.click()}>
{videoOutfitVideoUrl ? "重新选择视频" : "选择视频文件"}
</button>
{videoOutfitVideoUrl ? <span className="clone-ai-video-outfit-info"></span> : null}
</div>
</div>
<div className="clone-ai-video-section">
<span className="clone-ai-video-title">/</span>
<div className="clone-ai-video-outfit-upload">
<input
ref={videoOutfitRefRef}
type="file"
accept="image/*"
onChange={handleVideoOutfitRefChange}
style={{ display: "none" }}
/>
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitRefRef.current?.click()}>
{videoOutfitRefUrl ? "重新选择参考图" : "选择参考图"}
</button>
{videoOutfitRefUrl ? <span className="clone-ai-video-outfit-info"></span> : null}
</div>
</div>
</section>
) : null}
<button type="button" className="clone-ai-generate" disabled={!canGenerate || cloneOutput === "video"} onClick={status === "failed" && lastFailedActionRef.current ? lastFailedActionRef.current : handleGenerate} style={cloneOutput === "video" ? { display: "none" } : undefined}>
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <ReloadOutlined /> : null}
{status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : cloneOutput === "video-outfit" ? "✦ 开始换装" : "✦ 开始生成"}
</button>
</div>
</>
);
}
@@ -0,0 +1,168 @@
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import type { ChangeEvent, RefObject } from "react";
import { EcommerceProgressBar } from "../EcommerceProgressBar";
interface EcommerceDetailPanelProps {
detailInputRef: RefObject<HTMLInputElement>;
detailProductImages: Array<{ id: string; src: string; name: string }>;
detailPlatform: string;
detailMarket: string;
detailLanguage: string;
detailType: string;
detailRequirement: string;
selectedDetailModules: string[];
detailStatus: string;
canGenerateDetail: boolean;
detailPrimaryLabel: string;
platformOptions: string[];
marketOptions: string[];
detailLanguageOptions: string[];
detailTypeOptions: string[];
detailModules: Array<{ id: string; title: string; desc: string }>;
handleDetailUpload: (event: ChangeEvent<HTMLInputElement>) => void;
handleDetailPlatformChange: (value: string) => void;
handleDetailMarketChange: (value: string) => void;
setDetailLanguage: (value: string) => void;
setDetailType: (value: string) => void;
setDetailRequirement: (value: string) => void;
handleDetailAiWrite: () => void;
toggleDetailModule: (id: string) => void;
handleDetailGenerate: () => void;
}
export default function EcommerceDetailPanel({
detailInputRef,
detailProductImages,
detailPlatform,
detailMarket,
detailLanguage,
detailType,
detailRequirement,
selectedDetailModules,
detailStatus,
canGenerateDetail,
detailPrimaryLabel,
platformOptions,
marketOptions,
detailLanguageOptions,
detailTypeOptions,
detailModules,
handleDetailUpload,
handleDetailPlatformChange,
handleDetailMarketChange,
setDetailLanguage,
setDetailType,
setDetailRequirement,
handleDetailAiWrite,
toggleDetailModule,
handleDetailGenerate,
}: EcommerceDetailPanelProps) {
return (
<>
<div className="product-clone-panel__scroll">
<section className="product-clone-field">
<h2>
<QuestionCircleOutlined />
</h2>
<button type="button" className="product-clone-upload-zone product-detail-upload" onClick={() => detailInputRef.current?.click()}>
<strong>
<CloudUploadOutlined />
</strong>
<span>3</span>
</button>
<input ref={detailInputRef} type="file" accept="image/*" multiple onChange={handleDetailUpload} />
{detailProductImages.length ? (
<div className="product-clone-thumb-row" aria-label="已上传商品原图">
{detailProductImages.map((item) => (
<figure key={item.id} className="product-clone-uploaded-thumb">
<img src={item.src} alt={item.name} />
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
</figure>
))}
</div>
) : null}
</section>
<section className="product-clone-field">
<h2></h2>
<div className="product-detail-settings-grid">
<select value={detailPlatform} onChange={(event) => handleDetailPlatformChange(event.target.value)}>
{platformOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={detailMarket} onChange={(event) => handleDetailMarketChange(event.target.value)}>
{marketOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={detailLanguage} onChange={(event) => setDetailLanguage(event.target.value)}>
{detailLanguageOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={detailType} onChange={(event) => setDetailType(event.target.value)}>
{detailTypeOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</div>
</section>
<section className="product-clone-field product-detail-requirement">
<h2>
&
<QuestionCircleOutlined />
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleDetailAiWrite();
}}
>
AI
</button>
</h2>
<textarea
value={detailRequirement}
onChange={(event) => setDetailRequirement(event.target.value)}
placeholder={"建议包含以下信息生成更精准:\n1.产品名称\n2.核心卖点\n3.适用人群\n4.期望场景\n5.具体参数"}
/>
</section>
<section className="product-clone-field">
<h2>
<QuestionCircleOutlined />
</h2>
<div className="product-detail-module-grid">
{detailModules.map((module) => (
<button
key={module.id}
type="button"
className={selectedDetailModules.includes(module.id) ? "is-active" : ""}
onClick={() => toggleDetailModule(module.id)}
>
<strong>{module.title}</strong>
<span>{module.desc}</span>
</button>
))}
</div>
</section>
</div>
<footer className="product-clone-panel__footer">
{detailStatus === "generating" ? <EcommerceProgressBar status="generating" label="A+详情页" /> : null}
<button type="button" className="product-clone-primary" disabled={!canGenerateDetail} onClick={handleDetailGenerate}>
{detailStatus === "generating" ? <LoadingOutlined /> : null}
{detailPrimaryLabel}
</button>
</footer>
</>
);
}
@@ -0,0 +1,169 @@
import { CloudUploadOutlined, CloseOutlined, FileImageOutlined, SettingOutlined } from "@ant-design/icons";
import type { ChangeEvent, DragEvent, RefObject } from "react";
interface EcommerceSetPanelProps {
setInputRef: RefObject<HTMLInputElement>;
setImages: Array<{ id: string; src: string; name: string }>;
isSetUploadDragging: boolean;
productSetOutputOptions: Array<{ key: string; label: string }>;
productSetOutput: string;
platformOptions: string[];
marketOptions: string[];
productSetLanguageOptions: string[];
productSetRatioOptions: string[];
productSetPlatform: string;
productSetMarket: string;
productSetLanguage: string;
productSetRatio: string;
setIsSetUploadDragging: (value: boolean) => void;
handleSetDrop: (event: DragEvent<HTMLElement>) => void;
handleSetUpload: (event: ChangeEvent<HTMLInputElement>) => void;
removeSetImage: (id: string) => void;
handleProductSetOutputChange: (value: string) => void;
handleProductSetPlatformChange: (value: string) => void;
handleProductSetMarketChange: (value: string) => void;
setProductSetLanguage: (value: string) => void;
setProductSetRatio: (value: string) => void;
formatRatioDisplayValue: (value: string) => string;
}
export default function EcommerceSetPanel({
setInputRef,
setImages,
isSetUploadDragging,
productSetOutputOptions,
productSetOutput,
platformOptions,
marketOptions,
productSetLanguageOptions,
productSetRatioOptions,
productSetPlatform,
productSetMarket,
productSetLanguage,
productSetRatio,
setIsSetUploadDragging,
handleSetDrop,
handleSetUpload,
removeSetImage,
handleProductSetOutputChange,
handleProductSetPlatformChange,
handleProductSetMarketChange,
setProductSetLanguage,
setProductSetRatio,
formatRatioDisplayValue,
}: EcommerceSetPanelProps) {
return (
<>
<div className="product-clone-panel__scroll">
<section className="product-clone-field product-set-upload-section">
<h2>
<CloudUploadOutlined />
</h2>
<button
type="button"
className={`product-clone-upload-zone product-set-upload${isSetUploadDragging ? " is-dragging" : ""}`}
onClick={() => setInputRef.current?.click()}
onDragEnter={(event) => {
event.preventDefault();
setIsSetUploadDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsSetUploadDragging(false)}
onDrop={handleSetDrop}
>
<span className="product-set-upload-icon">
<FileImageOutlined />
</span>
<span className="product-set-upload-title"></span>
<strong>
<span aria-hidden="true">+</span>
</strong>
<span className="product-set-upload-note"> 3 </span>
</button>
<input ref={setInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={handleSetUpload} />
{setImages.length ? (
<div className="product-clone-thumb-row product-set-thumb-row" aria-label="已上传商品原图">
{setImages.map((item) => (
<figure key={item.id} className="product-set-thumb">
<img src={item.src} alt={item.name} />
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
<button type="button" onClick={() => removeSetImage(item.id)} aria-label={`删除${item.name}`}>
<CloseOutlined />
</button>
</figure>
))}
</div>
) : null}
</section>
<section className="product-clone-field product-set-settings-section">
<h2>
<SettingOutlined />
</h2>
<div className="product-set-setting-block">
<span className="product-set-setting-title"></span>
<div className="product-set-output-grid" role="radiogroup" aria-label="生成内容">
{productSetOutputOptions.map((option) => (
<button
key={option.key}
type="button"
className={productSetOutput === option.key ? "is-active" : ""}
aria-pressed={productSetOutput === option.key}
onClick={() => handleProductSetOutputChange(option.key)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="product-set-setting-block">
<span className="product-set-setting-title"></span>
<div className="product-set-field-grid">
<label>
<span></span>
<select value={productSetPlatform} onChange={(event) => handleProductSetPlatformChange(event.target.value)}>
{platformOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</label>
<label>
<span></span>
<select value={productSetMarket} onChange={(event) => handleProductSetMarketChange(event.target.value)}>
{marketOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</label>
<label>
<span></span>
<select value={productSetLanguage} onChange={(event) => setProductSetLanguage(event.target.value)}>
{productSetLanguageOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</label>
<label>
<span>/</span>
<select
value={productSetRatio}
onChange={(event) => setProductSetRatio(event.target.value)}
disabled={productSetRatioOptions.length <= 1}
>
{productSetRatioOptions.map((item) => (
<option key={item} value={item}>{formatRatioDisplayValue(item)}</option>
))}
</select>
</label>
</div>
</div>
</section>
</div>
</>
);
}
@@ -0,0 +1,219 @@
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import type { ChangeEvent, RefObject } from "react";
import { EcommerceProgressBar } from "../EcommerceProgressBar";
interface EcommerceTryOnPanelProps {
garmentInputRef: RefObject<HTMLInputElement>;
garmentImages: Array<{ id: string; src: string; name: string }>;
modelSource: string;
modelGender: string;
modelAge: string;
modelEthnicity: string;
modelBody: string;
appearance: string;
selectedScenes: string[];
customScene: string;
smartScene: boolean;
tryOnRatio: string;
tryOnStatus: string;
canGenerateTryOn: boolean;
tryOnPrimaryLabel: string;
tryOnModelOptions: { gender: string[]; age: string[]; ethnicity: string[]; body: string[] };
tryOnAssets: { modelWoman: string; modelMan: string; modelAsian: string };
tryOnScenes: string[];
tryOnRatioOptions: string[];
handleGarmentUpload: (event: ChangeEvent<HTMLInputElement>) => void;
setModelSource: (value: "ai" | "library") => void;
setModelGender: (value: string) => void;
setModelAge: (value: string) => void;
setModelEthnicity: (value: string) => void;
setModelBody: (value: string) => void;
setAppearance: (value: string) => void;
handleGenerateModel: () => void;
toggleScene: (scene: string) => void;
setCustomScene: (value: string) => void;
setSmartScene: (updater: (current: boolean) => boolean) => void;
setTryOnRatio: (value: string) => void;
handleTryOnGenerate: () => void;
}
export default function EcommerceTryOnPanel({
garmentInputRef,
garmentImages,
modelSource,
modelGender,
modelAge,
modelEthnicity,
modelBody,
appearance,
selectedScenes,
customScene,
smartScene,
tryOnRatio,
tryOnStatus,
canGenerateTryOn,
tryOnPrimaryLabel,
tryOnModelOptions,
tryOnAssets,
tryOnScenes,
tryOnRatioOptions,
handleGarmentUpload,
setModelSource,
setModelGender,
setModelAge,
setModelEthnicity,
setModelBody,
setAppearance,
handleGenerateModel,
toggleScene,
setCustomScene,
setSmartScene,
setTryOnRatio,
handleTryOnGenerate,
}: EcommerceTryOnPanelProps) {
return (
<>
<div className="product-clone-panel__scroll">
<section className="product-clone-field">
<h2></h2>
<button type="button" className="product-clone-upload-zone product-try-on-upload" onClick={() => garmentInputRef.current?.click()}>
<strong>
<CloudUploadOutlined />
</strong>
<span>5</span>
</button>
<input ref={garmentInputRef} type="file" accept="image/*" multiple onChange={handleGarmentUpload} />
{garmentImages.length ? (
<div className="product-clone-thumb-row product-try-on-thumb-row" aria-label="已上传服装图片">
{garmentImages.map((item) => (
<figure key={item.id} className="product-clone-uploaded-thumb">
<img src={item.src} alt={item.name} />
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
</figure>
))}
</div>
) : null}
</section>
<section className="product-clone-field">
<h2></h2>
<div className="product-clone-segment" role="tablist" aria-label="模特来源">
<button type="button" className={modelSource === "ai" ? "is-active" : ""} onClick={() => setModelSource("ai")}>
AI
</button>
<button type="button" className={modelSource === "library" ? "is-active" : ""} onClick={() => setModelSource("library")}>
<QuestionCircleOutlined />
</button>
</div>
{modelSource === "ai" ? (
<>
<div className="product-clone-model-grid">
<select value={modelGender} onChange={(event) => setModelGender(event.target.value)}>
{tryOnModelOptions.gender.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={modelAge} onChange={(event) => setModelAge(event.target.value)}>
{tryOnModelOptions.age.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={modelEthnicity} onChange={(event) => setModelEthnicity(event.target.value)}>
{tryOnModelOptions.ethnicity.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={modelBody} onChange={(event) => setModelBody(event.target.value)}>
{tryOnModelOptions.body.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</div>
<label className="product-try-on-textarea-label">
<span></span>
<textarea
value={appearance}
onChange={(event) => setAppearance(event.target.value)}
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
/>
</label>
<button type="button" className="product-clone-model-button" onClick={handleGenerateModel} disabled={tryOnStatus === "modeling"}>
{tryOnStatus === "modeling" ? <LoadingOutlined /> : null}
{tryOnStatus === "modeling" ? "生成中..." : "生成基准模特"}
</button>
</>
) : (
<div className="product-try-on-library" aria-label="模特库">
{[tryOnAssets.modelWoman, tryOnAssets.modelMan, tryOnAssets.modelAsian].map((src, index) => (
<button key={src} type="button" className={index === 0 ? "is-active" : ""}>
<img src={src} alt={`模特 ${index + 1}`} />
</button>
))}
</div>
)}
</section>
<section className="product-clone-field">
<h2></h2>
<div className="product-clone-scene-grid">
{tryOnScenes.map((scene) => (
<button
key={scene}
type="button"
className={selectedScenes.includes(scene) ? "is-active" : ""}
onClick={() => toggleScene(scene)}
>
<span aria-hidden="true" />
{scene}
</button>
))}
</div>
</section>
<label className="product-clone-field product-try-on-scene-field">
<h2></h2>
<textarea
value={customScene}
onChange={(event) => setCustomScene(event.target.value)}
placeholder="描述你想要的场景:如秋季枫叶小径、暖色调午后阳光、模特倚靠树干..."
/>
</label>
<section className="product-clone-field">
<button type="button" className="product-clone-switch-row" onClick={() => setSmartScene((current) => !current)}>
<span>
<strong></strong>
<em></em>
</span>
<span className={`product-clone-switch${smartScene ? " is-on" : ""}`} role="switch" aria-checked={smartScene}>
<span />
</span>
</button>
</section>
<section className="product-clone-field">
<h2></h2>
<div className="product-clone-ratio-row">
{tryOnRatioOptions.map((item) => (
<button key={item} type="button" className={tryOnRatio === item ? "is-active" : ""} onClick={() => setTryOnRatio(item)}>
{item}
</button>
))}
</div>
</section>
</div>
<footer className="product-clone-panel__footer">
{tryOnStatus === "generating" ? <EcommerceProgressBar status="generating" label="服饰穿戴图" /> : null}
<button type="button" className="product-clone-primary" disabled={!canGenerateTryOn} onClick={handleTryOnGenerate}>
{tryOnStatus === "generating" ? <LoadingOutlined /> : null}
{tryOnPrimaryLabel}
</button>
</footer>
</>
);
}
+361 -86
View File
@@ -7,16 +7,13 @@ import {
ShoppingOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
import { useScrollEntrance } from "../../hooks/useScrollEntrance";
import WelcomeSplash from "./WelcomeSplash";
import ToolboxSection from "./ToolboxSection";
import ScriptReviewShowcase from "./ScriptReviewShowcase";
import ModelGenerationShowcase from "./ModelGenerationShowcase";
const ecommerceTemplate1 = "https://www.omniai.net.cn/static/home-ecommerce-template-1.png";
const ecommerceTemplate2 = "https://www.omniai.net.cn/static/home-ecommerce-template-2.png";
const ecommerceTemplate3 = "https://www.omniai.net.cn/static/home-ecommerce-template-3.png";
function ScrollEntrance({ children, className, ...rest }: { children: React.ReactNode; className?: string } & React.HTMLAttributes<HTMLElement>) {
const { ref, isVisible } = useScrollEntrance<HTMLElement>();
@@ -54,16 +51,6 @@ const HOME_CAROUSEL_IMAGES = [
];
const HOME_FEATURES = [
{
key: "script",
eyebrow: "Script Review",
title: "剧本智能测评",
description: "用六维雷达评分拆解剧本质量,从结构、节奏、人物到商业潜力给出可执行的优化路径。",
imageUrl: featureScriptImage,
actionLabel: "开始测评",
icon: <FileSearchOutlined />,
stats: ["六维评分", "质量量化", "逐项优化"],
},
{
key: "model",
eyebrow: "AI Generation",
@@ -84,6 +71,16 @@ const HOME_FEATURES = [
icon: <ShoppingOutlined />,
stats: ["多场景", "多角度", "批量输出"],
},
{
key: "script",
eyebrow: "Script Review",
title: "剧本智能测评",
description: "用六维雷达评分拆解剧本质量,从结构、节奏、人物到商业潜力给出可执行的优化路径。",
imageUrl: featureScriptImage,
actionLabel: "开始测评",
icon: <FileSearchOutlined />,
stats: ["六维评分", "质量量化", "逐项优化"],
},
];
const HOME_EXPERIENCE_POINTS = [
@@ -93,37 +90,96 @@ const HOME_EXPERIENCE_POINTS = [
{ label: "电商", meta: "商品视觉", tone: "amber" },
];
const HOME_ECOMMERCE_TEMPLATES = [
{
title: "卖点详情图",
tag: "详情",
meta: "中文卖点标注",
imageUrl: ecommerceTemplate1,
},
{
title: "场景主图",
tag: "主图",
meta: "商品氛围构图",
imageUrl: ecommerceTemplate2,
},
{
title: "虚拟模特",
tag: "模特",
meta: "使用场景延展",
imageUrl: ecommerceTemplate3,
},
const ECOMMERCE_MATRIX_FEATURES = [
{ icon: "⚡", title: "高效工作流", description: "自动化处理,一键触发" },
{ icon: "⊞", title: "矩阵式产出", description: "多场景、多尺寸批量生成" },
{ icon: "◈", title: "一致性保证", description: "智能保持商品特征与风格统一" },
];
const HOME_ECOMMERCE_TOOLS = [
{ title: "主图", meta: "平台首图" },
{ title: "详情", meta: "卖点拆解" },
{ title: "模特", meta: "虚拟模特" },
{ title: "短视频", meta: "首帧方案" },
const ECOMMERCE_MATRIX_PROCESS = [
{ icon: "📤", label: "上传原图", subLabel: "Upload" },
{ icon: "🔍", label: "AI识别", subLabel: "Recognition" },
{ icon: "⚙️", label: "生成处理", subLabel: "Processing" },
{ icon: "📦", label: "矩阵产出", subLabel: "Output" },
];
const ECOMMERCE_MATRIX_AI_STEPS = ["智能识别主体", "3D虚拟模特", "场景生成", "详情图生成", "批量导出"];
type EcommerceMatrixModelCard = {
kind: "model";
color: "brown" | "green" | "blue";
tag: string;
tagTone: string;
resolution: string;
square?: false;
};
type EcommerceMatrixSceneCard = {
kind: "scene";
color: "p1" | "p2" | "p3";
tag: string;
tagTone: string;
resolution: string;
square: true;
variant?: "greenery" | "blue";
};
type EcommerceMatrixLayoutCard = {
kind: "layout";
color: "c1" | "c2" | "c3";
tag: string;
tagTone: string;
resolution: string;
square: true;
badge: string;
badgeTone?: "purple";
};
type EcommerceMatrixCard = EcommerceMatrixModelCard | EcommerceMatrixSceneCard | EcommerceMatrixLayoutCard;
const ECOMMERCE_MATRIX_OUTPUTS: Array<{
title: string;
subtitle: string;
cards: EcommerceMatrixCard[];
}> = [
{
title: "3D 虚拟模特",
subtitle: "Virtual Model",
cards: [
{ kind: "model", color: "brown", tag: "3D", tagTone: "tag-3d", resolution: "1024×1536" },
{ kind: "model", color: "green", tag: "3D", tagTone: "tag-3d", resolution: "1024×1536" },
{ kind: "model", color: "blue", tag: "3D", tagTone: "tag-3d", resolution: "1024×1536" },
],
},
{
title: "场景图",
subtitle: "Scene Image",
cards: [
{ kind: "scene", color: "p1", tag: "场景", tagTone: "tag-scene", resolution: "1024×1024", square: true },
{ kind: "scene", color: "p2", tag: "场景", tagTone: "tag-scene", resolution: "1024×1024", square: true, variant: "greenery" },
{ kind: "scene", color: "p3", tag: "场景", tagTone: "tag-scene", resolution: "1024×1024", square: true, variant: "blue" },
],
},
{
title: "详情图",
subtitle: "Detail Image",
cards: [
{ kind: "layout", color: "c1", tag: "详情", tagTone: "tag-layout", resolution: "1080×1080", square: true, badge: "优雅随行" },
{ kind: "layout", color: "c2", tag: "详情", tagTone: "tag-layout", resolution: "1080×1080", square: true, badge: "限时特惠", badgeTone: "purple" },
{ kind: "layout", color: "c3", tag: "详情", tagTone: "tag-layout", resolution: "1080×1080", square: true, badge: "新品首发" },
],
},
];
const HOME_CAROUSEL_SLOTS = [-4, -3, -2, -1, 0, 1, 2, 3, 4];
const HOME_CAROUSEL_TRANSITION_MS = 860;
type EcommerceFlowLine = {
d: string;
x: number;
y: number;
};
interface HomeCarouselMotion {
direction: number;
progress: 0 | 1;
@@ -137,9 +193,9 @@ function getHomeCarouselCardStyle(offset: number): CSSProperties {
const depth = Math.abs(offset);
const direction = Math.sign(offset);
const isActive = depth === 0;
const xByDepth = [0, 286, 456, 610, 735, 840];
const xByDepth = [0, 190, 320, 430, 520, 590];
const yByDepth = [8, -2, -8, -13, -18, -24];
const scaleByDepth = [1, 0.98, 0.94, 0.91, 0.88, 0.84];
const scaleByDepth = [1, 1, 1, 1, 1, 1];
const x = direction * (xByDepth[depth] ?? xByDepth[xByDepth.length - 1]!);
const y = yByDepth[depth] ?? yByDepth[yByDepth.length - 1]!;
const z = isActive ? 90 : 28 - depth;
@@ -159,38 +215,253 @@ function getHomeCarouselCardStyle(offset: number): CSSProperties {
} as CSSProperties;
}
function EcommerceFeatureShowcase() {
function EcommerceMatrixCardVisual({ card }: { card: EcommerceMatrixCard }) {
if (card.kind === "model") {
return (
<div className="mock-model">
<div className="silhouette" />
<div className={`mock-product-hold ${card.color}`} />
</div>
);
}
if (card.kind === "scene") {
return (
<div className="mock-scene">
{card.variant === "greenery" ? <div className="obj greenery" /> : <div className="obj decor-item is-soft-blue" />}
<div className={`obj table-top${card.variant === "greenery" ? " is-warm" : ""}`} />
<div className={`obj prod ${card.color}`} />
</div>
);
}
return (
<div className="omni-home-ecommerce-showcase">
<div className="omni-home-ecommerce-showcase__depth" />
<div className="omni-home-ecommerce-showcase__grain" />
<div className="omni-home-ecommerce-showcase__prompt">
<span> + </span>
<strong></strong>
<p></p>
<div className="mock-layout">
<div className="lay-img">
<div className={`mini-cup ${card.color}`} />
</div>
<div className="lay-text">
<div className={`lay-line title${card.color === "c2" ? " is-short" : card.color === "c3" ? " is-wide" : ""}`} />
<div className={`lay-line sub${card.color === "c2" ? " is-medium" : ""}`} />
<div className={`lay-line short${card.color === "c3" ? " is-medium" : ""}`} />
<div className={`lay-badge${card.badgeTone === "purple" ? " purple" : ""}`}>{card.badge}</div>
</div>
</div>
);
}
<div className="omni-home-ecommerce-showcase__tools" aria-hidden="true">
{HOME_ECOMMERCE_TOOLS.map((item) => (
<div key={item.title} className="omni-home-ecommerce-showcase__tool">
<b>{item.title}</b>
<small>{item.meta}</small>
function EcommerceFeatureShowcase() {
const rootRef = useRef<HTMLDivElement | null>(null);
const inputCardRef = useRef<HTMLDivElement | null>(null);
const outputGroupRefs = useRef<Array<HTMLDivElement | null>>([]);
const [flowLines, setFlowLines] = useState<EcommerceFlowLine[]>(() =>
ECOMMERCE_MATRIX_OUTPUTS.map(() => ({ d: "", x: 0, y: 0 })),
);
useEffect(() => {
let frameId: number | null = null;
const updateFlowLines = () => {
const root = rootRef.current;
const inputCard = inputCardRef.current;
if (!root || !inputCard) return;
const rootRect = root.getBoundingClientRect();
const inputRect = inputCard.getBoundingClientRect();
const sx = inputRect.right - rootRect.left;
const sy = inputRect.top - rootRect.top + inputRect.height / 2;
const cornerRadius = 24;
const nextLines = outputGroupRefs.current.slice(0, ECOMMERCE_MATRIX_OUTPUTS.length).map((group) => {
if (!group) return { d: "", x: 0, y: 0 };
const groupRect = group.getBoundingClientRect();
const tx = groupRect.left - rootRect.left;
const ty = groupRect.top - rootRect.top + groupRect.height / 2;
const totalDistance = tx - sx;
const splitX = sx + totalDistance * 0.3;
const direction = ty > sy ? 1 : ty < sy ? -1 : 0;
const verticalDistance = Math.abs(ty - sy);
const resolvedRadius = Math.min(cornerRadius, verticalDistance / 2);
const d =
direction === 0
? `M ${sx} ${sy} L ${tx} ${ty}`
: `M ${sx} ${sy} L ${splitX} ${sy} Q ${splitX + resolvedRadius} ${sy}, ${splitX + resolvedRadius} ${
sy + direction * resolvedRadius
} L ${splitX + resolvedRadius} ${ty - direction * resolvedRadius} Q ${splitX + resolvedRadius} ${ty}, ${
splitX + resolvedRadius * 2
} ${ty} L ${tx} ${ty}`;
return { d, x: tx, y: ty };
});
setFlowLines(nextLines);
};
const scheduleUpdate = () => {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
frameId = window.requestAnimationFrame(updateFlowLines);
};
scheduleUpdate();
window.addEventListener("resize", scheduleUpdate);
const resizeObserver = new ResizeObserver(scheduleUpdate);
if (rootRef.current) {
resizeObserver.observe(rootRef.current);
}
return () => {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
window.removeEventListener("resize", scheduleUpdate);
resizeObserver.disconnect();
};
}, []);
return (
<div ref={rootRef} className="omni-home-ecommerce-matrix">
<div className="bg-base" />
<div className="bg-grid" />
<div className="bg-stars" />
<div className="bg-vignette" />
<div className="bg-noise" />
<div className="page">
<div className="left-panel">
<h3 className="hero-title">
<br />
</h3>
<p className="hero-desc">
3D虚拟模特
<br />
AI工作流自动化
</p>
<div className="features">
{ECOMMERCE_MATRIX_FEATURES.map((item) => (
<div key={item.title} className="feature-item">
<div className="feature-icon">{item.icon}</div>
<div className="feature-text">
<h4>{item.title}</h4>
<p>{item.description}</p>
</div>
</div>
))}
</div>
))}
</div>
<div className="omni-home-ecommerce-showcase__gallery" aria-hidden="true">
{HOME_ECOMMERCE_TEMPLATES.map((item, index) => (
<article key={item.title} className={`omni-home-ecommerce-showcase__shot is-${index + 1}`}>
<img src={item.imageUrl} alt="" />
<div>
<span>{item.tag}</span>
<strong>{item.title}</strong>
<small>{item.meta}</small>
<div className="process-flow">
{ECOMMERCE_MATRIX_PROCESS.map((item, index) => (
<Fragment key={item.label}>
{index > 0 ? <span className="process-arrow"></span> : null}
<div className="process-step">
<span className="step-icon">{item.icon}</span>
<span className="step-label">{item.label}</span>
<span className="step-sub">{item.subLabel}</span>
</div>
</Fragment>
))}
</div>
</div>
<div className="center-panel">
<div ref={inputCardRef} className="input-card">
<div className="input-card-header">
<span className="input-card-label"> Input</span>
<span className="input-card-res">3000×3000</span>
</div>
</article>
))}
<div className="input-card-img">
<div className="product-placeholder">
<div className="cup cup-1">
<div className="cup-lid" />
<div className="cup-straw" />
<div className="cup-tag">DRINK MORE</div>
</div>
<div className="cup cup-2">
<div className="cup-lid" />
<div className="cup-straw" />
<div className="cup-tag">DRINK MORE</div>
</div>
<div className="cup cup-3">
<div className="cup-lid" />
<div className="cup-straw" />
<div className="cup-tag">DRINK MORE</div>
</div>
<div className="books">
<div className="book" />
<div className="book" />
<div className="book" />
<div className="book" />
</div>
<div className="table-surface" />
</div>
</div>
</div>
</div>
<div className="right-panel">
<div className="ai-node">
<div className="ai-node-title">AI </div>
<div className="ai-node-list">
{ECOMMERCE_MATRIX_AI_STEPS.map((item) => (
<div key={item} className="ai-node-item">
{item}
</div>
))}
</div>
</div>
{ECOMMERCE_MATRIX_OUTPUTS.map((group, groupIndex) => (
<div
key={group.title}
ref={(node) => {
outputGroupRefs.current[groupIndex] = node;
}}
className="output-group"
>
<div className="output-label">
<h4>{group.title}</h4>
<p>{group.subtitle}</p>
</div>
<div className="output-cards">
{group.cards.map((card, cardIndex) => (
<div key={`${group.title}-${cardIndex}`} className={`output-card${card.square ? " square" : ""}`}>
<div className="output-card-img">
<span className={`output-card-tag ${card.tagTone}`}>{card.tag}</span>
<EcommerceMatrixCardVisual card={card} />
<span className="output-card-res">{card.resolution}</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
<svg className="flow-svg" aria-hidden="true">
<defs>
<filter id="home-ecommerce-flow-glow">
<feGaussianBlur stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{flowLines.map((line, index) => (
<Fragment key={index}>
<path className={`flow-path flow-path-${index + 1}`} d={line.d} filter="url(#home-ecommerce-flow-glow)" />
<circle className={`flow-dot flow-dot-${index + 1}`} cx={line.x} cy={line.y} r="4" filter="url(#home-ecommerce-flow-glow)" />
</Fragment>
))}
</svg>
</div>
</div>
);
@@ -367,19 +638,21 @@ function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptR
<main className="omni-home__feature-pages" aria-label="OmniAI 功能介绍">
{HOME_FEATURES.map((feature, index) => (
<section key={feature.key} className={`omni-home__feature-page is-${feature.key}${index % 2 ? " is-alt" : ""}`}>
<div className="omni-home__feature-copy">
<span>
{feature.icon}
{feature.eyebrow}
</span>
<h2>{feature.title}</h2>
<p>{feature.description}</p>
<button type="button" onClick={() => handleFeatureOpen(feature.key)}>
{feature.actionLabel}
<ArrowRightOutlined />
</button>
</div>
<div className="omni-home__feature-visual" aria-hidden="true">
{feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce" ? (
<div className="omni-home__feature-copy">
<span>
{feature.icon}
{feature.eyebrow}
</span>
<h2>{feature.title}</h2>
<p>{feature.description}</p>
<button type="button" onClick={() => handleFeatureOpen(feature.key)}>
{feature.actionLabel}
<ArrowRightOutlined />
</button>
</div>
) : null}
<div className="omni-home__feature-visual" aria-hidden={feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce"}>
{feature.key === "script" ? (
<ScriptReviewShowcase />
) : feature.key === "model" ? (
@@ -390,14 +663,18 @@ function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptR
<img src={feature.imageUrl} alt="" />
)}
</div>
<div className="omni-home__feature-stats" aria-hidden="true">
{feature.stats.map((item) => (
<span key={item}>{item}</span>
))}
</div>
{feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce" ? (
<div className="omni-home__feature-stats" aria-hidden="true">
{feature.stats.map((item) => (
<span key={item}>{item}</span>
))}
</div>
) : null}
</section>
))}
<ToolboxSection onSelectView={onSelectView} onOpenImageTool={onOpenImageTool} />
<section className="omni-home__experience" aria-label="点击体验">
<div className="omni-home__experience-copy">
<span>
@@ -430,8 +707,6 @@ function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptR
</button>
</div>
</section>
<ToolboxSection onSelectView={onSelectView} onOpenImageTool={onOpenImageTool} />
</main>
</section>
</>
+141 -106
View File
@@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from "react";
const DIMS = [
{ name: "钩子设计", score: 16, max: 20, hue: 145, desc: "吸引力·悬念·黄金三秒", isPerfect: false, isLow: false },
{ name: "角色塑造", score: 15, max: 15, hue: 155, desc: "立体度·动机·弧光", isPerfect: true, isLow: false },
{ name: "剧情结构", score: 16, max: 20, hue: 165, desc: "起承转合·节奏·冲突", isPerfect: false, isLow: false },
{ name: "角色塑造", score: 15, max: 15, hue: 155, desc: "立体度·动机·弧光", isPerfect: true, isLow: false },
{ name: "逻辑严密", score: 12, max: 15, hue: 175, desc: "自洽·伏笔·因果链", isPerfect: false, isLow: false },
{ name: "场景构建", score: 10, max: 15, hue: 185, desc: "空间·视听·画面感", isPerfect: false, isLow: true },
{ name: "内容深度", score: 8, max: 15, hue: 195, desc: "主题·情感·思想内核", isPerfect: false, isLow: true },
@@ -27,6 +27,12 @@ const OPTIMIZATIONS = [
{ dim: "逻辑严密 → 补强", priority: "中优先", priorityClass: "badge-orange", text: "补充世界观细节,强化因果链与伏笔回收" },
];
const SHOWCASE_POINTS = [
{ icon: "⚡", title: "六维评分", text: "结构、节奏、人物到商业潜力全面量化" },
{ icon: "◈", title: "质量量化", text: "用雷达评分拆解剧本质量与短板" },
{ icon: "↗", title: "逐项优化", text: "给出可执行的优化路径和打磨方向" },
];
function animateNumber(el: HTMLElement | null, target: number, duration: number) {
if (!el) return;
const start = performance.now();
@@ -79,125 +85,154 @@ function ScriptReviewShowcase() {
return (
<div className="omni-script-review-showcase" id="script-review-showcase">
{/* Score Hero */}
<div className="srs-score-hero">
<div className="srs-score-left">
<div className="srs-score-circle">
<div className="srs-score-circle-inner">
<span className="srs-score-num" ref={scoreRef}>0</span>
<span className="srs-score-den">/ 100</span>
</div>
</div>
<div className="srs-score-meta">
<div className="srs-score-grade">A </div>
<div className="srs-score-tags">
<span className="srs-score-tag"></span>
<span className="srs-score-tag">58min</span>
<span className="srs-score-tag">6</span>
</div>
</div>
<div className="srs-left-panel">
<div className="srs-brand-section">
<h1></h1>
<p></p>
</div>
<div className="srs-score-divider" />
<div className="srs-score-right">
<div className="srs-score-proj">广 · </div>
<div className="srs-score-summary">
</div>
<div className="srs-point-list">
{SHOWCASE_POINTS.map((item) => (
<div key={item.title} className="srs-point-card">
<div className="srs-point-icon">{item.icon}</div>
<div>
<h3>{item.title}</h3>
<p>{item.text}</p>
</div>
</div>
))}
</div>
<div className="srs-flow-card">
<span></span>
<b></b>
<span></span>
<b></b>
<span></span>
</div>
</div>
{/* Vertical Bar Chart */}
<div className="srs-chart-card">
<div className="srs-chart-title"> Dimension Breakdown</div>
<div className="srs-chart-body">
{DIMS.map((dim, i) => {
const pct = dim.score / dim.max;
return (
<div key={dim.name} className="srs-chart-col">
<div className="srs-chart-bar-wrap">
<div className="srs-chart-bar-bg" style={{ height: "100%" }} />
<div
ref={(el) => { barRefs.current[i] = el; }}
className={`srs-chart-bar-fill${dim.isPerfect ? " is-perfect" : ""}${dim.isLow ? " is-low" : ""}`}
data-pct={String(Math.round(pct * 100))}
style={{ height: "0%" }}
>
<div className="srs-chart-bar-score">
<span
ref={(el) => { scoreValRefs.current[i] = el; }}
data-target={String(dim.score)}
>0</span>
<span className="srs-chart-bar-sub">/{dim.max}</span>
{dim.isPerfect && <span className="srs-chart-bar-star"></span>}
<div className="srs-results-panel">
{/* Score Hero */}
<div className="srs-score-hero">
<div className="srs-score-left">
<div className="srs-score-circle">
<div className="srs-score-circle-inner">
<span className="srs-score-num" ref={scoreRef}>0</span>
<span className="srs-score-den">/ 100</span>
</div>
</div>
<div className="srs-score-meta">
<div className="srs-score-grade">A </div>
<div className="srs-score-tags">
<span className="srs-score-tag"></span>
<span className="srs-score-tag">58min</span>
<span className="srs-score-tag">6</span>
</div>
</div>
</div>
<div className="srs-score-divider" />
<div className="srs-score-right">
<div className="srs-score-proj">广 · </div>
<div className="srs-score-summary">
</div>
</div>
</div>
{/* Vertical Bar Chart */}
<div className="srs-chart-card">
<div className="srs-chart-title"> Dimension Breakdown</div>
<div className="srs-chart-body">
{DIMS.map((dim, i) => {
const pct = dim.score / dim.max;
return (
<div key={dim.name} className="srs-chart-col">
<div className="srs-chart-bar-wrap">
<div className="srs-chart-bar-bg" style={{ height: "100%" }} />
<div
ref={(el) => { barRefs.current[i] = el; }}
className={`srs-chart-bar-fill${dim.isPerfect ? " is-perfect" : ""}${dim.isLow ? " is-low" : ""}`}
data-pct={String(Math.round(pct * 100))}
style={{ height: "0%" }}
>
<div className="srs-chart-bar-score">
<span
ref={(el) => { scoreValRefs.current[i] = el; }}
data-target={String(dim.score)}
>0</span>
<span className="srs-chart-bar-sub">/{dim.max}</span>
{dim.isPerfect && <span className="srs-chart-bar-star"></span>}
</div>
</div>
</div>
<div className="srs-chart-col-label">
<div className="srs-chart-col-name">{dim.name}</div>
<div className="srs-chart-col-desc">{dim.desc}</div>
</div>
</div>
<div className="srs-chart-col-label">
<div className="srs-chart-col-name">{dim.name}</div>
<div className="srs-chart-col-desc">{dim.desc}</div>
</div>
</div>
);
})}
</div>
</div>
{/* Triple Section */}
<div className="srs-triple-section">
{/* Highlights */}
<div className="srs-section-card is-highlight">
<div className="srs-section-header">
<div className="srs-section-icon"></div>
<span className="srs-section-label"></span>
</div>
<div className="srs-section-list">
{HIGHLIGHTS.map((item) => (
<div key={item.dim} className="srs-section-item">
<div className="srs-section-item-head">
<span className="srs-section-item-dim">{item.dim}</span>
<span className="srs-section-item-score is-green">{item.score}</span>
</div>
<div className="srs-section-item-text">{item.text}</div>
</div>
))}
);
})}
</div>
</div>
{/* Weaknesses */}
<div className="srs-section-card is-weakness">
<div className="srs-section-header">
<div className="srs-section-icon"></div>
<span className="srs-section-label"></span>
</div>
<div className="srs-section-list">
{WEAKNESSES.map((item) => (
<div key={item.dim} className="srs-section-item">
<div className="srs-section-item-head">
<span className="srs-section-item-dim">{item.dim}</span>
<span className="srs-section-item-score is-red">{item.score}</span>
{/* Triple Section */}
<div className="srs-triple-section">
{/* Highlights */}
<div className="srs-section-card is-highlight">
<div className="srs-section-header">
<div className="srs-section-icon"></div>
<span className="srs-section-label"></span>
</div>
<div className="srs-section-list">
{HIGHLIGHTS.map((item) => (
<div key={item.dim} className="srs-section-item">
<div className="srs-section-item-head">
<span className="srs-section-item-dim">{item.dim}</span>
<span className="srs-section-item-score is-green">{item.score}</span>
</div>
<div className="srs-section-item-text">{item.text}</div>
</div>
<div className="srs-section-item-text">{item.text}</div>
</div>
))}
))}
</div>
</div>
</div>
{/* Optimization */}
<div className="srs-section-card is-optimize">
<div className="srs-section-header">
<div className="srs-section-icon"></div>
<span className="srs-section-label"></span>
</div>
<div className="srs-section-list">
{OPTIMIZATIONS.map((item) => (
<div key={item.dim} className="srs-section-item">
<div className="srs-section-item-head">
<span className="srs-section-item-dim">{item.dim}</span>
<span className={`srs-section-item-badge ${item.priorityClass}`}>{item.priority}</span>
{/* Weaknesses */}
<div className="srs-section-card is-weakness">
<div className="srs-section-header">
<div className="srs-section-icon"></div>
<span className="srs-section-label"></span>
</div>
<div className="srs-section-list">
{WEAKNESSES.map((item) => (
<div key={item.dim} className="srs-section-item">
<div className="srs-section-item-head">
<span className="srs-section-item-dim">{item.dim}</span>
<span className="srs-section-item-score is-red">{item.score}</span>
</div>
<div className="srs-section-item-text">{item.text}</div>
</div>
<div className="srs-section-item-text">{item.text}</div>
</div>
))}
))}
</div>
</div>
{/* Optimization */}
<div className="srs-section-card is-optimize">
<div className="srs-section-header">
<div className="srs-section-icon"></div>
<span className="srs-section-label"></span>
</div>
<div className="srs-section-list">
{OPTIMIZATIONS.map((item) => (
<div key={item.dim} className="srs-section-item">
<div className="srs-section-item-head">
<span className="srs-section-item-dim">{item.dim}</span>
<span className={`srs-section-item-badge ${item.priorityClass}`}>{item.priority}</span>
</div>
<div className="srs-section-item-text">{item.text}</div>
</div>
))}
</div>
</div>
</div>
</div>
@@ -787,7 +787,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</div>
) : inpaintResultImages.length && activeTool === "inpaint" ? (
<div className="image-workbench-inpaint-stage">
<img src={inpaintResultImages[0]} alt="重绘结果" style={{ maxWidth: "90%", maxHeight: "90%", borderRadius: 8, objectFit: "contain" }} />
<img src={inpaintResultImages[0]} alt="重绘结果" style={{ maxWidth: "95%", maxHeight: "95%", borderRadius: 8, objectFit: "contain" }} />
<div className="image-workbench-inpaint-bottom-bar">
<button type="button" className="image-workbench-inpaint-edit-btn" onClick={() => { setInpaintResultImages([]); setIsMaskEditing(true); setInpaintTool("brush"); setCanvasInitCounter((c) => c + 1); }}>
<HighlightOutlined />
@@ -1284,12 +1284,16 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
))}
</div>
) : referenceImage ? (
<img src={referenceImage} alt="参考图预览" />
<div className="studio-canvas-image">
<img src={referenceImage} alt="参考图预览" />
</div>
) : (
<div className="image-workbench-empty">
<PictureOutlined />
<strong></strong>
<span></span>
<div className="studio-canvas-ghost">
<div className="studio-canvas-ghost__icon">
<PictureOutlined />
</div>
<div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"></div>
</div>
)}
</section>
+131 -22
View File
@@ -214,6 +214,14 @@ function ProfilePage({
const [isSubmitting, setIsSubmitting] = useState(false);
const [smsCooldown, setSmsCooldown] = useState(0);
const [isSendingSms, setIsSendingSms] = useState(false);
const [emailCode, setEmailCode] = useState("");
const [emailCooldown, setEmailCooldown] = useState(0);
const [isSendingEmail, setIsSendingEmail] = useState(false);
const [showForgotPassword, setShowForgotPassword] = useState(false);
const [forgotStep, setForgotStep] = useState<"email" | "code" | "newPassword">("email");
const [forgotEmail, setForgotEmail] = useState("");
const [forgotCode, setForgotCode] = useState("");
const [forgotPassword, setForgotPassword] = useState("");
const [activePanel, setActivePanel] = useState<ProfilePanel>("works");
const [accountPanel, setAccountPanel] = useState<AccountPanel>("credits");
@@ -296,6 +304,70 @@ function ProfilePage({
return () => window.clearInterval(timer);
}, [smsCooldown]);
useEffect(() => {
if (emailCooldown <= 0) return;
const timer = window.setInterval(() => {
setEmailCooldown((current) => Math.max(0, current - 1));
}, 1000);
return () => window.clearInterval(timer);
}, [emailCooldown]);
const handleSendEmailCode = async (purpose: "register" | "login" | "reset" = "register") => {
const targetEmail = purpose === "reset" ? forgotEmail : email;
if (emailCooldown > 0 || !targetEmail.trim() || isSendingEmail) return;
if (purpose === "register" && !betaCode.trim()) {
setNotice("请输入企业邀请码 / 内测码后再获取验证码");
return;
}
setIsSendingEmail(true);
setNotice(null);
try {
const result = await keyServerClient.sendEmailCode(targetEmail, purpose, betaCode);
setEmailCooldown(result.cooldownSeconds || 60);
if (result.devCode) {
setNotice(`验证码已发送(开发模式: ${result.devCode}`);
} else {
setNotice("验证码已发送,请查收邮件");
}
} catch (error) {
setNotice(error instanceof Error ? error.message : "验证码发送失败");
} finally {
setIsSendingEmail(false);
}
};
const handleForgotPassword = async () => {
if (forgotStep === "email") {
if (!forgotEmail.trim()) { setNotice("请输入邮箱"); return; }
try {
await keyServerClient.forgotPassword({ email: forgotEmail });
setForgotStep("code");
setNotice("重置验证码已发送到您的邮箱");
await handleSendEmailCode("reset");
} catch (error) {
setNotice(error instanceof Error ? error.message : "发送失败");
}
} else if (forgotStep === "code") {
if (!forgotCode.trim()) { setNotice("请输入验证码"); return; }
setForgotStep("newPassword");
setNotice(null);
} else {
if (forgotPassword.length < 6) { setNotice("密码至少 6 位"); return; }
try {
const result = await keyServerClient.resetPassword({ email: forgotEmail, code: forgotCode, newPassword: forgotPassword });
setNotice(result.message || "密码重置成功,请重新登录");
setShowForgotPassword(false);
setForgotStep("email");
setForgotEmail("");
setForgotCode("");
setForgotPassword("");
setMode("login");
} catch (error) {
setNotice(error instanceof Error ? error.message : "重置失败");
}
}
};
const handleSendSms = async () => {
if (smsCooldown > 0 || !phone.trim() || isSendingSms) return;
if (mode === "register" && !betaCode.trim()) {
@@ -340,6 +412,10 @@ function ProfilePage({
if (!value.trim()) return "请输入验证码";
if (value.length !== 6) return "验证码为 6 位数字";
return "";
case "emailCode":
if (!value.trim()) return "请输入邮箱验证码";
if (value.length !== 6) return "验证码为 6 位数字";
return "";
default:
return "";
}
@@ -379,6 +455,10 @@ function ProfilePage({
if (emailErr) errors.email = emailErr;
const pwErr = validateField("password", password);
if (pwErr) errors.password = pwErr;
if (mode === "register") {
const codeErr = validateField("emailCode", emailCode);
if (codeErr) errors.emailCode = codeErr;
}
} else {
const userErr = validateField("username", username);
if (userErr) errors.username = userErr;
@@ -405,7 +485,7 @@ function ProfilePage({
const nextSession =
mode === "login"
? await keyServerClient.loginEmail({ email, password })
: await keyServerClient.registerEmail({ email, password, username: username.trim() || undefined, betaCode });
: await keyServerClient.registerEmail({ email, password, code: emailCode, username: username.trim() || undefined, betaCode });
await onAuthComplete?.(nextSession);
} else if (mode === "login") {
await onLogin(username.trim(), password);
@@ -530,20 +610,22 @@ function ProfilePage({
const renderActivePanel = () => {
if (activePanel === "works") {
return visibleWorks.length ? (
<div className="profile-page__list-grid motion-stagger">
{visibleWorks.map((task) => (
<article key={task.id} className="profile-page__list-card">
<div className="profile-page__list-card-head">
<strong>{task.title}</strong>
<span>{formatTaskType(task.type)}</span>
</div>
<p>{task.prompt}</p>
<div className="profile-page__list-card-meta">
<span>{formatTaskStatus(task.status)}</span>
<span>{formatProfileDate(task.createdAt)}</span>
</div>
</article>
))}
<div className="profile-page__works-scroll">
<div className="profile-page__list-grid motion-stagger">
{visibleWorks.map((task) => (
<article key={task.id} className="profile-page__list-card">
<div className="profile-page__list-card-head">
<strong>{task.title}</strong>
<span>{formatTaskType(task.type)}</span>
</div>
<p>{task.prompt}</p>
<div className="profile-page__list-card-meta">
<span>{formatTaskStatus(task.status)}</span>
<span>{formatProfileDate(task.createdAt)}</span>
</div>
</article>
))}
</div>
</div>
) : (
renderEmptyState("向全世界展示你最得意的创作。", "开始创作", onOpenWorkbench)
@@ -850,7 +932,6 @@ function ProfilePage({
<span className="auth-page__logo">
<img src={AUTH_LOGO_URL} alt="OmniAI" />
</span>
<span className="auth-page__form-kicker">{mode === "login" ? "账户登录" : "新用户注册"}</span>
<h2 className="auth-page__title">{mode === "login" ? "欢迎回来" : "创建账号"}</h2>
<p className="auth-page__subtitle">
{mode === "login" ? "登录后继续你的 AI 创作之旅" : "注册即可免费体验全部功能"}
@@ -919,7 +1000,31 @@ function ProfilePage({
</label>
) : null}
{authTab === "password" ? (
{showForgotPassword ? (
<div className="auth-page__forgot-box">
<p className="auth-page__forgot-title"></p>
{forgotStep === "email" ? (
<input value={forgotEmail} onChange={(e) => setForgotEmail(e.target.value)} placeholder="输入注册邮箱" type="email" className="auth-page__forgot-input" />
) : forgotStep === "code" ? (
<div className="auth-page__sms-row">
<input value={forgotCode} onChange={(e) => setForgotCode(e.target.value)} placeholder="输入验证码" maxLength={6} />
<button type="button" className="auth-page__sms-btn" disabled={emailCooldown > 0 || isSendingEmail} onClick={() => void handleSendEmailCode("reset")}>
{isSendingEmail ? "发送中" : emailCooldown > 0 ? `${emailCooldown}s` : "重新发送"}
</button>
</div>
) : (
<input type="password" value={forgotPassword} onChange={(e) => setForgotPassword(e.target.value)} placeholder="输入新密码(至少 6 位)" className="auth-page__forgot-input" />
)}
<div className="auth-page__forgot-actions">
<button type="button" className="auth-page__forgot-cancel" onClick={() => { setShowForgotPassword(false); setForgotStep("email"); setForgotEmail(""); setForgotCode(""); setForgotPassword(""); setNotice(null); }}></button>
<button type="button" className="auth-page__forgot-confirm" onClick={() => void handleForgotPassword()}>
{forgotStep === "newPassword" ? "重置密码" : "下一步"}
</button>
</div>
</div>
) : null}
{!showForgotPassword && authTab === "password" ? (
<>
<label className={`auth-page__field${fieldErrors.username ? " auth-page__field--error" : ""}`}>
<span>
@@ -955,13 +1060,13 @@ function ProfilePage({
</label>
{mode === "login" ? (
<div className="auth-page__forgot">
<button type="button"></button>
<button type="button" onClick={() => { setShowForgotPassword(true); setForgotStep("email"); }}></button>
</div>
) : null}
</>
) : null}
{authTab === "email" ? (
{!showForgotPassword && authTab === "email" ? (
<>
{mode === "register" ? (
<label className="auth-page__field">
@@ -1017,7 +1122,7 @@ function ProfilePage({
</>
) : null}
{authTab === "phone" ? (
{!showForgotPassword && authTab === "phone" ? (
<>
<label className={`auth-page__field${fieldErrors.phone ? " auth-page__field--error" : ""}`}>
<span>
@@ -1068,9 +1173,11 @@ function ProfilePage({
</>
) : null}
{notice ? <p className="auth-page__notice">{notice}</p> : null}
{!showForgotPassword ? (
<>
{notice ? <p className="auth-page__notice">{notice}</p> : null}
<button type="submit" className="auth-page__submit" disabled={isSubmitting}>
<button type="submit" className="auth-page__submit" disabled={isSubmitting}>
{isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"}
</button>
@@ -1090,6 +1197,8 @@ function ProfilePage({
<MobileOutlined />
</button>
</div>
</>
) : null}
</form>
</div>
</aside>
+143 -10
View File
@@ -1,8 +1,11 @@
import {
BarChartOutlined,
CheckCircleFilled,
CopyOutlined,
DownloadOutlined,
FileTextOutlined,
LoadingOutlined,
ThunderboltOutlined,
UploadOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
@@ -33,6 +36,8 @@ interface HistoryEntry {
timestamp: number;
score: number;
grade: string;
script?: string;
result?: EvalResult;
}
function getGrade(score: number): string {
@@ -54,6 +59,8 @@ const TEXT_FILE_EXTENSIONS = [
".fountain",
".fdx",
".rtf",
".docx",
".doc",
".csv",
".tsv",
".json",
@@ -99,7 +106,7 @@ const TEXT_FILE_EXTENSIONS = [
] as const;
const TEXT_FILE_EXTENSION_SET = new Set<string>(TEXT_FILE_EXTENSIONS);
const TEXT_FILE_ACCEPT = TEXT_FILE_EXTENSIONS.join(",");
const TEXT_FILE_HINT = "支持常见文本格式:TXT / MD / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等";
const TEXT_FILE_HINT = "支持常见文本格式:TXT / MD / DOCX / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等";
function loadHistory(): HistoryEntry[] {
try {
@@ -168,6 +175,68 @@ function normalizeUploadedText(raw: string, ext: string): string {
return raw;
}
async function extractDocxText(bytes: Uint8Array): Promise<string> {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const entries: Array<{ name: string; offset: number; size: number; compressed: boolean }> = [];
let pos = 0;
while (pos < bytes.length - 30) {
if (view.getUint32(pos, true) !== 0x04034b50) break;
const compressed = view.getUint16(pos + 10, true) !== 0;
const compressedSize = view.getUint32(pos + 18, true);
const fileNameLen = view.getUint16(pos + 26, true);
const extraLen = view.getUint16(pos + 28, true);
const name = new TextDecoder().decode(bytes.slice(pos + 30, pos + 30 + fileNameLen));
const dataStart = pos + 30 + fileNameLen + extraLen;
entries.push({ name, offset: dataStart, size: compressedSize, compressed });
pos = dataStart + compressedSize;
}
const docEntry = entries.find((e) => e.name === "word/document.xml");
if (!docEntry) return "";
const xmlBytes = bytes.slice(docEntry.offset, docEntry.offset + docEntry.size);
let xmlText: string;
if (docEntry.compressed) {
try {
const ds = new DecompressionStream("deflate-raw");
const writer = ds.writable.getWriter();
writer.write(xmlBytes);
writer.close();
const reader = ds.readable.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
const combined = new Uint8Array(totalLen);
let offset = 0;
for (const c of chunks) { combined.set(c, offset); offset += c.length; }
xmlText = new TextDecoder().decode(combined);
} catch {
xmlText = new TextDecoder().decode(xmlBytes);
}
} else {
xmlText = new TextDecoder().decode(xmlBytes);
}
const textMatches = xmlText.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
if (!textMatches) return "";
const paraMatches = xmlText.match(/<w:p[ >][\s\S]*?<\/w:p>/g);
if (paraMatches) {
return paraMatches.map((p) => {
const tMatches = p.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
if (!tMatches) return "";
return tMatches.map((m) => m.replace(/<[^>]+>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, "\"")).join("");
}).filter(Boolean).join("\n").trim();
}
return "";
}
function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
const SCORE_DIMENSIONS: ScoreDimension[] = [
{ key: "hook", label: "钩子设计", maxScore: 20, hint: "开篇吸引力·悬念设置·黄金三秒", detail: "开篇即抛出高概念钩子,悬念设置紧凑有力。" },
{ key: "character", label: "角色塑造", maxScore: 15, hint: "人物立体度·动机合理性·弧光设计", detail: "主角动机有铺垫,配角功能性较强,人物弧光尚可进一步深化。" },
@@ -222,6 +291,7 @@ function ScriptTokensPage() {
const [copied, setCopied] = useState(false);
const [activeDim, setActiveDim] = useState<number | null>(null);
const [animatedScore, setAnimatedScore] = useState(0);
const [activeHistoryIndex, setActiveHistoryIndex] = useState<number>(0);
const [history, setHistory] = useState<HistoryEntry[]>(loadHistory);
const fileInputRef = useRef<HTMLInputElement>(null);
const scoreFrameRef = useRef<number | null>(null);
@@ -251,7 +321,23 @@ function ScriptTokensPage() {
const ext = getFileExtension(file.name);
const readable = isReadableTextFile(file, ext);
setUploadedFile({ name: file.name, size: file.size });
if (readable) {
if (ext === ".docx") {
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const text = await extractDocxText(bytes);
if (text) {
setScript(text);
} else {
setScript(`[已上传文件:${file.name}]\n\n无法从 DOCX 文件中提取文本,请尝试另存为 TXT 格式后重新上传。`);
}
} catch {
setScript(`[已上传文件:${file.name}]\n\n解析 DOCX 文件失败,请尝试另存为 TXT 格式后重新上传。`);
}
} else if (ext === ".doc") {
const text = await decodeTextFile(file);
const cleaned = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/\s{3,}/g, "\n\n").trim();
setScript(cleaned || `[已上传文件:${file.name}]\n\n无法从 .doc 文件中提取文本,请另存为 .docx 或 .txt 格式。`);
} else if (readable) {
const text = normalizeUploadedText(await decodeTextFile(file), ext);
setScript(text);
} else {
@@ -277,6 +363,8 @@ function ScriptTokensPage() {
timestamp: Date.now(),
score: aiResult.totalScore,
grade: g,
script,
result: aiResult,
};
const updated = [entry, ...loadHistory().filter((h) => h.name !== entry.name || h.score !== entry.score)].sort(
(a, b) => b.timestamp - a.timestamp,
@@ -289,6 +377,20 @@ function ScriptTokensPage() {
setLoading(false);
};
const handleHistoryClick = (item: HistoryEntry, index: number) => {
setActiveHistoryIndex(index);
if (item.script) {
setScript(item.script);
setUploadedFile({ name: `${item.name}.txt`, size: item.script.length });
}
if (item.result) {
setResult(item.result);
} else {
setResult(null);
}
setEvalError(null);
};
const handleReset = () => {
setScript("");
setResult(null);
@@ -346,9 +448,10 @@ function ScriptTokensPage() {
const compactTitle = uploadedFile?.name?.replace(/\.[^.]+$/, "") ?? "剧本评测";
const scriptMinutes = Math.max(8, Math.round(script.length / 460));
const reportDate = new Date().toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" });
const statusClass = loading ? "is-loading" : result ? "is-complete" : hasContent ? "is-ready" : "is-idle";
return (
<section className="script-eval-v5 page-motion">
<section className={`script-eval-v5 page-motion ${statusClass}`}>
<div className="script-eval-v5-page">
{/* Left Panel */}
<aside className="script-eval-v5-left">
@@ -364,7 +467,10 @@ function ScriptTokensPage() {
{uploadedFile ? (
<div className="script-eval-v5-upload-done is-show">
<CheckCircleFilled />
<span className="script-eval-v5-uf-name">{uploadedFile.name}</span>
<span className="script-eval-v5-uf-meta">
<span className="script-eval-v5-uf-name">{uploadedFile.name}</span>
<span className="script-eval-v5-uf-size">{formatFileSize(uploadedFile.size)}</span>
</span>
<span className="script-eval-v5-uf-re" onClick={(e) => { e.stopPropagation(); handleReset(); }}>
</span>
@@ -374,7 +480,7 @@ function ScriptTokensPage() {
<div className="script-eval-v5-upload-icon"><UploadOutlined /></div>
<div className="script-eval-v5-upload-text"></div>
<button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}>
+
<UploadOutlined />
</button>
<div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div>
</>
@@ -420,7 +526,9 @@ function ScriptTokensPage() {
<div className="script-eval-v5-history-empty"></div>
) : (
history.map((item, i) => (
<div key={i} className={`script-eval-v5-history-item${i === 0 ? " is-active" : ""}`}>
<div key={i} className={`script-eval-v5-history-item${i === activeHistoryIndex ? " is-active" : ""}`}
onClick={() => handleHistoryClick(item, i)} role="button" tabIndex={0}
onKeyDown={(e) => { if ((e as React.KeyboardEvent).key === "Enter") handleHistoryClick(item, i); }}>
<div className="script-eval-v5-hi-left">
<div className="script-eval-v5-hi-name">{item.name}</div>
<div className="script-eval-v5-hi-date">{item.date}</div>
@@ -445,10 +553,12 @@ function ScriptTokensPage() {
disabled={loading || !hasContent}
onClick={() => void handleEvaluate()}
>
{loading ? "◆ 评测中..." : "◆ 开始评测"}
{loading ? <LoadingOutlined /> : <ThunderboltOutlined />}
<span>{loading ? "评测中..." : "开始评测"}</span>
</button>
<button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}>
<DownloadOutlined />
<span></span>
</button>
</div>
</aside>
@@ -482,6 +592,11 @@ function ScriptTokensPage() {
<div className="page-loading-spinner" />
<strong>AI ...</strong>
<p> 15-30 </p>
<div className="script-eval-v5-loading-steps" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
@@ -568,13 +683,23 @@ function ScriptTokensPage() {
<span>0%</span>
</div>
<div className="script-eval-report__chart-grid">
{SCORE_DIMENSIONS.map((dim) => {
{SCORE_DIMENSIONS.map((dim, dimIndex) => {
const score = result.dimensionScores[dim.key] ?? 0;
const pct = Math.max(0, Math.min(1, score / dim.maxScore));
const lossPct = 1 - pct;
const isPerfect = score === dim.maxScore;
const isActive = activeDim === null || activeDim === dimIndex;
return (
<button key={dim.key} type="button" className="script-eval-report__bar-col">
<button
key={dim.key}
type="button"
className={`script-eval-report__bar-col${isActive ? "" : " is-dimmed"}`}
onMouseEnter={() => setActiveDim(dimIndex)}
onFocus={() => setActiveDim(dimIndex)}
onMouseLeave={() => setActiveDim(null)}
onBlur={() => setActiveDim(null)}
aria-label={`${dim.label} ${score}/${dim.maxScore}${dim.hint}`}
>
<div className="script-eval-report__bar-score">
<b>{score}</b><small>/{dim.maxScore}</small>{isPerfect ? <em>*</em> : null}
</div>
@@ -589,6 +714,14 @@ function ScriptTokensPage() {
})}
</div>
</div>
<div className="script-eval-report__chart-note">
<BarChartOutlined />
<span>
{activeDim === null
? "悬停维度可查看当前分项表现,优先从低分项制定改稿计划。"
: `${SCORE_DIMENSIONS[activeDim].label}${SCORE_DIMENSIONS[activeDim].detail}`}
</span>
</div>
</section>
<div className="script-eval-report__findings">
+29 -18
View File
@@ -6,7 +6,6 @@ import {
LineChartOutlined,
ReloadOutlined,
RightOutlined,
SettingOutlined,
TeamOutlined,
UserOutlined,
WarningOutlined,
@@ -149,10 +148,10 @@ function TokenUsagePage({
const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise");
const refreshEnterpriseUsage = useCallback(async () => {
if (!session) return;
const loader = isEnterpriseAdmin ? loadEnterpriseUsage : loadPersonalUsage;
if (!loader) {
setEnterpriseUsage(null);
setEnterpriseUsageError(null);
return;
}
setEnterpriseUsageLoading(true);
@@ -161,11 +160,11 @@ function TokenUsagePage({
setEnterpriseUsage(await loader());
} catch (error) {
setEnterpriseUsage(null);
setEnterpriseUsageError(error instanceof Error ? error.message : "用量数据暂时不可用");
setEnterpriseUsageError(error instanceof Error ? error.message : "加载失败");
} finally {
setEnterpriseUsageLoading(false);
}
}, [isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
}, [session, isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
useEffect(() => {
void refreshEnterpriseUsage();
@@ -230,45 +229,51 @@ function TokenUsagePage({
{ label: "账户类型", value: isEnterpriseAccount ? "企业账户" : "个人账户", tone: "good" },
{ label: "企业空间", value: enterpriseUsage?.enterpriseName || session?.user.enterpriseName || "-" },
];
const pageStatusClass = enterpriseUsageLoading
? "is-syncing"
: enterpriseUsageError
? "has-sync-error"
: isLowBalance
? "has-low-balance"
: "is-healthy";
return (
<section className="script-token-page token-usage-page management-center-page" aria-label="管理中心">
<section className={`script-token-page token-usage-page management-center-page ${pageStatusClass}`} aria-label="管理中心">
<main className="management-center-shell">
<header className="management-center-toolbar" aria-label="管理中心操作">
<div className="management-center-toolbar__title">
<button type="button" className="management-center-toolbar__back" aria-label="返回工具盒" onClick={onOpenMore}>
<ArrowLeftOutlined />
</button>
<strong></strong>
<span>
<strong></strong>
<small></small>
</span>
</div>
<span className="management-center-status-pill">
<span className={`management-center-status-pill ${enterpriseUsageError ? "is-error" : enterpriseUsageLoading ? "is-loading" : "is-online"}`}>
{enterpriseUsageLoading ? "正在同步企业用量" : enterpriseUsageError || "服务器已连接"}
</span>
<button type="button" onClick={refreshEnterpriseUsage}>
<button type="button" onClick={refreshEnterpriseUsage} disabled={enterpriseUsageLoading}>
<ReloadOutlined />
</button>
<button type="button">
<button type="button" className="is-muted-action">
<UserOutlined />
</button>
<button type="button" className="is-primary" onClick={() => onSelectView?.("settings")}>
<SettingOutlined />
</button>
</header>
{isLowBalance ? (
<div className="management-balance-alert" role="alert">
<WarningOutlined />
<span> {formatCredits(availableBalanceCents)}</span>
<button type="button" onClick={() => onSelectView?.("settings")}></button>
</div>
) : null}
<section className="management-metric-cards" aria-label="关键指标">
{metricCards.map((card) => (
{metricCards.map((card, index) => (
<article key={card.key} className={`management-metric-card is-${card.tone}`}>
<span className="management-metric-card__index">{String(index + 1).padStart(2, "0")}</span>
<span className="management-metric-card__label">{card.label}</span>
<strong className="management-metric-card__value">{card.value}</strong>
<span className="management-metric-card__hint">{card.hint}</span>
@@ -283,7 +288,7 @@ function TokenUsagePage({
<BarChartOutlined />
</h2>
<span>{modelBreakdown.length ? `${modelBreakdown.length} 个模型` : "LIVE"}</span>
<span>{enterpriseUsageLoading ? "SYNC" : modelBreakdown.length ? `${modelBreakdown.length} 个模型` : "LIVE"}</span>
</div>
{modelBreakdown.length ? (
<div className="management-model-list">
@@ -310,7 +315,10 @@ function TokenUsagePage({
<article className="management-card management-status-card">
<div className="management-card__head">
<h2></h2>
<h2>
<LineChartOutlined />
</h2>
</div>
<dl>
{systemStatus.map((item) => (
@@ -364,7 +372,10 @@ function TokenUsagePage({
<section className="management-card management-records">
<div className="management-card__head">
<h2></h2>
<h2>
<BarChartOutlined />
</h2>
<span>{records.length} </span>
</div>
<div className="management-record-table" role="table" aria-label="调用记录">
@@ -356,13 +356,13 @@ function WatermarkRemovalPage({
</p>
</section>
<div className="image-workbench-actions">
<div className="image-workbench-actions watermark-removal-actions">
<button type="button" className="image-workbench-primary" onClick={() => void handleStart()} disabled={isProcessing}>
<DeleteOutlined />
{isProcessing ? "处理中" : "开始去水印"}
{isProcessing ? "处理中..." : "开始去水印"}
</button>
{isProcessing && (
<button type="button" className="image-workbench-cancel" onClick={handleCancel} style={{ marginTop: 6 }}>
<button type="button" className="image-workbench-cancel" onClick={handleCancel}>
</button>
)}
+10 -38
View File
@@ -41,6 +41,7 @@ import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUp
import { assetClient } from "../../api/assetClient";
import { communityClient } from "../../api/communityClient";
import { RechargeModal } from "../../components/RechargeModal/RechargeModal";
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
@@ -236,9 +237,9 @@ function WorkbenchPage({
const keepaliveTasksRef = useRef<Record<string, WorkbenchKeepaliveTask>>(readStoredKeepaliveTasks());
const taskAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
const lastScrollTopRef = useRef(0);
const scrollActionsHideTimerRef = useRef<number | null>(null);
const shouldFollowNewMessagesRef = useRef(true);
const pendingScrollToLatestRef = useRef(true);
const genTracker = useGenerationTasks({ sourceView: "workbench" });
const renderedMessageIdsRef = useRef<string[]>([]);
const hasHandledInitialMessagesRef = useRef(false);
@@ -274,8 +275,6 @@ function WorkbenchPage({
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
const [mentionActiveIndex, setMentionActiveIndex] = useState(0);
const [composerHidden, setComposerHidden] = useState(false);
const [scrollActionsVisible, setScrollActionsVisible] = useState(false);
const [scrollActionDirection, setScrollActionDirection] = useState<"top" | "bottom" | null>(null);
const [workspaceStarted, setWorkspaceStarted] = useState(false);
useEffect(() => {
@@ -444,27 +443,6 @@ function WorkbenchPage({
"--accent-glow": `0 0 24px rgba(${accentRgb}, 0.22)`,
} as CSSProperties;
const revealScrollActionsTemporarily = useCallback((direction: "top" | "bottom") => {
setScrollActionDirection(direction);
setScrollActionsVisible(true);
if (scrollActionsHideTimerRef.current !== null) {
window.clearTimeout(scrollActionsHideTimerRef.current);
}
scrollActionsHideTimerRef.current = window.setTimeout(() => {
setScrollActionsVisible(false);
setScrollActionDirection(null);
scrollActionsHideTimerRef.current = null;
}, 950);
}, []);
useEffect(() => {
return () => {
if (scrollActionsHideTimerRef.current !== null) {
window.clearTimeout(scrollActionsHideTimerRef.current);
}
};
}, []);
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior = "smooth") => {
const scroll = () => {
const surface = messagesSurfaceRef.current;
@@ -475,7 +453,6 @@ function WorkbenchPage({
setComposerHidden(false);
shouldFollowNewMessagesRef.current = true;
revealScrollActionsTemporarily("bottom");
surface.scrollTo({ top: surface.scrollHeight, behavior });
lastScrollTopRef.current = surface.scrollTop;
};
@@ -484,7 +461,7 @@ function WorkbenchPage({
scroll();
window.setTimeout(scroll, 80);
});
}, [revealScrollActionsTemporarily]);
}, []);
const imageSettingGroups = useMemo<WorkbenchFieldGroup[]>(
() => [
@@ -1398,9 +1375,6 @@ function WorkbenchPage({
const delta = top - lastScrollTopRef.current;
const atTop = top <= edgeThreshold;
const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold;
if (surface.scrollHeight > surface.clientHeight + edgeThreshold && Math.abs(delta) > 1) {
revealScrollActionsTemporarily(delta > 0 ? "bottom" : "top");
}
shouldFollowNewMessagesRef.current = atBottom;
if (atTop || atBottom) {
setComposerHidden(false);
@@ -1412,7 +1386,7 @@ function WorkbenchPage({
surface.addEventListener("scroll", handleScroll, { passive: true });
return () => surface.removeEventListener("scroll", handleScroll);
}, [hasActivatedWorkspace, revealScrollActionsTemporarily]);
}, [hasActivatedWorkspace]);
const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => {
const surface = messagesSurfaceRef.current;
@@ -1420,9 +1394,8 @@ function WorkbenchPage({
const top = direction === "top" ? 0 : surface.scrollHeight;
setComposerHidden(false);
revealScrollActionsTemporarily(direction);
surface.scrollTo({ top, behavior: "smooth" });
}, [revealScrollActionsTemporarily]);
}, []);
const closeToolbarMenus = () => setToolbarMenuId(null);
const toggleToolbarMenu = (menuId: Exclude<ToolbarMenuId, null>) => {
@@ -1880,6 +1853,7 @@ function WorkbenchPage({
referenceUrls: refUrls.length ? refUrls : undefined,
});
taskId = result.taskId;
genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "image", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
} else {
let requestModel = resolveVideoRequestModel({
model: taskInput.params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL,
@@ -1899,6 +1873,7 @@ function WorkbenchPage({
hasReferenceVideo: requestReferenceItems.some((item) => item.kind === "video"),
});
taskId = result.taskId;
genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "video", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
}
onRefreshUsage?.();
@@ -3051,13 +3026,10 @@ function WorkbenchPage({
{renderComposerToolbar(false, isGenerating)}
</div>
</section>
<div
className={`wb-chat-scroll-actions${scrollActionsVisible ? " is-visible" : ""}${scrollActionDirection ? ` is-${scrollActionDirection}` : ""}`}
aria-label="聊天滚动"
>
<div className="wb-chat-scroll-actions" aria-label="聊天滚动">
<button
type="button"
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--top"
className="wb-chat-scroll-actions__button"
title="返回聊天顶部"
aria-label="返回聊天顶部"
onClick={() => scrollMessagesSurface("top")}
@@ -3066,7 +3038,7 @@ function WorkbenchPage({
</button>
<button
type="button"
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--bottom"
className="wb-chat-scroll-actions__button"
title="到达聊天底部"
aria-label="到达聊天底部"
onClick={() => scrollMessagesSurface("bottom")}
+41
View File
@@ -0,0 +1,41 @@
import { useCallback, useRef, useState } from "react";
export type GenStatus = "idle" | "ready" | "generating" | "done" | "failed";
export interface UseGenerationStatusReturn {
status: GenStatus;
error: string | null;
abortRef: { current: boolean };
start: () => void;
succeed: () => void;
fail: (msg: string) => void;
reset: () => void;
cancel: () => void;
isGenerating: boolean;
isFailed: boolean;
isIdle: boolean;
}
export function useGenerationStatus(): UseGenerationStatusReturn {
const [status, setStatus] = useState<GenStatus>("idle");
const [error, setError] = useState<string | null>(null);
const abortRef = useRef({ current: false });
const start = useCallback(() => {
setStatus("generating");
setError(null);
abortRef.current = { current: false };
}, []);
const succeed = useCallback(() => setStatus("done"), []);
const fail = useCallback((msg: string) => { setStatus("failed"); setError(msg); }, []);
const reset = useCallback(() => { setStatus("idle"); setError(null); }, []);
const cancel = useCallback(() => { abortRef.current.current = true; }, []);
return {
status, error, abortRef, start, succeed, fail, reset, cancel,
isGenerating: status === "generating",
isFailed: status === "failed",
isIdle: status === "idle",
};
}
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useMemo, useRef, useCallback } from "react";
import type { GenerationQueueItem } from "../stores/useGenerationStore";
import { useGenerationStore } from "../stores/useGenerationStore";
import {
startBackgroundPolling,
subscribeToTaskUpdates,
} from "../services/backgroundTaskRunner";
interface UseGenerationTasksOptions {
sourceView: string;
autoResume?: boolean;
}
export function useGenerationTasks(options: UseGenerationTasksOptions) {
const { sourceView, autoResume = true } = options;
const store = useGenerationStore();
const pollingStartedRef = useRef(false);
// ── Auto-resume: re-subscribe to running tasks on mount ────
useEffect(() => {
if (!autoResume || pollingStartedRef.current) return;
pollingStartedRef.current = true;
const active = store.getRunningTasks().filter((t) => t.sourceView === sourceView);
if (active.length > 0) {
startBackgroundPolling();
}
return () => {
pollingStartedRef.current = false;
};
}, [autoResume, sourceView, store]);
// ── Subscribe to live updates ───────────────────────────
useEffect(() => {
return subscribeToTaskUpdates((updated) => {
store.updateTask(updated.id, updated);
});
}, [store]);
// ── View-scoped computed lists ──────────────────────────
const myTasks = useMemo(
() => store.queue.filter((t) => t.sourceView === sourceView),
[store.queue, sourceView],
);
const activeTasks = useMemo(
() => myTasks.filter((t) => t.status === "running" || t.status === "pending"),
[myTasks],
);
const completedTasks = useMemo(
() => myTasks.filter((t) => t.status === "completed"),
[myTasks],
);
const failedTasks = useMemo(
() => myTasks.filter((t) => t.status === "failed"),
[myTasks],
);
// ── Actions ─────────────────────────────────────────────
const submitTask = useCallback(
(task: Omit<GenerationQueueItem, "id" | "createdAt">) => {
const id = `gen-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
store.addTask({ ...task, id, createdAt: Date.now() });
return id;
},
[store],
);
const updateTask = useCallback(
(id: string, patch: Partial<GenerationQueueItem>) => {
store.updateTask(id, patch);
},
[store],
);
const markCompleted = useCallback(
(id: string, resultUrl: string) => {
store.updateTask(id, { status: "completed", progress: 100, resultUrl });
},
[store],
);
const markFailed = useCallback(
(id: string, error: string) => {
store.updateTask(id, { status: "failed", error });
},
[store],
);
const retryTask = useCallback(
(id: string) => {
const task = store.queue.find((t) => t.id === id);
if (task) {
store.updateTask(id, { status: "pending", progress: 0, error: null });
}
},
[store],
);
return {
tasks: myTasks,
activeTasks,
completedTasks,
failedTasks,
submitTask,
updateTask,
markCompleted,
markFailed,
retryTask,
hasActiveTasks: activeTasks.length > 0,
};
}
+128
View File
@@ -0,0 +1,128 @@
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
import { aiGenerationClient } from "../api/aiGenerationClient";
type PollCallback = (item: GenerationQueueItem) => void;
const activePollers = new Map<string, ReturnType<typeof setInterval>>();
const pollCallbacks = new Set<PollCallback>();
const POLL_INTERVAL = 3000;
const MAX_POLL_ATTEMPTS = 200; // 10 minutes max per task
export function subscribeToTaskUpdates(callback: PollCallback): () => void {
pollCallbacks.add(callback);
return () => { pollCallbacks.delete(callback); };
}
function notifyCallbacks(item: GenerationQueueItem): void {
pollCallbacks.forEach((cb) => cb(item));
}
function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void {
const key = `poll-${item.id}`;
if (activePollers.has(key)) return;
const interval = setInterval(async () => {
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") {
cleanupPoll(key);
return;
}
attemptsRef.current++;
if (attemptsRef.current > MAX_POLL_ATTEMPTS) {
useGenerationStore.getState().updateTask(item.id, {
status: "failed",
error: "任务超时,请重新提交",
});
notifyCallbacks({ ...item, status: "failed", error: "任务超时,请重新提交" });
cleanupPoll(key);
return;
}
try {
const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || "");
const patch: Partial<GenerationQueueItem> = {
progress: status.progress,
resultUrl: status.resultUrl || current.resultUrl,
error: status.error || current.error,
};
if (status.status === "completed") {
patch.status = "completed";
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "completed" });
cleanupPoll(key);
} else if (status.status === "failed" || status.status === "cancelled") {
patch.status = "failed";
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "failed" });
cleanupPoll(key);
} else {
patch.status = "running";
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "running" });
}
} catch {
// Network error during poll — keep trying
}
}, POLL_INTERVAL);
activePollers.set(key, interval);
}
function cleanupPoll(key: string): void {
const interval = activePollers.get(key);
if (interval) {
clearInterval(interval);
activePollers.delete(key);
}
}
export function startBackgroundPolling(): void {
const tasks = useGenerationStore.getState().getRunningTasks();
const attemptsMap = new Map<string, { current: number }>();
tasks.forEach((task) => {
if (task.taskId) {
if (!attemptsMap.has(task.id)) {
attemptsMap.set(task.id, { current: 0 });
}
pollTask(task, attemptsMap.get(task.id)!);
}
});
}
export function resumeTaskPolling(taskId: string, storeId: string): void {
const task = useGenerationStore.getState().queue.find((i) => i.id === storeId);
if (task && task.status !== "completed" && task.status !== "failed") {
pollTask(task, { current: 0 });
}
}
export function stopAllPolling(): void {
activePollers.forEach((interval) => clearInterval(interval));
activePollers.clear();
}
// ── Recovery on page load ──────────────────────────
export function recoverAndResumeTasks(): void {
const pendingTasks = useGenerationStore.getState().getRunningTasks();
if (!pendingTasks.length) return;
pendingTasks.forEach((task) => {
if (task.taskId) {
// Mark as pending so the workbench/ecommerce can re-submit to polling
useGenerationStore.getState().updateTask(task.id, { status: "pending" });
} else {
// No taskId means it was queued but never submitted — mark failed
useGenerationStore.getState().updateTask(task.id, {
status: "failed",
error: "页面刷新后任务丢失,请重新提交",
});
}
});
// Start polling recovered tasks
setTimeout(() => startBackgroundPolling(), 500);
}
+2
View File
@@ -3,6 +3,8 @@ export { useSessionStore } from './useSessionStore';
export { useProjectStore } from './useProjectStore';
export { useTaskStore } from './useTaskStore';
export { useAppStore } from './useAppStore';
export { useGenerationStore } from './useGenerationStore';
export type { GenerationQueueItem, QueueItemStatus } from './useGenerationStore';
// Type exports
export type { PendingAction } from './useSessionStore';
+121
View File
@@ -0,0 +1,121 @@
import { create } from "zustand";
import type { WebGenerationPreviewTask } from "../types";
export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
export interface GenerationQueueItem {
id: string;
taskId?: string;
title: string;
type: "image" | "video" | "agent" | "digital-human" | "character-mix" | "ecommerce-video";
status: QueueItemStatus;
progress: number;
prompt: string;
createdAt: number;
sourceView: string; // which page created this: "ecommerce", "workbench", "canvas", "agent"
resultUrl?: string | null;
error?: string | null;
params?: Record<string, unknown>;
}
interface PersistedQueueSnapshot {
version: 1;
items: GenerationQueueItem[];
savedAt: number;
}
const STORAGE_KEY = "omniai:generation-queue";
const MAX_ITEMS = 80;
const STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
function loadPersistedQueue(): GenerationQueueItem[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const snapshot = JSON.parse(raw) as PersistedQueueSnapshot;
if (Date.now() - (snapshot.savedAt || 0) > STALE_MS) {
localStorage.removeItem(STORAGE_KEY);
return [];
}
return snapshot.items.filter(
(item) => item.status === "pending" || item.status === "running",
);
} catch {
return [];
}
}
function persistQueue(items: GenerationQueueItem[]): void {
try {
const snapshot: PersistedQueueSnapshot = { version: 1, items, savedAt: Date.now() };
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch { /* quota exceeded */ }
}
interface GenerationStoreState {
queue: GenerationQueueItem[];
addTask: (item: GenerationQueueItem) => void;
updateTask: (id: string, patch: Partial<GenerationQueueItem>) => void;
removeTask: (id: string) => void;
getRunningTasks: () => GenerationQueueItem[];
getPendingTasks: () => GenerationQueueItem[];
getTasksByView: (sourceView: string) => GenerationQueueItem[];
clearTerminal: () => void;
}
function hashUserId(): string {
try {
const raw = localStorage.getItem("omniai-web-session");
if (!raw) return "anon";
const parsed = JSON.parse(raw) as { user?: { id?: number | string } };
return String(parsed?.user?.id || "anon");
} catch {
return "anon";
}
}
const initialQueue = loadPersistedQueue();
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
queue: initialQueue,
addTask: (item) => {
set((state) => {
const next = [item, ...state.queue].slice(0, MAX_ITEMS);
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
return { queue: next };
});
},
updateTask: (id, patch) => {
set((state) => {
const next = state.queue.map((item) =>
item.id === id ? { ...item, ...patch } : item,
);
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
return { queue: next };
});
},
removeTask: (id) => {
set((state) => {
const next = state.queue.filter((item) => item.id !== id);
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
return { queue: next };
});
},
getRunningTasks: () => get().queue.filter((i) => i.status === "running" || i.status === "pending"),
getPendingTasks: () => get().queue.filter((i) => i.status === "pending"),
getTasksByView: (sourceView) => get().queue.filter((i) => i.sourceView === sourceView),
clearTerminal: () => {
set((state) => {
const next = state.queue.filter(
(i) => i.status === "pending" || i.status === "running",
);
persistQueue(next);
return { queue: next };
});
},
}));
+7
View File
@@ -65,6 +65,13 @@
min-height: 0;
}
/* Collapse when empty (e.g. KeepAlive pages rendered outside PageTransition) */
.page-transition-wrap:empty {
height: 0;
min-height: 0;
overflow: hidden;
}
/* page-motion--exit moved to page-transition.css */
.page-loading-center {
+102
View File
@@ -365,11 +365,113 @@
line-height: 1.55;
}
.recharge-modal__checkout {
display: grid;
gap: 14px;
border: 1px solid rgba(var(--accent-rgb), 0.26);
border-radius: 14px;
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.12), rgba(var(--accent-rgb), 0.05));
padding: 18px;
}
.recharge-modal__checkout-eyebrow {
color: var(--accent, #34d399);
font-size: 12px;
font-weight: 900;
}
.recharge-modal__checkout h3,
.recharge-modal__checkout p {
margin: 0;
}
.recharge-modal__checkout h3 {
margin-top: 4px;
color: var(--fg-body, #edf2f7);
font-size: 18px;
}
.recharge-modal__checkout p {
color: var(--fg-muted, #9ba7b7);
font-size: 13px;
line-height: 1.6;
}
.recharge-modal__payment-methods {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.recharge-modal__payment-methods button {
display: grid;
gap: 5px;
min-height: 68px;
padding: 12px;
border: 1px solid var(--border-subtle, rgb(255 255 255 / 10%));
border-radius: 12px;
background: var(--bg-inset, rgb(0 0 0 / 18%));
color: var(--fg-body, #edf2f7);
cursor: pointer;
text-align: left;
}
.recharge-modal__payment-methods button.is-active {
border-color: rgba(var(--accent-rgb), 0.56);
background: rgba(var(--accent-rgb), 0.14);
}
.recharge-modal__payment-methods span {
color: var(--fg-muted, #9ba7b7);
font-size: 12px;
}
.recharge-modal__pay {
min-height: 42px;
border: 0;
border-radius: 12px;
background: var(--accent, #34d399);
color: #07110d;
cursor: pointer;
font-weight: 950;
}
.recharge-modal__pay:disabled {
cursor: wait;
opacity: 0.7;
}
.recharge-modal__order {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid var(--border-subtle, rgb(255 255 255 / 10%));
border-radius: 12px;
background: var(--bg-inset, rgb(0 0 0 / 18%));
color: var(--fg-muted, #9ba7b7);
font-size: 13px;
}
.recharge-modal__order strong,
.recharge-modal__order a {
color: var(--accent, #34d399);
}
.recharge-modal__order img {
width: 160px;
max-width: 100%;
border-radius: 10px;
}
@media (max-width: 980px) {
.recharge-modal__grid[data-audience="personal"],
.recharge-modal__grid[data-audience="enterprise"] {
grid-template-columns: 1fr;
}
.recharge-modal__payment-methods {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
+1
View File
@@ -29,6 +29,7 @@
@import "./pages/compliance.css";
@import "./pages/provider-health.css";
@import "./pages/legacy-pages.css";
@import "./pages/not-found.css";
@import "./components/recharge-modal.css";
@import "./components/dropzone.css";
@import "./components/skeleton.css";
+34
View File
@@ -189,6 +189,40 @@
cursor: zoom-in;
}
.asset-card-wrapper {
position: relative;
display: inline-block;
}
.asset-card__delete {
position: absolute;
top: 6px;
right: 6px;
z-index: 2;
display: none;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 6px;
background: var(--bg-panel);
color: var(--fg-muted);
cursor: pointer;
font-size: 14px;
opacity: 0.85;
transition: opacity 150ms, color 150ms;
}
.asset-card-wrapper:hover .asset-card__delete {
display: flex;
}
.asset-card__delete:hover {
opacity: 1;
color: var(--fg-danger);
}
@media (max-width: 720px) {
.asset-preview-modal {
padding: 14px;
+107 -1
View File
@@ -725,9 +725,110 @@
font-size: 42px;
}
.compliance-page {
min-height: 100%;
background: #0d0d0f;
color: var(--fg-body);
}
.compliance-page__inner {
width: min(940px, calc(100% - 48px));
margin: 0 auto;
padding: 40px 0 56px;
}
.compliance-hero {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 18px;
}
.compliance-hero__icon {
display: grid;
place-items: center;
flex: 0 0 54px;
width: 54px;
height: 54px;
border: 1px solid rgba(var(--accent-rgb), 0.28);
border-radius: 16px;
background: rgba(var(--accent-rgb), 0.12);
color: var(--accent);
font-size: 24px;
}
.compliance-hero__eyebrow {
color: var(--accent);
font-size: 12px;
font-weight: 900;
}
.compliance-hero h1 {
margin: 4px 0 8px;
font-size: clamp(26px, 4vw, 38px);
}
.compliance-hero p,
.compliance-section p,
.compliance-contact span {
color: var(--fg-muted);
line-height: 1.7;
}
.compliance-card,
.compliance-contact {
border: 1px solid var(--border-subtle);
border-radius: 18px;
background: var(--bg-panel);
box-shadow: var(--shadow-tight);
}
.compliance-card {
display: grid;
overflow: hidden;
}
.compliance-section {
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
gap: 16px;
padding: 22px;
border-bottom: 1px solid var(--border-weak);
}
.compliance-section:last-child {
border-bottom: 0;
}
.compliance-section > span {
color: var(--accent);
font-size: 13px;
font-weight: 950;
}
.compliance-section h2,
.compliance-section p {
margin: 0;
}
.compliance-section h2 {
margin-bottom: 8px;
font-size: 18px;
}
.compliance-contact {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
margin-top: 16px;
padding: 16px 18px;
font-size: 13px;
}
@media (max-width: 900px) {
.community-review-page__inner,
.report-page__inner {
.report-page__inner,
.compliance-page__inner {
width: min(100% - 28px, 720px);
padding-top: 24px;
}
@@ -786,4 +887,9 @@
display: grid;
grid-template-columns: 1fr 1fr;
}
.compliance-hero,
.compliance-section {
grid-template-columns: 1fr;
}
}
+389 -7
View File
@@ -50,7 +50,7 @@
}
.ecom-video-flowbar__pulse.is-active {
background: #34d399;
background: #00ff88;
}
.ecom-video-flowbar__wave {
@@ -97,7 +97,7 @@
}
.ecom-video-step-dot.is-done {
background: #34d399;
background: #00ff88;
}
.ecom-video-step-dot.is-active {
@@ -139,7 +139,7 @@
place-items: center;
border: 1px solid #1c4d3a;
border-radius: 8px;
background: #34d399;
background: #00ff88;
color: #06110e;
padding: 0;
font-size: 17px;
@@ -180,6 +180,9 @@
overflow: auto;
background: #101318;
padding: 26px;
display: flex;
align-items: center;
justify-content: center;
}
.ecom-video-flow-map {
@@ -213,7 +216,7 @@
}
.ecom-video-flow-lines path.is-active {
stroke: #34d399;
stroke: #00ff88;
animation: ecom-video-path-dash 1.8s linear infinite;
}
@@ -319,7 +322,7 @@
.ecom-video-flow-node.is-ready .ecom-video-flow-node__status-orb,
.ecom-video-flow-node.is-completed .ecom-video-flow-node__status-orb {
background: #34d399;
background: #00ff88;
}
.ecom-video-flow-node.is-running .ecom-video-flow-node__status-orb,
@@ -390,7 +393,7 @@
position: absolute;
inset: 0;
transform: translateX(-100%);
background: #34d399;
background: #00ff88;
}
.ecom-video-flow-connector.is-active i,
@@ -499,6 +502,7 @@
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #697486;
font-size: 13px;
@@ -541,7 +545,7 @@
place-items: center;
border-radius: 999px;
background: #1c4d3a;
color: #34d399;
color: #00ff88;
font-size: 12px;
font-weight: 900;
}
@@ -708,4 +712,382 @@
.ecom-video-flow-node--scene {
width: 118px;
}
.ecom-video-tree {
flex-direction: column;
align-items: center;
}
.ecom-video-tree__trunk {
display: none;
}
.ecom-video-tree__row {
flex-wrap: wrap;
justify-content: center;
}
}
/*
Tree Layout 分支树状流程图 (参考图风格)
原图 分支连接线 [分镜文本 分镜图 分镜视频] × N
*/
.ecom-video-tree {
display: flex;
align-items: center;
gap: 0;
width: 100%;
min-height: 0;
}
/* ── Source node ── */
.ecom-video-tree__source {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
flex-shrink: 0;
align-self: center;
}
.ecom-video-tree-node {
position: relative;
overflow: hidden;
border: 1.5px solid #2c3038;
border-radius: 10px;
background: #171c22;
transition: border-color 280ms ease, box-shadow 280ms ease, transform 280ms ease;
animation: ecom-tree-node-in 420ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) both;
}
.ecom-video-tree-node--source {
width: 150px;
height: 190px;
flex-shrink: 0;
border-color: #1c4d3a;
background: #162820;
}
.ecom-video-tree-node--source img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.ecom-video-tree-node__label {
color: #a0b0aa;
font-size: 12px;
font-weight: 700;
text-align: center;
}
/* ── Text node (分镜文本) ── */
.ecom-video-tree-node--text {
min-width: 120px;
max-width: 150px;
padding: 14px 12px;
cursor: default;
border-color: #2a3d30;
background: #131d1a;
}
.ecom-video-tree-node--text.is-completed {
border-color: #1c4d3a;
background: #162820;
}
.ecom-video-tree-node--text.is-active {
border-color: #1a4d4d;
animation: ecom-tree-breathe 1.8s ease-in-out infinite;
}
.ecom-video-tree-node__inner {
display: flex;
flex-direction: column;
gap: 5px;
}
.ecom-video-tree-node__title {
color: #e2eaf4;
font-size: 13px;
font-weight: 800;
}
.ecom-video-tree-node__desc {
color: #6b7a8a;
font-size: 11px;
line-height: 1.4;
}
/* ── Image node (分镜图) ── */
.ecom-video-tree-node--image,
.ecom-video-tree-node--video {
width: 150px;
height: 120px;
flex-shrink: 0;
}
.ecom-video-tree-node--image img,
.ecom-video-tree-node--image video,
.ecom-video-tree-node--video img,
.ecom-video-tree-node--video video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.ecom-video-tree-node--image.is-completed,
.ecom-video-tree-node--video.is-completed {
border-color: #1c4d3a;
background: #162820;
}
.ecom-video-tree-node--image.is-active,
.ecom-video-tree-node--video.is-active {
border-color: #1a4d4d;
animation: ecom-tree-breathe 1.8s ease-in-out infinite;
}
.ecom-video-tree-node--video.is-failed {
border-color: #4d1a1a;
background: #2a1b1d;
}
.ecom-video-tree-node__placeholder {
display: grid;
width: 100%;
height: 100%;
place-items: center;
background: linear-gradient(135deg, #171c22 0%, #12161b 100%);
color: #5a6a78;
font-size: 24px;
}
.ecom-video-tree-node__placeholder span {
font-size: 12px;
font-weight: 600;
}
.ecom-video-tree-node__tag {
position: absolute;
left: 8px;
top: 8px;
max-width: calc(100% - 16px);
overflow: hidden;
border: 1px solid #303540;
border-radius: 999px;
background: rgba(18, 20, 26, 0.9);
backdrop-filter: blur(6px);
color: #c8d4e0;
padding: 3px 9px;
font-size: 10px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.ecom-video-tree-node__progress {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: #53e5ff;
font-size: 14px;
font-weight: 900;
}
.ecom-video-tree-node__retry {
position: absolute;
right: 8px;
top: 8px;
z-index: 5;
display: grid;
width: 28px;
height: 28px;
place-items: center;
border: 1px solid #4d1a1a;
border-radius: 999px;
background: #241417;
color: #ffb1b1;
font-size: 12px;
cursor: pointer;
}
/* ── Trunk connector (分支连接线) ── */
.ecom-video-tree__trunk {
position: relative;
width: 48px;
flex-shrink: 0;
align-self: stretch;
}
.ecom-video-tree__trunk-line {
position: absolute;
left: 0;
top: 50%;
width: 24px;
height: 2px;
background: #3a4550;
transform: translateY(-50%);
}
.ecom-video-tree__trunk-line::after {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, #00ff88, transparent);
animation: ecom-tree-trunk-flow 2.4s ease-in-out infinite;
border-radius: 2px;
}
.ecom-video-tree__branches-line {
position: absolute;
left: 24px;
top: 0;
bottom: 0;
width: 24px;
}
.ecom-video-tree__branches-line::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: #3a4550;
}
.ecom-video-tree__branch-tap {
position: absolute;
left: 0;
width: 100%;
height: 2px;
background: #3a4550;
}
.ecom-video-tree__branch-tap:nth-child(1) { top: 0; }
.ecom-video-tree__branch-tap:nth-child(2) { top: 50%; transform: translateY(-50%); }
.ecom-video-tree__branch-tap:nth-child(3) { bottom: 0; }
.ecom-video-tree__branch-tap::after {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, #00ff88, transparent);
animation: ecom-tree-branch-flow 2.4s ease-in-out infinite;
}
.ecom-video-tree__branch-tap:nth-child(2)::after { animation-delay: 0.3s; }
.ecom-video-tree__branch-tap:nth-child(3)::after { animation-delay: 0.6s; }
/* ── Arrow between nodes ── */
.ecom-video-tree__arrow {
flex-shrink: 0;
width: 36px;
height: 20px;
color: #4a5565;
transition: color 280ms ease;
}
.ecom-video-tree__arrow svg {
width: 100%;
height: 100%;
}
.ecom-video-tree__arrow svg path {
transition: stroke 280ms ease;
}
.ecom-video-tree__row:hover .ecom-video-tree__arrow {
color: #00ff88;
}
/* ── Rows container ── */
.ecom-video-tree__rows {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 20px;
flex: 1;
min-width: 0;
align-self: center;
padding: 8px 0;
}
.ecom-video-tree__row {
display: flex;
align-items: center;
gap: 8px;
animation: ecom-tree-row-in 480ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) both;
}
.ecom-video-tree__row--empty {
opacity: 0.5;
transition: opacity 320ms ease;
}
.ecom-video-tree__row--empty.is-planning {
opacity: 0.75;
}
.ecom-video-tree__row--empty.is-planning .ecom-video-tree-node {
border-color: rgba(var(--accent-rgb, 0, 255, 136), 0.15);
}
/* ── Animations ── */
@keyframes ecom-tree-node-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes ecom-tree-row-in {
from {
opacity: 0;
transform: translateX(-16px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes ecom-tree-breathe {
0%, 100% {
border-color: #1a4d4d;
box-shadow: 0 0 0 0 rgba(83, 229, 255, 0);
}
50% {
border-color: #53e5ff;
box-shadow: 0 0 16px 2px rgba(83, 229, 255, 0.12);
}
}
@keyframes ecom-tree-trunk-flow {
0% { opacity: 0; transform: translateX(-100%); }
30% { opacity: 0.6; }
70% { opacity: 0.6; }
100% { opacity: 0; transform: translateX(100%); }
}
@keyframes ecom-tree-branch-flow {
0% { opacity: 0; transform: translateX(-100%); }
30% { opacity: 0.5; }
70% { opacity: 0.5; }
100% { opacity: 0; transform: translateX(100%); }
}
File diff suppressed because it is too large Load Diff
+1301 -92
View File
File diff suppressed because it is too large Load Diff
+90 -44
View File
@@ -563,7 +563,10 @@ textarea.image-workbench-prompt {
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--bg-inset);
background:
radial-gradient(circle, rgba(var(--accent-rgb), 0.12) 1px, transparent 1.4px),
var(--bg-inset);
background-size: 22px 22px;
}
.image-workbench-canvas img {
@@ -592,6 +595,7 @@ textarea.image-workbench-prompt {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--fg-dim);
font-size: 14px;
@@ -625,16 +629,24 @@ textarea.image-workbench-prompt {
height: 100%;
}
.image-workbench-camera-stage {
flex-direction: column;
gap: 16px;
}
.image-workbench-inpaint-stage img,
.image-workbench-camera-stage img {
max-width: 90%;
max-height: 90%;
max-width: 95%;
max-height: 95%;
border-radius: 8px;
object-fit: contain;
}
.image-workbench-inpaint-stage > span,
.image-workbench-camera-stage > span {
.image-workbench-camera-stage img {
max-height: 68%;
}
.image-workbench-inpaint-stage > span {
position: absolute;
bottom: 12px;
left: 50%;
@@ -647,6 +659,15 @@ textarea.image-workbench-prompt {
white-space: nowrap;
}
.image-workbench-camera-stage > span {
padding: 4px 12px;
border-radius: var(--radius-xs);
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 13px;
white-space: nowrap;
}
/* Inpaint mask canvas */
.image-workbench-inpaint-canvas {
display: block;
@@ -689,16 +710,8 @@ textarea.image-workbench-prompt {
color: #fff;
}
.image-workbench-camera-stage > span {
bottom: 64px;
}
.image-workbench-camera-stage > .image-workbench-result-actions {
position: absolute;
bottom: 16px;
left: 50%;
width: min(360px, calc(100% - 32px));
transform: translateX(-50%);
}
.image-workbench-inpaint-tool.is-active {
@@ -809,7 +822,7 @@ textarea.image-workbench-prompt {
text-align: center;
}
.image-workbench-result-grid {
.image-workbench-panel--right .image-workbench-result-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 8px;
@@ -1467,6 +1480,27 @@ textarea.image-workbench-prompt {
margin: 0;
}
.watermark-removal-actions {
flex-direction: column;
gap: 10px;
padding: 12px 0 0;
}
.watermark-removal-actions .image-workbench-primary {
width: 100%;
min-height: 48px;
justify-content: center;
font-size: 14px;
font-weight: 750;
}
.watermark-removal-actions .image-workbench-cancel {
width: 100%;
min-height: 42px;
justify-content: center;
font-size: 13px;
}
.watermark-removal-compare {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -1519,34 +1553,42 @@ textarea.image-workbench-prompt {
.watermark-removal-compare__actions {
position: absolute;
bottom: 10px;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
width: min(480px, calc(100% - 32px));
}
.watermark-removal-compare__actions button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
border: none;
justify-content: center;
gap: 10px;
min-height: 64px;
padding: 0 24px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-xs);
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 12px;
background: var(--bg-inset);
color: var(--fg-body);
font: inherit;
font-size: 18px;
font-weight: 750;
cursor: pointer;
backdrop-filter: blur(4px);
transition: background 0.15s;
backdrop-filter: none;
transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
}
.watermark-removal-compare__actions button:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.8);
border-color: rgba(var(--accent-rgb), 0.42);
background: rgba(var(--accent-rgb), 0.11);
color: var(--accent);
}
.watermark-removal-compare__actions button:disabled {
opacity: 0.5;
opacity: 0.56;
cursor: not-allowed;
}
@@ -1563,33 +1605,33 @@ textarea.image-workbench-prompt {
}
.image-workbench-generating strong {
font-size: 15px;
font-size: 20px;
color: var(--fg-default);
}
.image-workbench-progress-bar {
width: 200px;
height: 6px;
border-radius: 3px;
width: 320px;
height: 8px;
border-radius: 4px;
background: var(--bg-inset);
overflow: hidden;
}
.image-workbench-progress-fill {
height: 100%;
border-radius: 3px;
border-radius: 4px;
background: var(--accent);
transition: width 0.3s ease;
}
.image-workbench-cancel {
margin-top: 8px;
padding: 6px 16px;
margin-top: 12px;
padding: 8px 24px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-xs);
background: transparent;
color: var(--fg-muted);
font-size: 13px;
font-size: 15px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
@@ -1600,14 +1642,17 @@ textarea.image-workbench-prompt {
}
.image-workbench-result-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
align-content: center;
width: 100%;
height: 100%;
padding: 16px;
margin: 0;
padding: 24px;
overflow-y: auto;
align-content: start;
gap: 16px;
}
.image-workbench-result-item {
@@ -1634,8 +1679,9 @@ textarea.image-workbench-prompt {
.image-workbench-result-card {
display: grid;
min-width: 0;
width: min(100%, 500px);
align-content: start;
gap: 8px;
gap: 12px;
}
.image-workbench-result-actions {
@@ -1647,16 +1693,16 @@ textarea.image-workbench-prompt {
.image-workbench-result-actions button {
display: inline-flex;
min-width: 0;
min-height: 34px;
min-height: 48px;
align-items: center;
justify-content: center;
gap: 5px;
gap: 8px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-xs);
background: var(--bg-inset);
color: var(--fg-body);
font: inherit;
font-size: 12px;
font-size: 14px;
font-weight: 750;
cursor: pointer;
transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
.not-found-page {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 60px);
padding: 48px 24px;
background: var(--app-bg, #0b0b0f);
}
.not-found-page__content {
text-align: center;
max-width: 420px;
}
.not-found-page__code {
font-size: 96px;
font-weight: 800;
line-height: 1;
letter-spacing: -0.03em;
color: var(--accent-teal, #2dd4bf);
margin-bottom: 12px;
}
.not-found-page h1 {
font-size: 22px;
font-weight: 600;
color: var(--text-primary, #f1f5f9);
margin: 0 0 8px;
}
.not-found-page p {
font-size: 14px;
color: var(--text-secondary, #94a3b8);
margin: 0 0 28px;
line-height: 1.6;
}
.not-found-page__button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
border: 1px solid var(--border-default, #334155);
border-radius: 8px;
background: var(--surface-elevated, #1e293b);
color: var(--text-primary, #f1f5f9);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.not-found-page__button:hover {
background: var(--surface-hover, #334155);
border-color: var(--accent-teal, #2dd4bf);
}
+16
View File
@@ -1 +1,17 @@
/* Profile page rules move here as they are retired from legacy-pages.css. */
/* ── 代表作滚动容器:固定3列,刚好显示9个(3行),超出可滚动,隐藏滚动条 ── */
.profile-page__works-scroll {
max-height: 390px; /* 3行卡片:3 × 120(min-height) + 2 × 10(gap) = 380px,留10px余量 */
overflow-y: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.profile-page__works-scroll::-webkit-scrollbar {
display: none; /* Chrome/Safari/Edge */
}
.profile-page__works-scroll .profile-page__list-grid {
grid-template-columns: repeat(3, 1fr); /* 固定3列,刚好3×3=9个可见 */
}
File diff suppressed because it is too large Load Diff
+1 -8
View File
@@ -1489,7 +1489,7 @@
--eval-text-secondary: #94a3b8;
--eval-text-tertiary: #64748b;
--eval-text-placeholder: #475569;
--eval-accent-start: #34d399;
--eval-accent-start: #00ff88;
--eval-accent-mid: #10b981;
--eval-accent-end: #059669;
--eval-accent-glow: rgba(16, 185, 129, 0.3);
@@ -3400,7 +3400,6 @@
width: 100%;
height: 100%;
min-height: 520px;
max-height: 520px;
padding: 18px 22px;
border: none;
outline: none;
@@ -3410,7 +3409,6 @@
font-size: 14px;
line-height: 1.9;
resize: none;
overflow-y: auto;
}
.script-eval-v4-text-input::placeholder {
@@ -4270,11 +4268,6 @@
.script-eval-v4-text-shell,
.script-eval-v4-text-input {
min-height: calc(100vh - 422px);
max-height: calc(100vh - 422px);
}
.script-eval-v4-text-input {
overflow-y: auto;
}
.script-eval-v4-score-card {
+6 -6
View File
@@ -1,6 +1,6 @@
.product-clone-page[data-tool="clone"].size-template-workbench {
--clone-settings-panel-width: 640px;
--size-green: #34d399;
--size-green: #00ff88;
--size-cyan: #38bdf8;
--size-violet: #a78bfa;
--size-amber: #fbbf24;
@@ -106,7 +106,7 @@
.size-template-workbench .size-template-static-field.is-clickable > button:hover,
.size-template-workbench .size-template-static-field.is-clickable > button[aria-expanded="true"] {
border-color: #34d399;
border-color: #00ff88;
background: #202c28;
}
@@ -155,7 +155,7 @@
.size-template-platform-dialog button:hover,
.size-template-platform-dialog button.is-active {
background: #17352a;
color: #34d399;
color: #00ff88;
}
@keyframes size-template-dialog-rise {
@@ -241,7 +241,7 @@
width: 54px;
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, #34d399, rgb(52 211 153 / 0%));
background: linear-gradient(90deg, #00ff88, rgb(52 211 153 / 0%));
box-shadow: 0 0 18px rgb(52 211 153 / 35%);
}
@@ -355,7 +355,7 @@
}
.size-template-preview-note > div:first-child .anticon {
color: #34d399;
color: #00ff88;
}
.size-template-preview-note p {
@@ -414,7 +414,7 @@
}
.size-template-check-list .anticon {
color: #34d399;
color: #00ff88;
}
@media (max-width: 1320px) {
+3 -7
View File
@@ -376,23 +376,19 @@
margin-top: 8px;
}
.studio-result-actions--with-clear {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.studio-result-actions button {
display: inline-flex;
min-width: 0;
min-height: 36px;
min-height: 48px;
align-items: center;
justify-content: center;
gap: 6px;
gap: 8px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-xs);
background: var(--bg-inset);
color: var(--fg-body);
font: inherit;
font-size: 12px;
font-size: 14px;
font-weight: 750;
cursor: pointer;
transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
+43 -36
View File
@@ -3,15 +3,15 @@
--toolbox-green: #00ff88;
--toolbox-blue: #4fc3f7;
--toolbox-purple: #a855f7;
--toolbox-surface: rgba(14, 16, 38, 0.75);
--toolbox-elevated: rgba(20, 22, 52, 0.85);
--toolbox-surface: rgba(255, 255, 255, 0.04);
--toolbox-elevated: rgba(255, 255, 255, 0.06);
--toolbox-highlight: rgba(28, 31, 68, 0.9);
--toolbox-border-subtle: rgba(0, 255, 136, 0.08);
--toolbox-border-default: rgba(0, 255, 136, 0.14);
--toolbox-border-hover: rgba(0, 255, 136, 0.28);
--toolbox-text-primary: #f0f0f5;
--toolbox-text-secondary: rgba(240, 240, 245, 0.6);
--toolbox-text-tertiary: rgba(240, 240, 245, 0.4);
--toolbox-text-primary: #e8eaef;
--toolbox-text-secondary: #9aa1b8;
--toolbox-text-tertiary: #62697f;
position: relative;
isolation: isolate;
@@ -20,7 +20,7 @@
background:
linear-gradient(180deg, #070b10 0%, #05080d 100%),
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(79, 195, 247, 0.03) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 80% 70%, rgba(42, 159, 212, 0.03) 0%, transparent 60%),
radial-gradient(ellipse 50% 40% at 20% 80%, rgba(168, 85, 247, 0.03) 0%, transparent 60%);
scroll-snap-align: start;
scroll-snap-stop: normal;
@@ -30,21 +30,21 @@
position: relative;
z-index: 2;
display: flex;
gap: clamp(20px, 3vw, 40px);
padding: clamp(42px, 6vw, 82px) clamp(22px, 7vw, 92px);
gap: clamp(18px, 2.8vw, 36px);
padding: clamp(36px, 5.5vw, 68px) clamp(20px, 6vw, 76px);
min-height: var(--home-section-min-height);
align-items: center;
}
/* ===== Left Panel ===== */
.omni-home__toolbox-left {
width: clamp(340px, 30vw, 440px);
width: clamp(320px, 30vw, 450px);
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
gap: 17px;
justify-content: flex-start;
padding-top: clamp(40px, 8vh, 100px);
padding-top: clamp(34px, 6vh, 84px);
}
.omni-home__toolbox-brand {
@@ -54,31 +54,31 @@
}
.omni-home__toolbox-brand-icon {
width: 52px;
height: 52px;
width: 56px;
height: 56px;
background: var(--toolbox-green);
border-radius: 14px;
border-radius: 15px;
display: flex;
align-items: center;
justify-content: center;
color: #0a0b12;
font-size: 26px;
font-size: 28px;
}
.omni-home__toolbox-brand-icon .anticon {
font-size: 28px;
font-size: 30px;
}
.omni-home__toolbox-brand-text {
font-weight: 900;
font-size: 30px;
font-size: 34px;
color: #fff;
letter-spacing: -0.5px;
}
.omni-home__toolbox-title {
font-weight: 900;
font-size: clamp(34px, 3.6vw, 46px);
font-size: clamp(36px, 3.8vw, 50px);
line-height: 1.15;
background: linear-gradient(135deg, var(--toolbox-green), var(--toolbox-blue));
-webkit-background-clip: text;
@@ -87,9 +87,10 @@
}
.omni-home__toolbox-subtitle {
font-size: 17px;
line-height: 1.6;
font-size: 18px;
line-height: 1.55;
color: var(--toolbox-text-secondary);
max-width: 100%;
}
.omni-home__toolbox-list {
@@ -102,8 +103,8 @@
.omni-home__toolbox-item {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 18px 22px;
gap: 17px;
padding: 17px 22px;
border-radius: 16px;
background: var(--toolbox-surface);
border: 1px solid var(--toolbox-border-subtle);
@@ -124,14 +125,14 @@
}
.omni-home__toolbox-item-icon {
font-size: 28px;
font-size: 29px;
flex-shrink: 0;
width: 48px;
height: 48px;
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
border-radius: 13px;
background: rgba(0, 255, 136, 0.08);
}
@@ -139,18 +140,19 @@
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.omni-home__toolbox-item-name {
font-weight: 700;
font-size: 17px;
font-size: 19px;
color: var(--toolbox-text-primary);
}
.omni-home__toolbox-item-desc {
font-size: 14px;
font-size: 16px;
color: var(--toolbox-text-tertiary);
line-height: 1.5;
line-height: 1.4;
}
@keyframes omni-toolbox-fadeSlideIn {
@@ -160,14 +162,14 @@
.omni-home__toolbox-workflow {
margin-top: auto;
padding: 20px 24px;
padding: 19px 24px;
border-radius: 16px;
background: var(--toolbox-surface);
border: 1px solid var(--toolbox-border-subtle);
}
.omni-home__toolbox-workflow-label {
font-size: 14px;
font-size: 16px;
font-weight: 700;
color: var(--toolbox-green);
margin-bottom: 12px;
@@ -178,18 +180,21 @@
.omni-home__toolbox-workflow-steps {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
font-size: 14px;
font-size: 16px;
color: var(--toolbox-text-tertiary);
}
.omni-home__toolbox-workflow-step {
color: var(--toolbox-text-secondary);
white-space: nowrap;
}
.omni-home__toolbox-workflow-arrow {
color: var(--toolbox-green);
font-size: 14px;
font-size: 16px;
flex-shrink: 0;
}
/* ===== Grid Area ===== */
@@ -829,17 +834,19 @@
@media (max-width: 980px) {
.omni-home__toolbox-shell {
flex-direction: column;
padding: 48px 22px 64px;
padding: 36px 20px 48px;
gap: 24px;
}
.omni-home__toolbox-left {
width: 100%;
flex-shrink: unset;
padding-top: 0;
}
.omni-home__toolbox-grid {
width: 100%;
min-height: clamp(480px, 70vw, 700px);
min-height: clamp(400px, 60vw, 560px);
}
.omni-home__toolbox-workflow {
@@ -849,7 +856,7 @@
@media (max-width: 560px) {
.omni-home__toolbox-shell {
padding: 36px 18px 48px;
padding: 28px 16px 40px;
}
.omni-home__toolbox-title {
+149 -1
View File
@@ -237,7 +237,8 @@
}
.member-button {
color: var(--cyan-strong);
color: var(--accent);
font-weight: 600;
}
.member-button--community {
@@ -558,6 +559,7 @@
}
.web-shell__page {
position: relative;
flex: 1;
min-height: 0;
overflow: auto;
@@ -826,3 +828,149 @@
border-color: var(--accent);
color: var(--accent);
}
.cookie-consent {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 1300;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
width: min(640px, calc(100vw - 36px));
padding: 16px;
border: 1px solid rgba(var(--accent-rgb), 0.28);
border-radius: 16px;
background: var(--bg-panel);
color: var(--fg-body);
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.34);
}
.cookie-consent strong,
.cookie-consent p {
margin: 0;
}
.cookie-consent p {
margin-top: 5px;
color: var(--fg-muted);
font-size: 13px;
line-height: 1.55;
}
.cookie-consent__actions {
display: flex;
align-items: center;
gap: 10px;
}
.cookie-consent__actions a,
.cookie-consent__actions button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 900;
white-space: nowrap;
}
.cookie-consent__actions a {
color: var(--accent);
text-decoration: none;
}
.cookie-consent__actions button {
border: 0;
background: var(--accent);
color: #07100b;
cursor: pointer;
}
@media (max-width: 900px) {
.web-shell {
overflow: hidden;
}
.web-topbar {
flex: 0 0 auto;
gap: 10px;
padding: 10px 12px;
}
.brand-lockup__tone,
.profile-button span:not(.profile-button__avatar),
.member-button__label {
display: none;
}
.web-topbar__actions {
gap: 6px;
}
.member-button,
.profile-button,
.info-button {
width: 36px;
padding: 0;
}
.floating-nav {
left: 50%;
top: auto;
bottom: max(10px, env(safe-area-inset-bottom));
flex-direction: row;
width: min(calc(100vw - 20px), 560px);
overflow-x: auto;
justify-content: flex-start;
border-radius: 18px;
transform: translateX(-50%);
scrollbar-width: none;
}
.floating-nav::-webkit-scrollbar {
display: none;
}
.floating-nav__item {
flex: 0 0 44px;
}
.floating-nav__label,
.floating-nav__submenu,
.floating-page-scroll-actions {
display: none;
}
.web-shell__page {
padding-bottom: 78px;
}
.info-popover,
.profile-popover {
right: -8px;
max-width: calc(100vw - 24px);
}
}
@media (max-width: 640px) {
.brand-lockup__name {
font-size: 14px;
}
.web-topbar__actions {
flex: 1;
}
.cookie-consent {
right: 12px;
bottom: 12px;
grid-template-columns: 1fr;
}
.cookie-consent__actions {
justify-content: space-between;
}
}
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -14,7 +14,6 @@ export type WebViewKey =
| "sizeTemplate"
| "scriptTokens"
| "tokenUsage"
| "settings"
| "imageWorkbench"
| "resolutionUpscale"
| "digitalHuman"
@@ -26,7 +25,10 @@ export type WebViewKey =
| "communityReview"
| "communityCaseAdd"
| "report"
| "providerHealth";
| "providerHealth"
| "userAgreement"
| "privacyPolicy"
| "not-found";
export type WebImageWorkbenchTool = "workbench" | "inpaint" | "camera";
+3
View File
@@ -1,4 +1,5 @@
const ERROR_REPORT_ENDPOINT = "/api/client-errors";
const CLIENT_ERROR_REPORTING_ENABLED = import.meta.env.VITE_ENABLE_CLIENT_ERROR_REPORTING === "1";
interface ErrorReport {
message: string;
@@ -44,6 +45,8 @@ function scheduleFlush() {
}
export function reportError(error: unknown, source: ErrorReport["source"] = "manual") {
if (!CLIENT_ERROR_REPORTING_ENABLED) return;
const err = error instanceof Error ? error : new Error(String(error));
const report: ErrorReport = {
message: err.message,