Compare commits

...

12 Commits

Author SHA1 Message Date
OmniAI Developer 90e3b90e34 merge: 合并远程PR#12商业化打磨和PR#13修复 2026-06-05 00:45:21 +08:00
OmniAI Developer 10b8379965 feat: 交互式对话框生成器 + 电商取消生成与上传优化
新增:
- 交互式对话框生成器模块(路由、页面、样式、MorePage入口)
- 电商模块取消生成功能(任务追踪/取消按钮/中止逻辑)
- 视频服务图片上传支持 Blob/dataURL/远程URL 多种来源

优化:
- 电商图片上传修复本地 blob 预览图缺少原始文件的问题
- 视频规划管线错误信息改进
- 生成流程中多处增加中止检查点
2026-06-05 00:37:38 +08:00
stringadmin c1c4086383 Merge pull request 'fix: ????????? OSS ????' (#13) from fix/ecommerce-video-400-bug into master
Reviewed-on: #13
2026-06-04 12:10:38 +00:00
stringadmin 3493f169c0 fix: use public model config and disable source maps 2026-06-04 19:00:50 +08:00
stringadmin b81128d7ca fix: harden ecommerce media history for launch 2026-06-04 18:27:12 +08:00
stringadmin e166722945 Merge pull request 'Feat/commercial saas polish' (#12) from feat/commercial-saas-polish into master
Reviewed-on: #12
2026-06-04 10:09:15 +00:00
ludan e8a42dafde fix: 修复合并冲突导致的CSS未闭合花括号 2026-06-04 16:29:38 +08:00
ludan c4ef9cc6ba Merge origin/master: resolve CSS conflicts
script-tokens-v5.css: keep both SaaS polish rules and master's additions
dark-green.css: keep both profile/canvas polish and master's additions
2026-06-04 16:23:43 +08:00
ludan 05a42ed018 Merge origin/master: resolve CSS conflicts, keep both sides 2026-06-04 16:22:40 +08:00
ludan 9e7bfdd206 Merge origin/master into feat/commercial-saas-polish 2026-06-04 16:07:39 +08:00
ludan fb4011bf1f feat: 个人中心视觉重构、画布网点背景、剧本评分色调统一
【个人中心视觉重构】
- 列表卡片新增媒体预览缩略图(图片/视频/项目/资产),支持 image/video 两种媒体类型
- 新增 renderCardPreview 通用预览组件,自动识别视频格式并渲染 <video> 标签
- 新增 formatAssetType 工具函数,资产类型中文化(角色/场景/道具/视频/图像/素材)
- 媒体卡片采用固定高度网格布局(标题行 18px/正文 36px/元信息 18px),保证列表节奏一致
- 卡片预览区左上角显示类型标签徽章(品牌绿边框+半透明背景)
- 删除按钮增加 hover 红色反馈(边框/背景/文字渐变至红色)
- 积分/任务面板从底部区域移至侧边栏头像下方,减少滚动距离
- 新增 account-card 容器包裹积分/任务切换面板
- 侧边栏统计数据改为 3 列网格布局,每项增加独立圆角卡片样式
- 作品/项目/资产/社区发布四个 Tab 改为均分 4 列网格
- 分区标题增加品牌绿圆点前缀装饰
- 响应式断点:960px(侧边栏双列+内容区单列)、640px(全部单列+标签横向滚动)、420px(紧凑间距)

【画布网点背景】
- 移除 ReactFlow <Background> 组件,改用纯 CSS radial-gradient 圆点背景
- 通过 CSS 自定义属性(--canvas-bg-size/--canvas-bg-dot/--canvas-bg-x/--canvas-bg-y)实现缩放/平移时网点同步
- 网点颜色使用半透明灰蓝(rgba(148,163,184,0.34)),随画布缩放动态调整点间距与大小

【剧本评分色调统一】
- 变量 Token 体系重定义为电商同款暗色面板色调(--v5-bg: #0d0d0f, --v5-panel: #151719)
- 移除所有 box-shadow 和 depth 阴影,改用 inset 顶部光泽线
- 移除 backdrop-filter 毛玻璃效果,统一为纯色半透明背景
- hover 交互简化为边框+背景色变化,取消 transform 浮起动画
- 上传区移除 ::after 径向光晕伪元素
- 已上传态/选中态仅通过 border-color 和背景色微调区分
2026-06-04 13:16:38 +08:00
ludan b08a7918da feat: 剧本评分左侧面板滚动优化、电商克隆移动端适配、视觉细节精修
【剧本评分左侧面板滚动重构】
- 新增 script-eval-v5-left-main 滚动容器,上传区/AI信息/历史记录统一在容器内滚动
- 底部操作按钮(开始评测/导出报告)独立于滚动区外,始终可见可点击
- 历史评测列表增加 max-height 限制,超出区域内置滚动条
- 自定义窄滚动条(品牌绿半透明 thumb),保持视觉干净
- 短视口(≤760px/820px)压缩上传区和历史列表最小高度

【剧本评分视觉精修】
- 左侧面板增加渐变背景层次与分区微光分割线
- 上传区增加 ::after 伪元素径向光晕,hover 时品牌绿边框增强
- 已上传状态上传区增加绿色边框高亮(is-ready/is-complete)
- 底部操作栏背景层次加深,导出按钮 hover 增加绿色反馈
- 右侧面板增加底部径向渐变,上传引导卡标题提亮
- 顶部状态栏背景加深,模糊效果增强

【电商克隆移动端适配增强】
- 900px/620px/480px 三级断点增加顶部预留空间,避免与导航重叠
- Logo 区域定位从 sticky 改为 static,避免滚动时遮挡内容
- 设置面板在窄屏下调整内边距与边距

【Token 用量页精简】
- 移除指标卡片序号角标,保持卡片视觉简洁
2026-06-04 09:40:28 +08:00
26 changed files with 4577 additions and 255 deletions
+72
View File
@@ -0,0 +1,72 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const repoRoot = process.cwd();
const failures = [];
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
}
function assertMatch(label, content, pattern) {
if (!pattern.test(content)) {
failures.push(label);
}
}
function assertNoMatch(label, content, pattern) {
if (pattern.test(content)) {
failures.push(label);
}
}
const serverConnection = read("src/api/serverConnection.ts");
const generationClient = read("src/api/aiGenerationClient.ts");
const ecommerceVideoService = read("src/features/ecommerce/ecommerceVideoService.ts");
const workbenchPersistence = read("src/features/workbench/workbenchResultPersistence.ts");
assertMatch(
"serverConnection must build same-origin /api URLs",
serverConnection,
/return\s+`\/api\/\$\{cleanPath\}`;/,
);
assertNoMatch(
"frontend generation flow must not use fixed VITE environment config",
`${serverConnection}\n${generationClient}`,
/\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/,
);
assertNoMatch(
"frontend generation flow must not call provider hosts directly",
generationClient,
/dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i,
);
assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/);
assertMatch("video generation must go through the app API", generationClient, /buildApiUrl\("ai\/video"\)/);
assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/);
assertMatch("URL uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-by-url"\)/);
assertMatch(
"ecommerce video history must durable-copy media before saving",
ecommerceVideoService,
/buildDurableVideoHistoryPayload\(payload\)/,
);
assertMatch(
"ecommerce video history must filter temporary provider URLs on read",
ecommerceVideoService,
/items:\s*history\.items\.map\(removeTemporaryHistoryUrls\)/,
);
assertMatch(
"workbench results must persist generated media through OSS",
workbenchPersistence,
/uploadAssetByUrl\(/,
);
if (failures.length) {
console.error("Mocked generation smoke check failed:");
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log("Mocked generation smoke check passed.");
+5 -1
View File
@@ -48,6 +48,7 @@ const CommunityCaseAddPage = lazy(() => import("./features/community-review/Comm
const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage"));
const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage"));
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
const HomePage = lazy(() => import("./features/home/HomePage"));
const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage"));
@@ -110,6 +111,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
"resolutionUpscale",
"watermarkRemoval",
"subtitleRemoval",
"dialogGenerator",
"digitalHuman",
"avatarConsole",
"characterMix",
@@ -123,7 +125,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
"not-found",
]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]);
function normalizeViewKey(rawView: string): WebViewKey {
const normalized =
@@ -1159,6 +1161,8 @@ function App() {
onSelectView={handleSetView}
/>
);
case "dialogGenerator":
return <DialogGeneratorPage />;
case "report":
return <ReportPage />;
case "providerHealth":
+2 -2
View File
@@ -913,7 +913,7 @@ export const keyServerClient = {
async getProjectContent(projectId: string): Promise<WebCanvasWorkflow> {
const stored = readStoredSession();
if (!stored) {
throw new Error("闇€瑕佸厛鐧诲綍");
throw new Error("需要先登录");
}
const safeProjectId = encodeURIComponent(projectId.trim());
@@ -1000,7 +1000,7 @@ export const keyServerClient = {
async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise<void> {
const stored = readStoredSession();
if (!stored) {
throw new Error("闇€瑕佸厛鐧诲綍");
throw new Error("需要先登录");
}
const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`;
+1 -1
View File
@@ -71,7 +71,7 @@ export const modelCapabilitiesClient = {
let payload: unknown;
try {
payload = await serverRequest<unknown>(`config/profile?name=${encodeURIComponent(name)}`);
payload = await serverRequest<unknown>(`public/config/profile?name=${encodeURIComponent(name)}`);
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
modelCapabilitiesRouteMissing = true;
+1
View File
@@ -86,6 +86,7 @@ function AppShell({
"imageWorkbench",
"resolutionUpscale",
"digitalHuman",
"dialogGenerator",
"avatarConsole",
"characterMix",
] as WebViewKey[];
+1
View File
@@ -23,6 +23,7 @@ const NAV_ORDER: string[] = [
"resolutionUpscale",
"watermarkRemoval",
"subtitleRemoval",
"dialogGenerator",
"digitalHuman",
"avatarConsole",
"characterMix",
+3 -5
View File
@@ -26,7 +26,6 @@
VideoCameraOutlined,
} from "@ant-design/icons";
import {
Background,
ReactFlow,
} from "@xyflow/react";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react";
@@ -3560,7 +3559,8 @@ function CanvasPage({
onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove}
onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel}
style={{
"--canvas-bg-size": `${24 * canvasViewport.zoom}px`,
"--canvas-bg-size": `${34 * canvasViewport.zoom}px`,
"--canvas-bg-dot": `${1.35 * canvasViewport.zoom}px`,
"--canvas-bg-x": `${canvasViewport.x}px`,
"--canvas-bg-y": `${canvasViewport.y}px`,
cursor: canvasPanDrag ? "grabbing" : spacePanning ? "grab" : undefined,
@@ -3748,9 +3748,7 @@ function CanvasPage({
proOptions={{ hideAttribution: true }}
onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick}
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
>
<Background gap={24} color="transparent" className="studio-canvas__background" />
</ReactFlow>
/>
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
<button type="button" title="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}>
@@ -0,0 +1,290 @@
import { useCallback, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
type DialogStyle = "style1" | "style2" | "style3" | "style4";
interface DialogItem {
id: number;
style: DialogStyle;
x: number;
y: number;
text: string;
color: string;
confirmed: boolean;
}
interface DragState {
id: number;
offsetX: number;
offsetY: number;
}
const dialogStyles: Array<{
key: DialogStyle;
label: string;
description: string;
swatchClass: string;
}> = [
{ key: "style1", label: "白色圆角对话框", description: "适合浅色说明与标注", swatchClass: "is-white" },
{ key: "style2", label: "蓝色气泡对话框", description: "适合角色台词与重点提示", swatchClass: "is-blue" },
{ key: "style3", label: "黄色提示对话框", description: "适合醒目提醒与强调", swatchClass: "is-amber" },
{ key: "style4", label: "灰色简约对话框", description: "适合信息备注与辅助说明", swatchClass: "is-gray" },
];
const textColorOptions = [
{ value: "#ffffff", label: "白色" },
{ value: "#111827", label: "黑色" },
{ value: "#ef4444", label: "红色" },
{ value: "#f59e0b", label: "黄色" },
{ value: "#165dff", label: "蓝色" },
{ value: "#00ff88", label: "绿色" },
];
function DialogGeneratorPage() {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const previewRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragState | null>(null);
const nextIdRef = useRef(0);
const [backgroundUrl, setBackgroundUrl] = useState("");
const [dialogs, setDialogs] = useState<DialogItem[]>([]);
const [selectedTextColor, setSelectedTextColor] = useState(textColorOptions[0].value);
const [activeDragId, setActiveDragId] = useState<number | null>(null);
const handleFile = useCallback((file?: File | null) => {
if (!file || !file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
setBackgroundUrl(reader.result);
}
};
reader.readAsDataURL(file);
}, []);
const addDialog = useCallback((style: DialogStyle) => {
nextIdRef.current += 1;
const id = nextIdRef.current;
setDialogs((current) => [
...current,
{
id,
style,
x: 30 + (id * 25) % 200,
y: 30 + (id * 20) % 150,
text: "",
color: selectedTextColor,
confirmed: false,
},
]);
}, [selectedTextColor]);
const updateDialog = useCallback((id: number, patch: Partial<DialogItem>) => {
setDialogs((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
}, []);
const deleteDialog = useCallback((id: number) => {
setDialogs((current) => current.filter((item) => item.id !== id));
}, []);
const startDrag = useCallback((id: number, clientX: number, clientY: number) => {
const dialogEl = document.querySelector<HTMLElement>(`[data-dialog-id="${id}"]`);
if (!dialogEl) return;
const rect = dialogEl.getBoundingClientRect();
dragRef.current = {
id,
offsetX: clientX - rect.left,
offsetY: clientY - rect.top,
};
setActiveDragId(id);
}, []);
const moveDrag = useCallback((clientX: number, clientY: number) => {
const drag = dragRef.current;
const preview = previewRef.current;
if (!drag || !preview) return;
const dialogEl = document.querySelector<HTMLElement>(`[data-dialog-id="${drag.id}"]`);
if (!dialogEl) return;
const bounds = preview.getBoundingClientRect();
const nextX = Math.max(0, Math.min(clientX - drag.offsetX - bounds.left, bounds.width - dialogEl.offsetWidth));
const nextY = Math.max(0, Math.min(clientY - drag.offsetY - bounds.top, bounds.height - dialogEl.offsetHeight));
updateDialog(drag.id, { x: nextX, y: nextY });
}, [updateDialog]);
const endDrag = useCallback(() => {
dragRef.current = null;
setActiveDragId(null);
}, []);
const handleCanvasMouseMove = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
moveDrag(event.clientX, event.clientY);
}, [moveDrag]);
const handleCanvasTouchMove = useCallback((event: ReactTouchEvent<HTMLDivElement>) => {
const touch = event.touches[0];
if (!touch) return;
moveDrag(touch.clientX, touch.clientY);
}, [moveDrag]);
return (
<section className="dialog-generator-page page-motion">
<div className="dialog-generator-shell">
<aside className="dialog-generator-panel">
<div className="dialog-generator-heading">
<span className="dialog-generator-kicker">Interactive Dialog</span>
<h1></h1>
<p></p>
</div>
<div className="dialog-generator-section">
<h2></h2>
<button
type="button"
className="dialog-generator-drop"
onClick={() => fileInputRef.current?.click()}
onDragOver={(event) => {
event.preventDefault();
}}
onDrop={(event) => {
event.preventDefault();
handleFile(event.dataTransfer.files[0]);
}}
>
<span className="dialog-generator-drop-icon">🖼</span>
<strong></strong>
<small> JPGPNGWEBP </small>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={(event) => handleFile(event.target.files?.[0])}
/>
</div>
<div className="dialog-generator-section">
<h2></h2>
<p className="dialog-generator-hint"></p>
<div className="dialog-generator-color-picker" role="radiogroup" aria-label="文字颜色">
{textColorOptions.map((item) => (
<button
key={item.value}
type="button"
className={`dialog-generator-color${selectedTextColor === item.value ? " is-active" : ""}`}
style={{ "--text-color": item.value } as CSSProperties}
aria-checked={selectedTextColor === item.value}
role="radio"
onClick={() => setSelectedTextColor(item.value)}
>
<span />
<strong>{item.label}</strong>
</button>
))}
</div>
<div className="dialog-generator-style-list">
{dialogStyles.map((item) => (
<button key={item.key} type="button" className="dialog-generator-style" onClick={() => addDialog(item.key)}>
<span className={`dialog-generator-swatch ${item.swatchClass}`} />
<span>
<strong>{item.label}</strong>
<small>{item.description}</small>
</span>
</button>
))}
</div>
</div>
<button type="button" className="dialog-generator-clear" onClick={() => setDialogs([])}>
</button>
</aside>
<main className="dialog-generator-preview-card">
<div className="dialog-generator-preview-head">
<div>
<span>Preview</span>
<h2></h2>
</div>
<p></p>
</div>
<div
ref={previewRef}
className="dialog-generator-preview"
onMouseMove={handleCanvasMouseMove}
onMouseUp={endDrag}
onMouseLeave={endDrag}
onTouchMove={handleCanvasTouchMove}
onTouchEnd={endDrag}
>
{backgroundUrl ? <div className="dialog-generator-image" style={{ backgroundImage: `url(${backgroundUrl})` }} /> : null}
{!backgroundUrl ? (
<div className="dialog-generator-empty">
<span>🖼</span>
<p></p>
</div>
) : null}
{dialogs.map((dialog) => (
<div
key={dialog.id}
data-dialog-id={dialog.id}
className={`dialog-generator-bubble ${dialog.style}${dialog.confirmed ? " is-confirmed" : ""}${activeDragId === dialog.id ? " is-dragging" : ""}`}
style={{ left: dialog.x, top: dialog.y, "--dialog-text-color": dialog.color } as CSSProperties}
onMouseDown={(event) => {
const target = event.target as HTMLElement;
if (target.closest("textarea,button")) return;
startDrag(dialog.id, event.clientX, event.clientY);
event.preventDefault();
}}
onTouchStart={(event) => {
const target = event.target as HTMLElement;
if (target.closest("textarea,button")) return;
const touch = event.touches[0];
if (touch) startDrag(dialog.id, touch.clientX, touch.clientY);
}}
onDoubleClick={() => {
if (dialog.confirmed) updateDialog(dialog.id, { confirmed: false });
}}
>
{!dialog.confirmed ? (
<button type="button" className="dialog-generator-delete" onClick={() => deleteDialog(dialog.id)} aria-label="删除文字">
×
</button>
) : null}
{dialog.confirmed ? (
<div className="dialog-generator-text-display">{dialog.text}</div>
) : (
<textarea
className="dialog-generator-text"
rows={2}
placeholder="输入文本..."
value={dialog.text}
onChange={(event) => updateDialog(dialog.id, { text: event.target.value })}
/>
)}
{!dialog.confirmed ? (
<div className="dialog-generator-bubble-bottom">
<button
type="button"
className="dialog-generator-confirm"
onClick={() => {
if (dialog.text.trim()) {
updateDialog(dialog.id, { text: dialog.text.trim(), confirmed: true });
}
}}
>
</button>
</div>
) : null}
</div>
))}
</div>
</main>
</div>
</section>
);
}
export default DialogGeneratorPage;
+105 -9
View File
@@ -59,6 +59,7 @@ interface CloneImageItem {
id: string;
src: string;
name: string;
file?: File;
width?: number;
height?: number;
format?: string;
@@ -678,6 +679,7 @@ function createObjectImageItems(files: File[], limit: number, prefix: string) {
id: `${prefix}-${Date.now()}-${index}`,
src: URL.createObjectURL(file),
name: file.name,
file,
format: getImageFileFormat(file),
}));
}
@@ -791,6 +793,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [status, setStatus] = useState<ProductCloneStatus>("idle");
const [results, setResults] = useState<CloneResult[]>([]);
const imageAbortRef = useRef({ current: false });
const activeEcommerceTaskIdsRef = useRef<Set<string>>(new Set());
const lastFailedActionRef = useRef<(() => void) | null>(null);
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
const [modelSource, setModelSource] = useState<TryOnModelSource>("ai");
@@ -845,6 +848,30 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
} as CSSProperties;
const trackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.add(taskId);
};
const untrackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.delete(taskId);
};
const handleCancelGenerate = () => {
imageAbortRef.current.current = true;
const taskIds = Array.from(activeEcommerceTaskIdsRef.current);
activeEcommerceTaskIdsRef.current.clear();
taskIds.forEach((taskId) => {
aiGenerationClient.cancelTask(taskId).catch(() => {});
});
lastFailedActionRef.current = null;
if (productSetStatus === "generating") setProductSetStatus("idle");
if (status === "generating") setStatus("idle");
if (detailStatus === "generating") setDetailStatus("idle");
if (tryOnStatus === "generating") setTryOnStatus("idle");
if (tryOnStatus === "modeling") setTryOnStatus("ready");
toast.info("\u5df2\u53d6\u6d88\u751f\u6210");
};
const syncRequirementMentionQuery = (value: string, selectionStart: number | null | undefined) => {
setRequirementImageMentionQuery(ecommerceMentionImages.length ? getImageMentionQuery(value, selectionStart) : null);
};
@@ -1305,11 +1332,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const urls: string[] = [];
for (const item of images) {
try {
const resp = await fetch(item.src);
const rawBlob = await resp.blob();
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const dataUrl = await blobToDataUrl(blob);
if (!item.file && item.src.startsWith("blob:")) {
throw new Error("本地预览图缺少原始文件,无法上传");
}
const rawBlob = item.file ?? (item.src.startsWith("data:") ? null : await (await fetch(item.src)).blob());
const mimeType = normalizeEcommerceImageMime(
rawBlob?.type || item.src.match(/^data:([^;,]+)/)?.[1] || "image/png",
);
const blob = rawBlob ? (rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType })) : null;
const dataUrl = item.src.startsWith("data:") ? item.src : await blobToDataUrl(blob!);
const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType, scope: "ecommerce-product" });
urls.push(url);
} catch {
@@ -1395,6 +1426,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setStatusFn("idle");
return;
}
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
const generatedUrls: string[] = [];
const stamp = Date.now();
@@ -1414,13 +1449,21 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
gridMode: "single",
referenceUrls,
});
trackEcommerceTask(taskId);
const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId });
const resultUrl = await waitForTask(taskId, {
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, {
abortRef: imageAbortRef.current,
onProgress: () => {},
});
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) break;
if (resultUrl) {
generatedUrls.push(resultUrl);
@@ -1432,9 +1475,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}
}
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
setResultFn(generatedUrls);
setStatusFn(generatedUrls.some(Boolean) ? "done" : "idle");
} catch (err) {
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
if (err instanceof ServerRequestError && err.status === 402) {
setResultFn([]);
toast.error("余额不足,请充值后继续");
@@ -1465,6 +1516,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
statusFn?.("idle");
return;
}
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions);
const stamp = Date.now();
@@ -1477,13 +1532,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
gridMode: "single",
referenceUrls,
});
trackEcommerceTask(taskId);
const storeId = imageGen.submitTask({ title: `电商${outputKey}`, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId });
const resultUrl = await waitForTask(taskId, {
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, {
abortRef: imageAbortRef.current,
onProgress: () => {},
});
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
if (resultUrl) {
resultFn?.([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
@@ -1494,6 +1560,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
}
} catch (err) {
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
if (err instanceof ServerRequestError && err.status === 402) {
resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
toast.error("余额不足,请充值后继续");
@@ -1527,21 +1597,38 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
dataUrl: refDataUrl, name: videoOutfitRefFile.name,
mimeType: videoOutfitRefFile.type || "image/png", scope: "video-outfit",
});
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
const { taskId } = await aiGenerationClient.createVideoEditTask({
videoUrl: videoAsset.url,
referenceUrls: [refAsset.url],
prompt: requirement || undefined,
});
trackEcommerceTask(taskId);
const { waitForTask } = await import("../../api/taskSubscription");
imageAbortRef.current = { current: false };
const resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
if (resultUrl) {
setResults([{ id: crypto.randomUUID(), src: resultUrl, label: "换装视频" }]);
}
setStatus("done");
} catch (err) {
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
setStatus("failed");
toast.error(err instanceof Error ? err.message : "视频换装生成失败");
}
@@ -1877,6 +1964,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
clampCloneVideoDuration={clampCloneVideoDuration}
setCloneVideoSmart={setCloneVideoSmart}
handleGenerate={handleGenerate}
onCancelGenerate={handleCancelGenerate}
formatRatioDisplayValue={formatRatioDisplayValue}
setVideoOutfitFiles={(video, ref) => { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }}
onStartVideoPlan={() => setVideoPlanTrigger((n) => n + 1)}
@@ -1910,6 +1998,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
handleDetailAiWrite={handleDetailAiWrite}
toggleDetailModule={toggleDetailModule}
handleDetailGenerate={handleDetailGenerate}
onCancelGenerate={handleCancelGenerate}
/>
);
@@ -1947,6 +2036,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setSmartScene={setSmartScene}
setTryOnRatio={setTryOnRatio}
handleTryOnGenerate={handleTryOnGenerate}
onCancelGenerate={handleCancelGenerate}
/>
);
@@ -2022,6 +2112,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{productSetStatus === "generating" ? <LoadingOutlined /> : null}
{setPrimaryLabel}
</button>
{productSetStatus === "generating" ? (
<button type="button" className="product-set-floating-submit product-set-floating-submit--cancel" onClick={handleCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</section>
<button type="button" className="product-clone-help" aria-label="帮助">
@@ -2373,6 +2468,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<EcommerceVideoWorkspace
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
productImageDataUrls={productImages.map((img) => img.src)}
productImageFiles={productImages.map((img) => img.file)}
requirement={requirement}
platform={platform}
aspectRatio={ratio.includes("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"}
@@ -34,6 +34,7 @@ import {
interface EcommerceVideoWorkspaceProps {
isAuthenticated: boolean;
productImageDataUrls: string[];
productImageFiles?: Array<File | undefined>;
requirement: string;
platform: string;
aspectRatio: string;
@@ -97,6 +98,7 @@ function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress
export default function EcommerceVideoWorkspace({
isAuthenticated,
productImageDataUrls,
productImageFiles = [],
requirement,
platform,
aspectRatio,
@@ -376,8 +378,9 @@ export default function EcommerceVideoWorkspace({
});
};
try {
const productImageSources = productImageDataUrls.map((url, index) => productImageFiles[index] ?? url);
const result = await runVideoPlan(
productImageDataUrls, requirement, buildConfig(),
productImageSources, requirement, buildConfig(),
{
onStepStart: (step) => setCurrentStep(step),
onStepDone: (step) => {
+238 -40
View File
@@ -19,6 +19,102 @@ import type {
PlanStep,
} from "./ecommerceVideoTypes";
type UploadAssetByUrl = typeof aiGenerationClient.uploadAssetByUrl;
interface DurableMediaUrl {
url: string | null;
originalUrl?: string | null;
ossKey?: string | null;
}
const TEMP_MEDIA_HOST_RE = /^file\d*\.aitohumanize\.com$/i;
const OSS_MEDIA_HOST_RE = /\.oss-[^.]+\.aliyuncs\.com$/i;
function isTemporaryProviderUrl(url: string): boolean {
try {
return TEMP_MEDIA_HOST_RE.test(new URL(url).hostname);
} catch {
return false;
}
}
function isDurableOssUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" && OSS_MEDIA_HOST_RE.test(parsed.hostname);
} catch {
return false;
}
}
function getMediaExtension(url: string, mimeType: string): string {
const normalizedMime = mimeType.split(";")[0]?.trim().toLowerCase();
if (normalizedMime === "image/jpeg") return "jpg";
if (normalizedMime === "image/png") return "png";
if (normalizedMime === "image/webp") return "webp";
if (normalizedMime === "image/gif") return "gif";
if (normalizedMime === "video/mp4") return "mp4";
if (normalizedMime === "video/webm") return "webm";
if (normalizedMime === "video/quicktime") return "mov";
try {
const matched = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i);
if (matched?.[1]) return matched[1].toLowerCase();
} catch {
// Keep mime fallback below.
}
return mimeType.startsWith("video/") ? "mp4" : "png";
}
function buildDurableMediaName(prefix: string, url: string, mimeType: string): string {
const normalized = prefix
.trim()
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, " ")
.slice(0, 80)
.trim();
return `${normalized || "ecommerce-video-media"}.${getMediaExtension(url, mimeType)}`;
}
export async function resolveDurableMediaUrl(
url: string | null | undefined,
options: {
mediaType: "image" | "video";
namePrefix: string;
scope?: string;
uploadAssetByUrl?: UploadAssetByUrl;
},
): Promise<DurableMediaUrl> {
const sourceUrl = String(url || "").trim();
if (!sourceUrl) return { url: null };
if (isDurableOssUrl(sourceUrl)) return { url: sourceUrl };
const mimeType = options.mediaType === "video" ? "video/mp4" : "image/png";
const uploadAssetByUrl = options.uploadAssetByUrl || aiGenerationClient.uploadAssetByUrl.bind(aiGenerationClient);
try {
const uploaded = await uploadAssetByUrl({
sourceUrl,
name: buildDurableMediaName(options.namePrefix, sourceUrl, mimeType),
mimeType,
scope: options.scope || "ecommerce-video-history",
});
return {
url: uploaded.url || null,
originalUrl: sourceUrl,
ossKey: uploaded.ossKey || null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error || "");
console.warn("[ecommerce-video] history media persistence failed:", message);
if (isTemporaryProviderUrl(sourceUrl)) {
return { url: null, originalUrl: sourceUrl };
}
return { url: sourceUrl };
}
}
export interface PlanCallbacks {
onStepStart: (step: PlanStep) => void;
onStepDone: (step: PlanStep) => void;
@@ -30,13 +126,61 @@ export interface PlanCallbacks {
resumeFrom?: EcommerceVideoPlanProgress;
}
const LOCAL_PREVIEW_MISSING_FILE_MESSAGE = "Please re-upload the product image before generating the short video.";
function readBlobAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("File read failed"));
reader.readAsDataURL(blob);
});
}
function normalizeRemoteImageUrl(source: string): string | null {
try {
const url = new URL(source, typeof window !== "undefined" ? window.location.href : undefined);
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
} catch {
return null;
}
}
async function uploadProductImageSource(source: string | Blob): Promise<string> {
if (typeof source === "string") {
if (source.startsWith("blob:")) {
throw new Error(LOCAL_PREVIEW_MISSING_FILE_MESSAGE);
}
if (source.startsWith("data:")) {
const mimeType = normalizeEcommerceImageMime(source.match(/^data:([^;,]+)/)?.[1] || "image/png");
const result = await aiGenerationClient.uploadAsset({ dataUrl: source, mimeType, scope: "ecommerce-product" });
return result.url;
}
const remoteUrl = normalizeRemoteImageUrl(source);
if (remoteUrl) {
const result = await aiGenerationClient.uploadAssetByUrl({ sourceUrl: remoteUrl, scope: "ecommerce-product" });
return result.url;
}
throw new Error("Unsupported product image URL. Please re-upload the product image.");
}
const mimeType = normalizeEcommerceImageMime(source.type || "image/png");
const blob = source.type === mimeType ? source : new Blob([source], { type: mimeType });
const dataUrl = await readBlobAsDataUrl(blob);
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
return result.url;
}
/**
* Run the full ad video planning pipeline.
* Supports resumption: if `resumeFrom` contains data for a step, that step is skipped.
* After each step, `onPartialProgress` fires so callers can persist intermediate state.
*/
export async function runVideoPlan(
imageDataUrls: string[],
imageSources: Array<string | Blob>,
manualText: string,
config: AdVideoUserConfig,
callbacks: PlanCallbacks,
@@ -45,41 +189,30 @@ export async function runVideoPlan(
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
const emit = () => callbacks.onPartialProgress?.({ ...progress });
// ── Step: upload ──────────────────────────────────────
// Step: upload
if (!progress.imageUrls?.length) {
onStepStart("upload");
const imageUrls: string[] = [];
const rejected: string[] = [];
for (const srcUrl of imageDataUrls) {
for (const source of imageSources) {
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);
imageUrls.push(await uploadProductImageSource(source));
} catch (err) {
rejected.push(err instanceof Error ? err.message : "图片上传失败");
rejected.push(err instanceof Error ? err.message : "Image upload failed");
}
}
if (rejected.length) {
progress.uploadWarnings = rejected;
callbacks.onUploadRejected?.(rejected);
}
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
if (!imageUrls.length) throw new Error("Image upload failed. Please check the image format or network and try again.");
progress.imageUrls = imageUrls;
onStepDone("upload");
callbacks.onImagesUploaded?.(imageUrls);
emit();
}
// ── Step: analyze ─────────────────────────────────────
// Step: analyze
if (progress.imageDescription === undefined) {
onStepStart("analyze");
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
@@ -87,7 +220,7 @@ export async function runVideoPlan(
emit();
}
// ── Step: summary ─────────────────────────────────────
// Step: summary
if (!progress.summary) {
onStepStart("summary");
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
@@ -95,7 +228,7 @@ export async function runVideoPlan(
emit();
}
// ── Step: selling ─────────────────────────────────────
// Step: selling
if (!progress.selling) {
onStepStart("selling");
progress.selling = await extractSellingPoints(progress.summary, signal);
@@ -103,16 +236,16 @@ export async function runVideoPlan(
emit();
}
// ── Step: creative ────────────────────────────────────
// Step: creative
if (!progress.creatives?.length) {
onStepStart("creative");
progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
if (!progress.creatives.length) throw new Error("Failed to generate valid ad creatives.");
onStepDone("creative");
emit();
}
// ── Step: storyboard ──────────────────────────────────
// Step: storyboard
if (!progress.storyboard) {
onStepStart("storyboard");
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
@@ -120,7 +253,7 @@ export async function runVideoPlan(
emit();
}
// ── Step: prompts ─────────────────────────────────────
// Step: prompts
if (!progress.videoPrompts) {
onStepStart("prompts");
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
@@ -128,7 +261,7 @@ export async function runVideoPlan(
emit();
}
// ── Step: compliance ──────────────────────────────────
// Step: compliance
if (!progress.compliance) {
onStepStart("compliance");
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
@@ -185,7 +318,7 @@ export async function renderSceneImage(
if (resultUrl) {
callbacks.onSceneImageCompleted(input.sceneId, resultUrl);
} else {
callbacks.onSceneImageFailed(input.sceneId, "图片生成未返回结果");
callbacks.onSceneImageFailed(input.sceneId, "Image generation returned no result.");
}
}
@@ -240,7 +373,7 @@ export async function renderScene(
if (resultUrl) {
callbacks.onSceneCompleted(input.sceneId, resultUrl);
} else {
callbacks.onSceneFailed(input.sceneId, "任务未返回结果");
callbacks.onSceneFailed(input.sceneId, "Task returned no result.");
}
}
@@ -259,7 +392,7 @@ export function buildSceneTasks(
});
}
// ── Video History API ──────────────────────────────────
// Video History API
export interface VideoHistoryScene {
sceneId: number;
@@ -268,6 +401,15 @@ export interface VideoHistoryScene {
videoUrl?: string | null;
}
interface SaveVideoHistoryPayload {
title: string;
config: Record<string, unknown>;
plan: Record<string, unknown>;
scenes: VideoHistoryScene[];
sourceImageUrls: string[];
uploadAssetByUrl?: UploadAssetByUrl;
}
export interface VideoHistoryItem {
id: number;
title: string;
@@ -293,22 +435,74 @@ function getAuthHeaders(): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function saveVideoHistory(payload: {
title: string;
config: Record<string, unknown>;
plan: Record<string, unknown>;
scenes: VideoHistoryScene[];
sourceImageUrls: string[];
}): Promise<{ id: number; createdAt: string }> {
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
const uploadAssetByUrl = payload.uploadAssetByUrl;
const scenes = await Promise.all(
payload.scenes.map(async (scene) => {
const [image, video] = await Promise.all([
resolveDurableMediaUrl(scene.imageUrl, {
mediaType: "image",
namePrefix: `ecommerce-scene-${scene.sceneId}-image`,
uploadAssetByUrl,
}),
resolveDurableMediaUrl(scene.videoUrl, {
mediaType: "video",
namePrefix: `ecommerce-scene-${scene.sceneId}-video`,
uploadAssetByUrl,
}),
]);
return {
...scene,
imageUrl: image.url,
videoUrl: video.url,
};
}),
);
const sourceImageUrls = (
await Promise.all(
payload.sourceImageUrls.map((url, index) =>
resolveDurableMediaUrl(url, {
mediaType: "image",
namePrefix: `ecommerce-source-${index + 1}`,
uploadAssetByUrl,
}),
),
)
)
.map((item) => item.url)
.filter((url): url is string => Boolean(url));
return {
...payload,
scenes,
sourceImageUrls,
};
}
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
const res = await fetch(API_BASE, {
method: "POST",
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
body: JSON.stringify(payload),
body: JSON.stringify(historyPayload),
});
if (!res.ok) throw new Error("保存历史记录失败");
if (!res.ok) throw new Error("Failed to save video history");
return res.json();
}
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
return {
...item,
scenes: item.scenes.map((scene) => ({
...scene,
imageUrl: scene.imageUrl && !isTemporaryProviderUrl(scene.imageUrl) ? scene.imageUrl : null,
videoUrl: scene.videoUrl && !isTemporaryProviderUrl(scene.videoUrl) ? scene.videoUrl : null,
})),
sourceImageUrls: item.sourceImageUrls.filter((url) => !isTemporaryProviderUrl(url)),
};
}
export async function fetchVideoHistory(
limit = 20,
offset = 0,
@@ -317,8 +511,12 @@ export async function fetchVideoHistory(
`${API_BASE}?limit=${limit}&offset=${offset}`,
{ headers: getAuthHeaders() },
);
if (!res.ok) throw new Error("获取历史记录失败");
return res.json();
if (!res.ok) throw new Error("Failed to fetch video history");
const history = (await res.json()) as VideoHistoryListResponse;
return {
...history,
items: history.items.map(removeTemporaryHistoryUrls),
};
}
export async function deleteVideoHistory(id: number): Promise<void> {
@@ -326,5 +524,5 @@ export async function deleteVideoHistory(id: number): Promise<void> {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("删除失败");
if (!res.ok) throw new Error("Failed to delete video history");
}
@@ -133,6 +133,7 @@ interface EcommerceClonePanelProps {
clampCloneVideoDuration: (value: number) => number;
setCloneVideoSmart: (updater: (current: boolean) => boolean) => void;
handleGenerate: () => void;
onCancelGenerate: () => void;
formatRatioDisplayValue: (value: string) => string;
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
onStartVideoPlan?: () => void;
@@ -200,6 +201,7 @@ export default function EcommerceClonePanel({
clampCloneVideoDuration,
setCloneVideoSmart,
handleGenerate,
onCancelGenerate,
formatRatioDisplayValue,
setVideoOutfitFiles,
onStartVideoPlan,
@@ -746,6 +748,11 @@ export default function EcommerceClonePanel({
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <ReloadOutlined /> : null}
{status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : cloneOutput === "video-outfit" ? "✦ 开始换装" : "✦ 开始生成"}
</button>
{status === "generating" && cloneOutput !== "video" ? (
<button type="button" className="clone-ai-generate clone-ai-generate--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</div>
</>
);
@@ -28,6 +28,7 @@ interface EcommerceDetailPanelProps {
handleDetailAiWrite: () => void;
toggleDetailModule: (id: string) => void;
handleDetailGenerate: () => void;
onCancelGenerate: () => void;
}
export default function EcommerceDetailPanel({
@@ -56,6 +57,7 @@ export default function EcommerceDetailPanel({
handleDetailAiWrite,
toggleDetailModule,
handleDetailGenerate,
onCancelGenerate,
}: EcommerceDetailPanelProps) {
return (
<>
@@ -162,6 +164,11 @@ export default function EcommerceDetailPanel({
{detailStatus === "generating" ? <LoadingOutlined /> : null}
{detailPrimaryLabel}
</button>
{detailStatus === "generating" ? (
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</footer>
</>
);
@@ -35,6 +35,7 @@ interface EcommerceTryOnPanelProps {
setSmartScene: (updater: (current: boolean) => boolean) => void;
setTryOnRatio: (value: string) => void;
handleTryOnGenerate: () => void;
onCancelGenerate: () => void;
}
export default function EcommerceTryOnPanel({
@@ -70,6 +71,7 @@ export default function EcommerceTryOnPanel({
setSmartScene,
setTryOnRatio,
handleTryOnGenerate,
onCancelGenerate,
}: EcommerceTryOnPanelProps) {
return (
<>
@@ -213,6 +215,11 @@ export default function EcommerceTryOnPanel({
{tryOnStatus === "generating" ? <LoadingOutlined /> : null}
{tryOnPrimaryLabel}
</button>
{tryOnStatus === "generating" ? (
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</footer>
</>
);
+2
View File
@@ -7,6 +7,7 @@ import {
DeleteOutlined,
EditOutlined,
HighlightOutlined,
MessageOutlined,
SwapOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
@@ -42,6 +43,7 @@ const tools: MoreTool[] = [
{ id: "camera", title: "镜头实验室", text: "角度、焦段和机位控制", icon: <CameraOutlined />, category: "image", imageTool: "camera", ready: true },
{ id: "upscale", title: "分辨率提升", text: "图片与视频高清超分", icon: <ColumnWidthOutlined />, category: "image", target: "resolutionUpscale", ready: true },
{ id: "watermarkRemoval", title: "去水印", text: "AI 智能去除图片水印和文字", icon: <DeleteOutlined />, category: "image", target: "watermarkRemoval", ready: true },
{ id: "dialogGenerator", title: "交互式对话框生成器", text: "上传背景图,添加可拖拽编辑的对话框", icon: <MessageOutlined />, category: "image", target: "dialogGenerator", ready: true },
{ id: "subtitleRemoval", title: "字幕去除", text: "AI 智能擦除视频字幕", icon: <DeleteOutlined />, category: "video", target: "subtitleRemoval", ready: true },
{ id: "digitalHuman", title: "数字人", text: "参考人像与音频生成口播视频", icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true },
{ id: "characterMix", title: "角色迁移", text: "人物图迁移到参考视频动作", icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true },
+100 -53
View File
@@ -5,10 +5,13 @@ import {
CloseOutlined,
DeleteOutlined,
EditOutlined,
FileImageOutlined,
FolderOpenOutlined,
LockOutlined,
MailOutlined,
MobileOutlined,
PhoneOutlined,
PlayCircleOutlined,
PlusOutlined,
SafetyOutlined,
ShareAltOutlined,
@@ -180,6 +183,19 @@ function formatAssetStatus(status: string | undefined): string {
return status || "资产";
}
function formatAssetType(type: SavedAssetItem["type"]): string {
const labels: Record<string, string> = {
character: "角色",
scene: "场景",
prop: "道具",
video: "视频",
image: "图像",
asset: "资产",
other: "素材",
};
return labels[type] || "素材";
}
function ProfilePage({
session,
usage,
@@ -608,22 +624,50 @@ function ProfilePage({
</div>
);
const renderCardPreview = (
url: string | null | undefined,
type: "image" | "video" | "project" | "asset",
label: string,
) => {
const mediaUrl = typeof url === "string" ? url.trim() : "";
const isVideoPreview = type === "video" || /\.(mp4|webm|mov)(\?|#|$)/i.test(mediaUrl);
const placeholderIcon =
type === "video" ? <PlayCircleOutlined /> : type === "project" ? <FolderOpenOutlined /> : <FileImageOutlined />;
return (
<div className={`profile-page__list-card-preview${mediaUrl ? " has-media" : ""}`} aria-hidden="true">
{mediaUrl ? (
isVideoPreview ? (
<video src={mediaUrl} muted playsInline preload="metadata" />
) : (
<img src={mediaUrl} alt="" loading="lazy" />
)
) : (
<span className="profile-page__list-card-placeholder">{placeholderIcon}</span>
)}
<span className="profile-page__media-badge">{label}</span>
</div>
);
};
const renderActivePanel = () => {
if (activePanel === "works") {
return visibleWorks.length ? (
<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">
<article key={task.id} className="profile-page__list-card profile-page__media-card">
{renderCardPreview(task.outputUrl, task.type === "video" ? "video" : "image", formatTaskType(task.type))}
<div className="profile-page__list-card-body">
<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>
</div>
</article>
))}
</div>
@@ -637,10 +681,11 @@ function ProfilePage({
return projects.length ? (
<div className="profile-page__list-grid motion-stagger">
{projects.map((project) => (
<article key={project.id} className="profile-page__list-card">
<article key={project.id} className="profile-page__list-card profile-page__media-card">
{renderCardPreview(project.thumbnailUrl, "project", "项目")}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head">
<strong>{project.name}</strong>
<span>{formatProfileDate(project.updatedAt)}</span>
{onDeleteProject ? (
<button
type="button"
@@ -655,7 +700,8 @@ function ProfilePage({
<p>{project.description || "最近更新的项目"}</p>
<div className="profile-page__list-card-meta">
<span>{project.storyboardCount} </span>
<span>{project.imageCount} / {project.videoCount} </span>
<span>{formatProfileDate(project.updatedAt)}</span>
</div>
</div>
</article>
))}
@@ -669,16 +715,19 @@ function ProfilePage({
return savedAssets.length ? (
<div className="profile-page__list-grid">
{savedAssets.map((asset) => (
<article key={asset.id} className="profile-page__list-card">
<article key={asset.id} className="profile-page__list-card profile-page__media-card">
{renderCardPreview(asset.imageUrl || asset.url, asset.type === "video" ? "video" : "asset", formatAssetType(asset.type))}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head">
<strong>{asset.name}</strong>
<span>{formatAssetStatus(asset.status)}</span>
</div>
<p>{asset.description}</p>
<div className="profile-page__list-card-meta">
<span>{asset.type}</span>
<span>{formatAssetType(asset.type)}</span>
<span>{formatProfileDate(asset.updatedAt)}</span>
</div>
</div>
</article>
))}
</div>
@@ -791,6 +840,50 @@ function ProfilePage({
</div>
</div>
<div className="profile-page__account-card">
<div className="profile-page__list-tabs">
<button
type="button"
className={accountPanel === "credits" ? "is-active" : ""}
onClick={() => setAccountPanel("credits")}
>
{(totalBalance / 100).toFixed(2)}
</button>
<button
type="button"
className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")}
>
{tasks.length}
</button>
</div>
<div className="profile-page__upload-card profile-page__upload-card--meta">
{accountPanel === "credits" ? (
<>
<span className="profile-page__meta-item">
<small></small>
<strong>{displayName}</strong>
</span>
<span className="profile-page__meta-item">
<small></small>
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
</span>
</>
) : (
<>
<span className="profile-page__meta-item">
<small></small>
<strong>{tasks.length}</strong>
</span>
<span className="profile-page__meta-item">
<small></small>
<strong>{completedTasks.length}</strong>
</span>
</>
)}
</div>
</div>
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
<ShareAltOutlined />
{packageLabel}
@@ -838,52 +931,6 @@ function ProfilePage({
</span>
{renderActivePanel()}
</div>
<div className="profile-page__section">
<div className="profile-page__list-bar">
<div className="profile-page__list-tabs">
<button
type="button"
className={accountPanel === "credits" ? "is-active" : ""}
onClick={() => setAccountPanel("credits")}
>
{(totalBalance / 100).toFixed(2)}
</button>
<button
type="button"
className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")}
>
{tasks.length}
</button>
</div>
</div>
<div className="profile-page__upload-card profile-page__upload-card--meta">
{accountPanel === "credits" ? (
<>
<span className="profile-page__meta-item">
<small></small>
<strong>{displayName}</strong>
</span>
<span className="profile-page__meta-item">
<small></small>
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
</span>
</>
) : (
<>
<span className="profile-page__meta-item">
<small></small>
<strong>{tasks.length}</strong>
</span>
<span className="profile-page__meta-item">
<small></small>
<strong>{completedTasks.length}</strong>
</span>
</>
)}
</div>
</div>
</main>
</div>
</section>
@@ -405,6 +405,7 @@ function ScriptTokensPage() {
<div className="script-eval-v5-page">
{/* Left Panel */}
<aside className="script-eval-v5-left">
<div className="script-eval-v5-left-main">
<div className="script-eval-v5-lp-section">
<div className="script-eval-v5-lp-label"></div>
<div
@@ -511,6 +512,7 @@ function ScriptTokensPage() {
<span></span>
</button>
</div>
</div>
</aside>
{/* Right Area */}
@@ -271,9 +271,8 @@ function TokenUsagePage({
) : null}
<section className="management-metric-cards" aria-label="关键指标">
{metricCards.map((card, index) => (
{metricCards.map((card) => (
<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>
+1
View File
@@ -20,6 +20,7 @@
@import "./pages/studio-layout.css";
@import "./pages/image-workbench.css";
@import "./pages/subtitle-removal.css";
@import "./pages/dialog-generator.css";
@import "./pages/size-template.css";
@import "./pages/script-tokens-v5.css";
@import "./pages/script-tokens.css";
+580
View File
@@ -0,0 +1,580 @@
.dialog-generator-page {
min-height: 100%;
overflow: auto;
background:
radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0, 255, 136, 0.04) 0%, transparent 70%),
radial-gradient(ellipse 60% 50% at 80% 70%, rgba(42, 159, 212, 0.03) 0%, transparent 60%),
linear-gradient(180deg, #070b10 0%, #05080d 100%);
color: #e8eaef;
}
.dialog-generator-shell {
display: grid;
grid-template-columns: minmax(300px, 0.42fr) minmax(0, 0.58fr);
gap: clamp(18px, 2.8vw, 34px);
min-height: var(--shell-content-height, 100vh);
padding: clamp(24px, 4vw, 52px);
}
.dialog-generator-panel,
.dialog-generator-preview-card {
border: 1px solid rgba(0, 255, 136, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
box-shadow:
0 24px 72px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(18px);
}
.dialog-generator-panel {
display: grid;
align-content: start;
gap: 24px;
padding: clamp(22px, 2.6vw, 34px);
}
.dialog-generator-heading {
display: grid;
gap: 12px;
}
.dialog-generator-kicker {
color: #00ff88;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dialog-generator-heading h1 {
margin: 0;
background: linear-gradient(135deg, #00ff88, #22f0c0, #4fc3f7);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-size: clamp(32px, 3.6vw, 56px);
font-weight: 950;
letter-spacing: 0;
line-height: 1.1;
}
.dialog-generator-heading p,
.dialog-generator-hint,
.dialog-generator-preview-head p {
margin: 0;
color: #9aa1b8;
font-size: 15px;
font-weight: 650;
line-height: 1.7;
}
.dialog-generator-section {
display: grid;
gap: 12px;
}
.dialog-generator-section h2 {
margin: 0;
color: #f6f8fb;
font-size: 18px;
font-weight: 900;
}
.dialog-generator-drop {
display: grid;
justify-items: center;
gap: 8px;
min-height: 168px;
border: 1px dashed rgba(0, 255, 136, 0.28);
border-radius: 8px;
background: rgba(0, 255, 136, 0.035);
color: #e8eaef;
padding: 24px;
cursor: pointer;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-drop:hover {
border-color: rgba(0, 255, 136, 0.5);
background: rgba(0, 255, 136, 0.06);
transform: translateY(-1px);
}
.dialog-generator-drop-icon {
font-size: 42px;
}
.dialog-generator-drop strong {
font-size: 16px;
font-weight: 900;
}
.dialog-generator-drop small,
.dialog-generator-style small {
color: #62697f;
font-size: 13px;
font-weight: 700;
}
.dialog-generator-style-list {
display: grid;
gap: 10px;
}
.dialog-generator-color-picker {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.dialog-generator-color {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 38px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #dce3ed;
cursor: pointer;
font-size: 13px;
font-weight: 850;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-color:hover,
.dialog-generator-color.is-active {
border-color: var(--text-color);
background: rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
}
.dialog-generator-color span {
width: 14px;
height: 14px;
border: 1px solid rgba(255, 255, 255, 0.38);
border-radius: 50%;
background: var(--text-color);
box-shadow: 0 0 12px color-mix(in srgb, var(--text-color) 42%, transparent);
}
.dialog-generator-color strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog-generator-style {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 14px;
border: 1px solid rgba(0, 255, 136, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #e8eaef;
padding: 15px 18px;
text-align: left;
cursor: pointer;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-style:hover {
border-color: rgba(0, 255, 136, 0.28);
background: rgba(255, 255, 255, 0.06);
transform: translateX(3px);
}
.dialog-generator-style span:last-child {
display: grid;
gap: 4px;
min-width: 0;
}
.dialog-generator-style strong {
color: #f7fafc;
font-size: 16px;
font-weight: 900;
}
.dialog-generator-swatch {
width: 14px;
height: 14px;
border-radius: 4px;
}
.dialog-generator-swatch.is-white {
border: 1px solid #cbd5e1;
background: #ffffff;
}
.dialog-generator-swatch.is-blue {
background: #165dff;
}
.dialog-generator-swatch.is-amber {
background: #f59e0b;
}
.dialog-generator-swatch.is-gray {
background: #6b7280;
}
.dialog-generator-clear {
min-height: 48px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #e8eaef;
cursor: pointer;
font-size: 15px;
font-weight: 900;
transition:
border-color 180ms ease,
background 180ms ease;
}
.dialog-generator-clear:hover {
border-color: rgba(255, 77, 103, 0.32);
background: rgba(255, 77, 103, 0.1);
}
.dialog-generator-preview-card {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 16px;
min-width: 0;
min-height: 0;
padding: clamp(22px, 2.6vw, 34px);
}
.dialog-generator-preview-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.dialog-generator-preview-head span {
color: #00ff88;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dialog-generator-preview-head h2 {
margin: 4px 0 0;
color: #ffffff;
font-size: clamp(24px, 2vw, 34px);
font-weight: 950;
}
.dialog-generator-preview-head p {
max-width: 440px;
text-align: right;
}
.dialog-generator-preview {
position: relative;
min-height: 520px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
rgba(5, 8, 13, 0.72);
background-size: 32px 32px, 32px 32px, auto;
touch-action: none;
}
.dialog-generator-image {
position: absolute;
inset: 0;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
.dialog-generator-empty {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 12px;
color: #62697f;
text-align: center;
pointer-events: none;
}
.dialog-generator-empty span {
font-size: 52px;
}
.dialog-generator-empty p {
margin: 0;
font-size: 16px;
font-weight: 800;
}
.dialog-generator-bubble {
position: absolute;
z-index: 10;
min-width: 140px;
max-width: 280px;
border-radius: 12px;
padding: 12px 14px;
user-select: none;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
transition: box-shadow 0.2s;
}
.dialog-generator-bubble.is-confirmed {
min-width: 0;
max-width: min(420px, 80%);
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
box-shadow: none;
cursor: move;
}
.dialog-generator-bubble:hover {
box-shadow: 0 6px 32px rgba(0, 0, 0, 0.18);
}
.dialog-generator-bubble.is-confirmed:hover {
box-shadow: none;
}
.dialog-generator-bubble.is-dragging {
z-index: 20;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.22);
}
.dialog-generator-bubble.is-confirmed.is-dragging {
box-shadow: none;
}
.dialog-generator-bubble.style1 {
border: 2px solid #cbd5e1;
background: rgba(255, 255, 255, 0.97);
}
.dialog-generator-bubble.style2 {
border: 2px solid #4f8aff;
border-radius: 16px 16px 4px 16px;
background: rgba(22, 93, 255, 0.95);
}
.dialog-generator-bubble.style3 {
border: 2px solid #f59e0b;
background: rgba(255, 247, 237, 0.97);
}
.dialog-generator-bubble.style4 {
border: 2px solid #6b7280;
border-radius: 4px;
background: rgba(248, 250, 252, 0.97);
}
.dialog-generator-bubble.is-confirmed.style1,
.dialog-generator-bubble.is-confirmed.style2,
.dialog-generator-bubble.is-confirmed.style3,
.dialog-generator-bubble.is-confirmed.style4 {
border: 0;
background: transparent;
}
.dialog-generator-delete {
position: absolute;
top: -8px;
right: -8px;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 2px solid #fff;
border-radius: 50%;
background: #ef4444;
color: #fff;
cursor: pointer;
font-size: 13px;
line-height: 1;
opacity: 0;
transition: opacity 0.15s;
z-index: 5;
}
.dialog-generator-bubble:hover .dialog-generator-delete {
opacity: 1;
}
.dialog-generator-text,
.dialog-generator-text-display {
width: 100%;
border: 0;
outline: none;
background: transparent;
color: var(--dialog-text-color, #1e293b);
padding: 0;
resize: none;
font-family: inherit;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.dialog-generator-text-display {
width: max-content;
max-width: min(420px, 80vw);
color: var(--dialog-text-color, #ffffff);
font-size: clamp(18px, 2.2vw, 30px);
font-weight: 900;
line-height: 1.35;
letter-spacing: 0;
overflow-wrap: anywhere;
text-shadow:
0 2px 8px rgba(0, 0, 0, 0.72),
0 0 1px rgba(0, 0, 0, 0.9);
}
.dialog-generator-text::placeholder {
color: rgba(0, 0, 0, 0.3);
}
.dialog-generator-bubble.style2 .dialog-generator-text,
.dialog-generator-bubble.style2 .dialog-generator-text-display {
color: var(--dialog-text-color, #fff);
}
.dialog-generator-bubble.is-confirmed.style2 .dialog-generator-text-display {
color: var(--dialog-text-color, #7fb4ff);
}
.dialog-generator-bubble.style2 .dialog-generator-text::placeholder {
color: rgba(255, 255, 255, 0.62);
}
.dialog-generator-bubble.style3 .dialog-generator-text,
.dialog-generator-bubble.style3 .dialog-generator-text-display {
color: var(--dialog-text-color, #92400e);
}
.dialog-generator-bubble.is-confirmed.style3 .dialog-generator-text-display {
color: var(--dialog-text-color, #ffd76a);
}
.dialog-generator-bubble.style3 .dialog-generator-text::placeholder {
color: rgba(146, 64, 14, 0.4);
}
.dialog-generator-bubble.style4 .dialog-generator-text,
.dialog-generator-bubble.style4 .dialog-generator-text-display {
color: var(--dialog-text-color, #1f2937);
}
.dialog-generator-bubble.is-confirmed.style4 .dialog-generator-text-display {
color: var(--dialog-text-color, #111827);
text-shadow:
0 1px 0 rgba(255, 255, 255, 0.72),
0 0 8px rgba(255, 255, 255, 0.58);
}
.dialog-generator-bubble-bottom {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 6px;
}
.dialog-generator-confirm {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 6px;
background: #165dff;
color: #fff;
cursor: pointer;
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
transition:
filter 0.15s,
transform 0.15s;
}
.dialog-generator-confirm:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.dialog-generator-bubble.style2 .dialog-generator-confirm {
background: #fff;
color: #165dff;
}
.dialog-generator-bubble.style3 .dialog-generator-confirm {
background: #f59e0b;
}
.dialog-generator-bubble.style4 .dialog-generator-confirm {
background: #6b7280;
}
.dialog-generator-edit-hint {
display: none;
color: rgba(0, 0, 0, 0.36);
font-size: 10px;
font-weight: 700;
}
.dialog-generator-bubble.is-confirmed .dialog-generator-confirm {
display: none;
}
.dialog-generator-bubble.is-confirmed .dialog-generator-edit-hint {
display: inline-block;
}
@media (max-width: 980px) {
.dialog-generator-shell {
grid-template-columns: 1fr;
}
.dialog-generator-preview-head {
align-items: start;
flex-direction: column;
}
.dialog-generator-preview-head p {
max-width: none;
text-align: left;
}
}
@media (max-width: 560px) {
.dialog-generator-shell {
padding: 18px;
}
.dialog-generator-preview {
min-height: 420px;
}
}
+79 -4
View File
@@ -418,6 +418,15 @@
cursor: not-allowed;
}
.product-clone-page[data-tool="set"] .product-set-floating-submit--cancel {
background: #303540;
color: #eef2f6;
}
.product-clone-page[data-tool="set"] .product-set-floating-submit--cancel:hover {
background: #3a4050;
}
.product-clone-page[data-tool="set"] .product-clone-help {
display: none;
}
@@ -3976,6 +3985,7 @@
.product-clone-panel__footer {
display: grid;
align-items: center;
gap: 8px;
border-top: 1px solid #e5e7eb;
padding: 12px 16px;
}
@@ -4000,6 +4010,11 @@
cursor: not-allowed;
}
.product-clone-primary--cancel {
background: #303540;
color: #eef2f6;
}
.product-clone-preview {
display: grid;
align-content: center;
@@ -8599,9 +8614,8 @@
}
.product-clone-page[data-tool="clone"] .clone-ai-logo {
position: sticky;
top: 0;
z-index: 3;
position: static;
z-index: auto;
margin: -18px -18px 2px;
padding: 16px 18px 14px;
border-bottom-color: var(--ecm-line);
@@ -8761,6 +8775,17 @@
filter: none;
}
.product-clone-page[data-tool="clone"] .clone-ai-generate--cancel {
border: 1px solid var(--ecm-line);
background: var(--ecm-inset);
color: var(--ecm-text);
box-shadow: none;
}
.product-clone-page[data-tool="clone"] .clone-ai-generate--cancel:hover:not(:disabled) {
background: var(--ecm-inset-hover);
}
.product-clone-page[data-tool="clone"] .clone-ai-settings-toggle {
border-color: var(--ecm-line-strong);
background: rgba(20, 23, 25, 0.86);
@@ -8986,6 +9011,17 @@
box-shadow: none;
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(.product-clone-primary--cancel, .product-set-floating-submit--cancel) {
border: 1px solid var(--ecm-line);
background: var(--ecm-inset);
color: var(--ecm-text);
box-shadow: none;
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(.product-clone-primary--cancel, .product-set-floating-submit--cancel):hover {
background: var(--ecm-inset-hover);
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) .product-clone-preview {
background:
radial-gradient(circle at 50% 40%, rgba(var(--ecm-accent-rgb), 0.032), transparent 40%),
@@ -9104,7 +9140,7 @@
}
.product-clone-page[data-tool="clone"] .clone-ai-logo {
margin: -14px -14px 0;
margin: 0;
padding: 14px 54px 12px 14px;
}
@@ -9383,3 +9419,42 @@
padding-top: 14px;
}
}
/* Mobile clone header alignment: keep the tool title in normal flow, but attach it to the top nav rhythm. */
@media (max-width: 900px) {
.product-clone-page[data-tool="clone"] {
padding-top: 59px;
}
.product-clone-page[data-tool="clone"] > .product-clone-shell {
min-height: calc(100% - 59px);
}
.product-clone-page[data-tool="clone"] .clone-ai-panel {
padding-top: 0;
}
.product-clone-page[data-tool="clone"] .clone-ai-logo {
margin: 0 -18px 2px;
}
}
@media (max-width: 620px) {
.product-clone-page[data-tool="clone"] .clone-ai-panel {
padding: 0 14px 14px;
}
.product-clone-page[data-tool="clone"] .clone-ai-logo {
margin: 0 -14px 0;
}
}
@media (max-width: 480px) {
.product-clone-page[data-tool="clone"] {
padding-top: 59px;
}
.product-clone-page[data-tool="clone"] > .product-clone-shell {
min-height: calc(100% - 59px);
}
}
+511
View File
@@ -3425,3 +3425,514 @@
font-size: 13px;
}
}
<<<<<<< HEAD
=======
/* Script review left panel overflow guard: keep actions available while history remains scrollable. */
.script-eval-v5-left {
overflow: hidden;
}
.script-eval-v5-left-main {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgb(0 255 136 / 35%) transparent;
}
.script-eval-v5-left-main::-webkit-scrollbar,
.script-eval-v5-history-list::-webkit-scrollbar {
width: 6px;
}
.script-eval-v5-left-main::-webkit-scrollbar-track,
.script-eval-v5-history-list::-webkit-scrollbar-track {
background: transparent;
}
.script-eval-v5-left-main::-webkit-scrollbar-thumb,
.script-eval-v5-history-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgb(0 255 136 / 28%);
}
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
flex: 0 0 auto;
min-height: 210px;
}
.script-eval-v5-left-main .script-eval-v5-history-list {
min-height: 128px;
max-height: clamp(160px, 28vh, 300px);
overflow-y: auto;
}
.script-eval-v5-lp-bottom {
position: static;
z-index: auto;
flex-shrink: 0;
margin-top: 0;
}
@media (max-height: 820px) and (min-width: 901px) {
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
flex-basis: auto;
min-height: 190px;
}
.script-eval-v5-left-main .script-eval-v5-history-list {
min-height: 118px;
max-height: clamp(142px, 23vh, 220px);
}
}
@media (max-width: 900px) {
.script-eval-v5-left-main {
overscroll-behavior: contain;
}
}
@media (max-width: 680px) {
.script-eval-v5-left {
overflow: visible;
}
.script-eval-v5-left-main {
flex: 0 0 auto;
overflow: visible;
}
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
min-height: 224px;
}
.script-eval-v5-left-main .script-eval-v5-history-list {
min-height: 132px;
max-height: min(260px, 42vh);
}
.script-eval-v5-history-empty {
min-height: 118px;
}
}
/* Final commercial polish for the script scoring workspace. */
.script-eval-v5 {
background:
radial-gradient(circle at 12% 0%, rgb(0 255 136 / 5%), transparent 28%),
linear-gradient(180deg, #0d1010 0%, #090b0b 100%);
}
.script-eval-v5-page {
background:
linear-gradient(90deg, rgb(0 255 136 / 4%), transparent 24%),
linear-gradient(180deg, rgb(255 255 255 / 1.8%), transparent 180px);
}
.script-eval-v5-left {
background:
linear-gradient(180deg, rgb(255 255 255 / 4%), transparent 180px),
linear-gradient(90deg, rgb(0 255 136 / 4%), transparent 32%),
var(--v5-panel);
}
.script-eval-v5-left-main {
scroll-padding-block: 18px;
}
.script-eval-v5-left-main .script-eval-v5-lp-section {
flex-shrink: 0;
padding-inline: 22px;
background:
linear-gradient(180deg, rgb(255 255 255 / 1.8%), transparent 80px);
}
.script-eval-v5-left-main .script-eval-v5-lp-section + .script-eval-v5-lp-section {
box-shadow: inset 0 1px 0 rgb(255 255 255 / 2.5%);
}
.script-eval-v5-lp-label {
color: #91a09b;
}
.script-eval-v5-upload-zone {
display: grid;
place-items: center;
overflow: hidden;
isolation: isolate;
}
.script-eval-v5-upload-zone::after {
content: "";
position: absolute;
inset: 1px;
z-index: -1;
border-radius: inherit;
background:
radial-gradient(circle at 50% 18%, rgb(0 255 136 / 11%), transparent 38%),
linear-gradient(180deg, rgb(255 255 255 / 2%), transparent 60%);
opacity: 0.78;
pointer-events: none;
}
.script-eval-v5-upload-zone:focus-visible {
outline: 2px solid rgb(0 255 136 / 42%);
outline-offset: 3px;
}
.script-eval-v5.is-ready .script-eval-v5-upload-zone,
.script-eval-v5.is-complete .script-eval-v5-upload-zone {
border-color: rgb(0 255 136 / 28%);
background:
linear-gradient(180deg, rgb(0 255 136 / 8%), rgb(255 255 255 / 2.5%)),
rgb(255 255 255 / 2.8%);
}
.script-eval-v5-upload-done {
width: min(100%, 320px);
padding: 14px 14px;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%);
}
.script-eval-v5-info-grid {
display: grid;
grid-template-columns: 1fr;
}
.script-eval-v5-info-item {
min-height: 42px;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 3%);
}
.script-eval-v5-info-empty,
.script-eval-v5-history-empty {
color: #82918c;
background:
linear-gradient(180deg, rgb(255 255 255 / 3.2%), rgb(255 255 255 / 1.8%));
}
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
background:
linear-gradient(180deg, rgb(0 255 136 / 3.4%), transparent 92px),
linear-gradient(180deg, rgb(255 255 255 / 1.8%), transparent);
}
.script-eval-v5-history-list {
padding: 2px 8px 2px 0;
}
.script-eval-v5-history-item {
min-height: 68px;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 3%);
}
.script-eval-v5-lp-bottom {
padding: 18px 22px 22px;
background:
linear-gradient(180deg, rgb(255 255 255 / 2.2%), transparent 60px),
#111414;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 3.5%);
}
.script-eval-v5-export-btn {
border-color: rgb(255 255 255 / 7%);
background:
linear-gradient(180deg, rgb(255 255 255 / 3.5%), rgb(255 255 255 / 1.8%)),
#111414;
color: #7f8d88;
}
.script-eval-v5-export-btn:not(:disabled):hover {
border-color: rgb(0 255 136 / 22%);
color: #c7d5d0;
background:
linear-gradient(180deg, rgb(0 255 136 / 8%), rgb(255 255 255 / 2%)),
#111414;
}
.script-eval-v5-eval-btn:disabled,
.script-eval-v5-export-btn:disabled {
opacity: 0.48;
cursor: not-allowed;
}
.script-eval-v5-right-topbar {
backdrop-filter: blur(14px);
background:
linear-gradient(180deg, rgb(18 22 21 / 92%), rgb(12 14 14 / 88%));
}
.script-eval-v5-right-content:not(.is-report) {
background:
radial-gradient(circle at 50% 43%, rgb(0 255 136 / 5%), transparent 32%),
linear-gradient(180deg, transparent, rgb(0 0 0 / 12%));
}
.script-eval-v5-upload-card-title {
color: #f0fff8;
}
.script-eval-v5-upload-card-desc {
max-width: 540px;
color: #96a5a0;
}
.script-eval-v5-statusbar {
background:
linear-gradient(180deg, rgb(17 20 20 / 84%), rgb(10 12 12 / 92%));
}
@media (max-height: 760px) and (min-width: 901px) {
.script-eval-v5-left-main .script-eval-v5-lp-section {
padding-block: 12px;
}
.script-eval-v5-upload-zone {
min-height: 156px;
}
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
min-height: 176px;
}
.script-eval-v5-left-main .script-eval-v5-history-list {
min-height: 110px;
}
}
@media (max-width: 680px) {
.script-eval-v5-left-main .script-eval-v5-lp-section {
padding-inline: 16px;
}
.script-eval-v5-upload-zone {
min-height: 164px;
}
.script-eval-v5-lp-bottom {
padding: 14px 16px 18px;
}
.script-eval-v5-right-content:not(.is-report) {
padding-top: 22px;
}
}
/* Ecommerce-aligned tone pass: restrained dark SaaS surfaces, no depth shadows. */
.script-eval-v5 {
--v5-bg: #0d0d0f;
--v5-bg2: #151719;
--v5-bg3: #181b1d;
--v5-bg4: #1d2022;
--v5-bg5: #222629;
--v5-border: rgba(255, 255, 255, 0.08);
--v5-border2: rgba(255, 255, 255, 0.12);
--v5-panel: #151719;
--v5-panel-2: #181b1d;
--v5-panel-3: #101214;
--v5-line: rgba(255, 255, 255, 0.08);
--v5-line-strong: rgba(0, 255, 136, 0.24);
--v5-green-deep: rgba(0, 255, 136, 0.055);
--v5-green-soft: rgba(0, 255, 136, 0.09);
--v5-green-border: rgba(0, 255, 136, 0.24);
--v5-shadow-soft: none;
--v5-shadow-tight: none;
background:
radial-gradient(circle at 24% 0%, rgba(0, 255, 136, 0.038), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 160px),
var(--v5-bg);
}
.script-eval-v5-page {
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.014), transparent 24%, transparent 76%, rgba(255, 255, 255, 0.012)),
transparent;
}
.script-eval-v5-left,
.script-eval-v5-right {
background: var(--v5-panel);
box-shadow: none;
}
.script-eval-v5-left {
border-right-color: var(--v5-line);
}
.script-eval-v5-left-main .script-eval-v5-lp-section,
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
background: transparent;
border-bottom-color: var(--v5-line);
box-shadow: none;
}
.script-eval-v5-lp-label {
color: #a7b3af;
letter-spacing: 0.02em;
}
.script-eval-v5-lp-label::before {
background: var(--v5-green);
box-shadow: none;
opacity: 0.72;
}
.script-eval-v5-upload-zone,
.script-eval-v5-info-empty,
.script-eval-v5-history-empty,
.script-eval-v5-info-item,
.script-eval-v5-history-item,
.script-eval-v5-loading,
.script-eval-v5-illustration-hit,
.script-eval-report__score-block,
.script-eval-report__chart-card,
.script-eval-report__path-card,
.script-eval-report__finding-group p {
border-color: var(--v5-line);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.032), transparent 58%),
var(--v5-panel-2);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
}
.script-eval-v5-upload-zone {
border-style: dashed;
}
.script-eval-v5-upload-zone::after {
display: none;
}
.script-eval-v5-upload-zone:hover,
.script-eval-v5-upload-zone:focus-visible,
.script-eval-v5.is-ready .script-eval-v5-upload-zone,
.script-eval-v5.is-complete .script-eval-v5-upload-zone {
border-color: var(--v5-green-border);
background:
radial-gradient(circle at 50% 0%, rgba(0, 255, 136, 0.075), transparent 58%),
var(--v5-panel-3);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.028);
}
.script-eval-v5-upload-icon,
.script-eval-v5-upload-card-icon {
border-color: rgba(0, 255, 136, 0.18);
border-radius: 10px;
background: rgba(0, 255, 136, 0.09);
box-shadow: none;
}
.script-eval-v5-upload-btn,
.script-eval-v5-eval-btn {
background: var(--v5-green);
color: #061014;
box-shadow: none;
}
.script-eval-v5-upload-btn:hover,
.script-eval-v5-eval-btn:hover:not(:disabled) {
background: var(--v5-green-dim);
transform: none;
box-shadow: none;
}
.script-eval-v5-upload-done,
.script-eval-v5-history-item.is-active,
.script-eval-v5-error,
.script-eval-report__chart-note,
.script-eval-report__grade {
box-shadow: none;
}
.script-eval-v5-upload-done {
border-color: var(--v5-green-border);
background:
linear-gradient(180deg, rgba(0, 255, 136, 0.085), rgba(0, 255, 136, 0.035)),
var(--v5-panel-2);
}
.script-eval-v5-history-item:hover {
border-color: rgba(255, 255, 255, 0.13);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 58%),
var(--v5-panel-2);
transform: none;
box-shadow: none;
}
.script-eval-v5-history-item.is-active {
border-color: var(--v5-green-border);
background:
linear-gradient(90deg, rgba(0, 255, 136, 0.08), rgba(0, 255, 136, 0.025)),
var(--v5-panel-2);
}
.script-eval-v5-lp-bottom,
.script-eval-v5-right-topbar,
.script-eval-v5-statusbar {
background: rgba(21, 23, 25, 0.96);
border-color: var(--v5-line);
box-shadow: none;
backdrop-filter: none;
}
.script-eval-v5-export-btn,
.script-eval-v5-action-btn,
.script-eval-v5-retry-btn {
border-color: var(--v5-line);
background: rgba(255, 255, 255, 0.035);
color: #aeb8b1;
box-shadow: none;
}
.script-eval-v5-export-btn:hover:not(:disabled),
.script-eval-v5-action-btn:hover,
.script-eval-v5-retry-btn:hover {
border-color: var(--v5-green-border);
background: rgba(0, 255, 136, 0.07);
color: #d9fff0;
}
.script-eval-v5-right-content:not(.is-report) {
background:
radial-gradient(circle at 50% 0%, rgba(0, 255, 136, 0.034), transparent 44%),
transparent;
}
.script-eval-v5-illustration-hit:hover,
.script-eval-v5-illustration-hit:focus-visible {
background:
linear-gradient(180deg, rgba(0, 255, 136, 0.06), transparent 58%),
var(--v5-panel-2);
box-shadow: none;
}
.script-eval-report {
--report-bg: #0d0d0f;
--report-panel: #151719;
--report-panel-2: #101214;
--report-row: #181b1d;
--report-border: rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 180px),
var(--report-bg);
}
.script-eval-report::before,
.script-eval-report::after {
opacity: 0.28;
}
.script-eval-report__bar-fill {
box-shadow: none;
}
.script-eval-v5.is-complete .script-eval-v5-status-dot,
.script-eval-v5.is-ready .script-eval-v5-status-dot {
box-shadow: none;
}
>>>>>>> c1c4086383ddd7c1c8c152c2d5a97a4f432fa260
+2 -2
View File
@@ -202,9 +202,9 @@
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 16px;
min-height: clamp(360px, 40vw, 520px);
min-height: clamp(560px, 52vw, 760px);
}
/* ===== Tool Cards ===== */
File diff suppressed because it is too large Load Diff
+1
View File
@@ -22,6 +22,7 @@ export type WebViewKey =
| "more"
| "watermarkRemoval"
| "subtitleRemoval"
| "dialogGenerator"
| "communityReview"
| "communityCaseAdd"
| "report"
+1 -1
View File
@@ -25,7 +25,7 @@ export default defineConfig(() => ({
drop: ["console", "debugger"],
},
build: {
sourcemap: "hidden",
sourcemap: false,
rollupOptions: {
output: {
manualChunks(id: string) {