Compare commits
12 Commits
f0fed2f0fd
...
90e3b90e34
| Author | SHA1 | Date | |
|---|---|---|---|
| 90e3b90e34 | |||
| 10b8379965 | |||
| c1c4086383 | |||
| 3493f169c0 | |||
| b81128d7ca | |||
| e166722945 | |||
| e8a42dafde | |||
| c4ef9cc6ba | |||
| 05a42ed018 | |||
| 9e7bfdd206 | |||
| fb4011bf1f | |||
| b08a7918da |
@@ -0,0 +1,72 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const failures = [];
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function assertMatch(label, content, pattern) {
|
||||
if (!pattern.test(content)) {
|
||||
failures.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoMatch(label, content, pattern) {
|
||||
if (pattern.test(content)) {
|
||||
failures.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
const serverConnection = read("src/api/serverConnection.ts");
|
||||
const generationClient = read("src/api/aiGenerationClient.ts");
|
||||
const ecommerceVideoService = read("src/features/ecommerce/ecommerceVideoService.ts");
|
||||
const workbenchPersistence = read("src/features/workbench/workbenchResultPersistence.ts");
|
||||
|
||||
assertMatch(
|
||||
"serverConnection must build same-origin /api URLs",
|
||||
serverConnection,
|
||||
/return\s+`\/api\/\$\{cleanPath\}`;/,
|
||||
);
|
||||
assertNoMatch(
|
||||
"frontend generation flow must not use fixed VITE environment config",
|
||||
`${serverConnection}\n${generationClient}`,
|
||||
/\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/,
|
||||
);
|
||||
assertNoMatch(
|
||||
"frontend generation flow must not call provider hosts directly",
|
||||
generationClient,
|
||||
/dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i,
|
||||
);
|
||||
assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/);
|
||||
assertMatch("video generation must go through the app API", generationClient, /buildApiUrl\("ai\/video"\)/);
|
||||
assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/);
|
||||
assertMatch("URL uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-by-url"\)/);
|
||||
assertMatch(
|
||||
"ecommerce video history must durable-copy media before saving",
|
||||
ecommerceVideoService,
|
||||
/buildDurableVideoHistoryPayload\(payload\)/,
|
||||
);
|
||||
assertMatch(
|
||||
"ecommerce video history must filter temporary provider URLs on read",
|
||||
ecommerceVideoService,
|
||||
/items:\s*history\.items\.map\(removeTemporaryHistoryUrls\)/,
|
||||
);
|
||||
assertMatch(
|
||||
"workbench results must persist generated media through OSS",
|
||||
workbenchPersistence,
|
||||
/uploadAssetByUrl\(/,
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error("Mocked generation smoke check failed:");
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Mocked generation smoke check passed.");
|
||||
+5
-1
@@ -48,6 +48,7 @@ const CommunityCaseAddPage = lazy(() => import("./features/community-review/Comm
|
||||
const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage"));
|
||||
const 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":
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -86,6 +86,7 @@ function AppShell({
|
||||
"imageWorkbench",
|
||||
"resolutionUpscale",
|
||||
"digitalHuman",
|
||||
"dialogGenerator",
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
] as WebViewKey[];
|
||||
|
||||
@@ -23,6 +23,7 @@ const NAV_ORDER: string[] = [
|
||||
"resolutionUpscale",
|
||||
"watermarkRemoval",
|
||||
"subtitleRemoval",
|
||||
"dialogGenerator",
|
||||
"digitalHuman",
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
|
||||
@@ -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>支持 JPG、PNG、WEBP 格式</small>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(event) => handleFile(event.target.files?.[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dialog-generator-section">
|
||||
<h2>点击添加文字</h2>
|
||||
<p className="dialog-generator-hint">每点一次即在预览区新增一个可编辑文字图层。</p>
|
||||
<div className="dialog-generator-color-picker" role="radiogroup" aria-label="文字颜色">
|
||||
{textColorOptions.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={`dialog-generator-color${selectedTextColor === item.value ? " is-active" : ""}`}
|
||||
style={{ "--text-color": item.value } as CSSProperties}
|
||||
aria-checked={selectedTextColor === item.value}
|
||||
role="radio"
|
||||
onClick={() => setSelectedTextColor(item.value)}
|
||||
>
|
||||
<span />
|
||||
<strong>{item.label}</strong>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dialog-generator-style-list">
|
||||
{dialogStyles.map((item) => (
|
||||
<button key={item.key} type="button" className="dialog-generator-style" onClick={() => addDialog(item.key)}>
|
||||
<span className={`dialog-generator-swatch ${item.swatchClass}`} />
|
||||
<span>
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.description}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="dialog-generator-clear" onClick={() => setDialogs([])}>
|
||||
清空全部文字
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="dialog-generator-preview-card">
|
||||
<div className="dialog-generator-preview-head">
|
||||
<div>
|
||||
<span>Preview</span>
|
||||
<h2>预览区域</h2>
|
||||
</div>
|
||||
<p>拖动文字定位,输入文字后点击确认,确认后只保留文字图层,双击可重新编辑。</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="dialog-generator-preview"
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={endDrag}
|
||||
onMouseLeave={endDrag}
|
||||
onTouchMove={handleCanvasTouchMove}
|
||||
onTouchEnd={endDrag}
|
||||
>
|
||||
{backgroundUrl ? <div className="dialog-generator-image" style={{ backgroundImage: `url(${backgroundUrl})` }} /> : null}
|
||||
{!backgroundUrl ? (
|
||||
<div className="dialog-generator-empty">
|
||||
<span>🖼</span>
|
||||
<p>上传图片后开始编辑</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{dialogs.map((dialog) => (
|
||||
<div
|
||||
key={dialog.id}
|
||||
data-dialog-id={dialog.id}
|
||||
className={`dialog-generator-bubble ${dialog.style}${dialog.confirmed ? " is-confirmed" : ""}${activeDragId === dialog.id ? " is-dragging" : ""}`}
|
||||
style={{ left: dialog.x, top: dialog.y, "--dialog-text-color": dialog.color } as CSSProperties}
|
||||
onMouseDown={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("textarea,button")) return;
|
||||
startDrag(dialog.id, event.clientX, event.clientY);
|
||||
event.preventDefault();
|
||||
}}
|
||||
onTouchStart={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("textarea,button")) return;
|
||||
const touch = event.touches[0];
|
||||
if (touch) startDrag(dialog.id, touch.clientX, touch.clientY);
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (dialog.confirmed) updateDialog(dialog.id, { confirmed: false });
|
||||
}}
|
||||
>
|
||||
{!dialog.confirmed ? (
|
||||
<button type="button" className="dialog-generator-delete" onClick={() => deleteDialog(dialog.id)} aria-label="删除文字">
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
{dialog.confirmed ? (
|
||||
<div className="dialog-generator-text-display">{dialog.text}</div>
|
||||
) : (
|
||||
<textarea
|
||||
className="dialog-generator-text"
|
||||
rows={2}
|
||||
placeholder="输入文本..."
|
||||
value={dialog.text}
|
||||
onChange={(event) => updateDialog(dialog.id, { text: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
{!dialog.confirmed ? (
|
||||
<div className="dialog-generator-bubble-bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-generator-confirm"
|
||||
onClick={() => {
|
||||
if (dialog.text.trim()) {
|
||||
updateDialog(dialog.id, { text: dialog.text.trim(), confirmed: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
✓ 确认
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default DialogGeneratorPage;
|
||||
@@ -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("9:16") || ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") || ratio.includes("16:9") ? "16:9" : ratio.includes("3:4") || 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) => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
.dialog-generator-page {
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0, 255, 136, 0.04) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 60% 50% at 80% 70%, rgba(42, 159, 212, 0.03) 0%, transparent 60%),
|
||||
linear-gradient(180deg, #070b10 0%, #05080d 100%);
|
||||
color: #e8eaef;
|
||||
}
|
||||
|
||||
.dialog-generator-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 0.42fr) minmax(0, 0.58fr);
|
||||
gap: clamp(18px, 2.8vw, 34px);
|
||||
min-height: var(--shell-content-height, 100vh);
|
||||
padding: clamp(24px, 4vw, 52px);
|
||||
}
|
||||
|
||||
.dialog-generator-panel,
|
||||
.dialog-generator-preview-card {
|
||||
border: 1px solid rgba(0, 255, 136, 0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
box-shadow:
|
||||
0 24px 72px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.dialog-generator-panel {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 24px;
|
||||
padding: clamp(22px, 2.6vw, 34px);
|
||||
}
|
||||
|
||||
.dialog-generator-heading {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog-generator-kicker {
|
||||
color: #00ff88;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dialog-generator-heading h1 {
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #00ff88, #22f0c0, #4fc3f7);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-size: clamp(32px, 3.6vw, 56px);
|
||||
font-weight: 950;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.dialog-generator-heading p,
|
||||
.dialog-generator-hint,
|
||||
.dialog-generator-preview-head p {
|
||||
margin: 0;
|
||||
color: #9aa1b8;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dialog-generator-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog-generator-section h2 {
|
||||
margin: 0;
|
||||
color: #f6f8fb;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.dialog-generator-drop {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
min-height: 168px;
|
||||
border: 1px dashed rgba(0, 255, 136, 0.28);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 255, 136, 0.035);
|
||||
color: #e8eaef;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.dialog-generator-drop:hover {
|
||||
border-color: rgba(0, 255, 136, 0.5);
|
||||
background: rgba(0, 255, 136, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dialog-generator-drop-icon {
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
.dialog-generator-drop strong {
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.dialog-generator-drop small,
|
||||
.dialog-generator-style small {
|
||||
color: #62697f;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dialog-generator-style-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dialog-generator-color-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-generator-color {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #dce3ed;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.dialog-generator-color:hover,
|
||||
.dialog-generator-color.is-active {
|
||||
border-color: var(--text-color);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dialog-generator-color span {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.38);
|
||||
border-radius: 50%;
|
||||
background: var(--text-color);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--text-color) 42%, transparent);
|
||||
}
|
||||
|
||||
.dialog-generator-color strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dialog-generator-style {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #e8eaef;
|
||||
padding: 15px 18px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.dialog-generator-style:hover {
|
||||
border-color: rgba(0, 255, 136, 0.28);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.dialog-generator-style span:last-child {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dialog-generator-style strong {
|
||||
color: #f7fafc;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.dialog-generator-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dialog-generator-swatch.is-white {
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.dialog-generator-swatch.is-blue {
|
||||
background: #165dff;
|
||||
}
|
||||
|
||||
.dialog-generator-swatch.is-amber {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.dialog-generator-swatch.is-gray {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.dialog-generator-clear {
|
||||
min-height: 48px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e8eaef;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background 180ms ease;
|
||||
}
|
||||
|
||||
.dialog-generator-clear:hover {
|
||||
border-color: rgba(255, 77, 103, 0.32);
|
||||
background: rgba(255, 77, 103, 0.1);
|
||||
}
|
||||
|
||||
.dialog-generator-preview-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: clamp(22px, 2.6vw, 34px);
|
||||
}
|
||||
|
||||
.dialog-generator-preview-head {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dialog-generator-preview-head span {
|
||||
color: #00ff88;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dialog-generator-preview-head h2 {
|
||||
margin: 4px 0 0;
|
||||
color: #ffffff;
|
||||
font-size: clamp(24px, 2vw, 34px);
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.dialog-generator-preview-head p {
|
||||
max-width: 440px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dialog-generator-preview {
|
||||
position: relative;
|
||||
min-height: 520px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||
rgba(5, 8, 13, 0.72);
|
||||
background-size: 32px 32px, 32px 32px, auto;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.dialog-generator-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.dialog-generator-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 12px;
|
||||
color: #62697f;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dialog-generator-empty span {
|
||||
font-size: 52px;
|
||||
}
|
||||
|
||||
.dialog-generator-empty p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
min-width: 140px;
|
||||
max-width: 280px;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
user-select: none;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed {
|
||||
min-width: 0;
|
||||
max-width: min(420px, 80%);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble:hover {
|
||||
box-shadow: 0 6px 32px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-dragging {
|
||||
z-index: 20;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed.is-dragging {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style1 {
|
||||
border: 2px solid #cbd5e1;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style2 {
|
||||
border: 2px solid #4f8aff;
|
||||
border-radius: 16px 16px 4px 16px;
|
||||
background: rgba(22, 93, 255, 0.95);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style3 {
|
||||
border: 2px solid #f59e0b;
|
||||
background: rgba(255, 247, 237, 0.97);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style4 {
|
||||
border: 2px solid #6b7280;
|
||||
border-radius: 4px;
|
||||
background: rgba(248, 250, 252, 0.97);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed.style1,
|
||||
.dialog-generator-bubble.is-confirmed.style2,
|
||||
.dialog-generator-bubble.is-confirmed.style3,
|
||||
.dialog-generator-bubble.is-confirmed.style4 {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-generator-delete {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble:hover .dialog-generator-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dialog-generator-text,
|
||||
.dialog-generator-text-display {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--dialog-text-color, #1e293b);
|
||||
padding: 0;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.dialog-generator-text-display {
|
||||
width: max-content;
|
||||
max-width: min(420px, 80vw);
|
||||
color: var(--dialog-text-color, #ffffff);
|
||||
font-size: clamp(18px, 2.2vw, 30px);
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.72),
|
||||
0 0 1px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.dialog-generator-text::placeholder {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style2 .dialog-generator-text,
|
||||
.dialog-generator-bubble.style2 .dialog-generator-text-display {
|
||||
color: var(--dialog-text-color, #fff);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed.style2 .dialog-generator-text-display {
|
||||
color: var(--dialog-text-color, #7fb4ff);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style2 .dialog-generator-text::placeholder {
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style3 .dialog-generator-text,
|
||||
.dialog-generator-bubble.style3 .dialog-generator-text-display {
|
||||
color: var(--dialog-text-color, #92400e);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed.style3 .dialog-generator-text-display {
|
||||
color: var(--dialog-text-color, #ffd76a);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style3 .dialog-generator-text::placeholder {
|
||||
color: rgba(146, 64, 14, 0.4);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style4 .dialog-generator-text,
|
||||
.dialog-generator-bubble.style4 .dialog-generator-text-display {
|
||||
color: var(--dialog-text-color, #1f2937);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed.style4 .dialog-generator-text-display {
|
||||
color: var(--dialog-text-color, #111827);
|
||||
text-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.72),
|
||||
0 0 8px rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.dialog-generator-confirm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #165dff;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
transition:
|
||||
filter 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.dialog-generator-confirm:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style2 .dialog-generator-confirm {
|
||||
background: #fff;
|
||||
color: #165dff;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style3 .dialog-generator-confirm {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style4 .dialog-generator-confirm {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.dialog-generator-edit-hint {
|
||||
display: none;
|
||||
color: rgba(0, 0, 0, 0.36);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed .dialog-generator-confirm {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed .dialog-generator-edit-hint {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.dialog-generator-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dialog-generator-preview-head {
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-generator-preview-head p {
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.dialog-generator-shell {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.dialog-generator-preview {
|
||||
min-height: 420px;
|
||||
}
|
||||
}
|
||||
@@ -418,6 +418,15 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -22,6 +22,7 @@ export type WebViewKey =
|
||||
| "more"
|
||||
| "watermarkRemoval"
|
||||
| "subtitleRemoval"
|
||||
| "dialogGenerator"
|
||||
| "communityReview"
|
||||
| "communityCaseAdd"
|
||||
| "report"
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ export default defineConfig(() => ({
|
||||
drop: ["console", "debugger"],
|
||||
},
|
||||
build: {
|
||||
sourcemap: "hidden",
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id: string) {
|
||||
|
||||
Reference in New Issue
Block a user