fix: 修复多个运行时崩溃和功能bug,优化画布连接线和剧本评分
- 修复 EcommercePage generateEcommerceImage 调用不存在变量导致运行时崩溃 - 修复 DigitalHumanPage/ImageWorkbenchPage 变量名错误导致页面不可用 - 修复 ecommerceVideoService token 读取用错 key 导致请求 401 - 修复画布连接线在弹窗出现后仍跟随鼠标的问题 - 剧本评分 .docx 文件改为服务端 mammoth 解析(新增 /api/files/extract-text) - ErrorBoundary 加 key 支持切换页面时自动重置 - Vite proxy 改为指向公网域名 omniai.net.cn - 新增视频生成历史记录面板和删除确认弹窗 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1224,7 +1224,7 @@ function App() {
|
|||||||
onMarkNotificationRead={handleMarkNotificationRead}
|
onMarkNotificationRead={handleMarkNotificationRead}
|
||||||
onMarkAllNotificationsRead={handleMarkAllNotificationsRead}
|
onMarkAllNotificationsRead={handleMarkAllNotificationsRead}
|
||||||
>
|
>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary key={activeView}>
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<div className="page-loading-center">
|
<div className="page-loading-center">
|
||||||
<div className="page-loading-spinner" />
|
<div className="page-loading-spinner" />
|
||||||
|
|||||||
@@ -2646,7 +2646,23 @@ function CanvasPage({
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
})()
|
})()
|
||||||
: null;
|
: connectionDropMenu
|
||||||
|
? (() => {
|
||||||
|
const source = getNodePortPoint(connectionDropMenu.sourcePort);
|
||||||
|
const target = getCanvasWorldPointFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
||||||
|
return source
|
||||||
|
? {
|
||||||
|
id: "pending-link-preview",
|
||||||
|
sourceX: source.x,
|
||||||
|
sourceY: source.y,
|
||||||
|
targetX: target.x,
|
||||||
|
targetY: target.y,
|
||||||
|
sourceSide: connectionDropMenu.sourcePort.side,
|
||||||
|
targetSide: null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
const openCanvasAddNodeMenu = useCallback((clientX: number, clientY: number) => {
|
const openCanvasAddNodeMenu = useCallback((clientX: number, clientY: number) => {
|
||||||
const menuPosition = positionFloatingMenu(clientX, clientY, 260, 390, 0);
|
const menuPosition = positionFloatingMenu(clientX, clientY, 260, 390, 0);
|
||||||
@@ -2816,6 +2832,8 @@ function CanvasPage({
|
|||||||
originTop: event.clientY,
|
originTop: event.clientY,
|
||||||
sourcePort: connectorDrag.port,
|
sourcePort: connectorDrag.port,
|
||||||
});
|
});
|
||||||
|
setPendingLinkPort(null);
|
||||||
|
setPendingLinkPreviewPoint(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
clearPendingConnector();
|
clearPendingConnector();
|
||||||
@@ -2840,7 +2858,7 @@ function CanvasPage({
|
|||||||
}, [selectedNode]);
|
}, [selectedNode]);
|
||||||
|
|
||||||
const handleCanvasMouseMove = (event: MouseEvent<HTMLElement>) => {
|
const handleCanvasMouseMove = (event: MouseEvent<HTMLElement>) => {
|
||||||
if (!pendingLinkPort) return;
|
if (!pendingLinkPort || connectionDropMenu) return;
|
||||||
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
|
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -5534,11 +5552,6 @@ function CanvasPage({
|
|||||||
role="menu"
|
role="menu"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
onMouseMove={(event) => {
|
|
||||||
if (pendingLinkPort) {
|
|
||||||
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="studio-canvas-add-node-menu__title">新建节点并连接</div>
|
<div className="studio-canvas-add-node-menu__title">新建节点并连接</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -114,12 +114,12 @@ function DigitalHumanPage({
|
|||||||
keepaliveRestoredRef.current = true;
|
keepaliveRestoredRef.current = true;
|
||||||
const saved = loadToolTaskState("digital-human");
|
const saved = loadToolTaskState("digital-human");
|
||||||
if (!saved || saved.resultUrl) return;
|
if (!saved || saved.resultUrl) return;
|
||||||
setIsProcessing(true);
|
setIsCreating(true);
|
||||||
cancelRef.current = false;
|
cancelRef.current = false;
|
||||||
pollRunRef.current += 1;
|
pollRunRef.current += 1;
|
||||||
setActiveTaskId(saved.taskId);
|
setActiveTaskId(saved.taskId);
|
||||||
void waitForTaskResult(saved.taskId).catch(() => {});
|
void waitForTaskResult(saved.taskId).catch(() => {});
|
||||||
setStatus("正在恢复数字人任务...");
|
setNotice("正在恢复数字人任务...");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const ecommerceSlide4 = `${OSS_MUBAN}/slide-4.png`;
|
|||||||
const ecommerceSlide5 = `${OSS_MUBAN}/slide-5.png`;
|
const ecommerceSlide5 = `${OSS_MUBAN}/slide-5.png`;
|
||||||
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
|
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
|
||||||
import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace";
|
import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace";
|
||||||
|
import EcommerceVideoHistoryPanel from "./panels/EcommerceVideoHistoryPanel";
|
||||||
import EcommerceDetailPanel from "./panels/EcommerceDetailPanel";
|
import EcommerceDetailPanel from "./panels/EcommerceDetailPanel";
|
||||||
import EcommerceSetPanel from "./panels/EcommerceSetPanel";
|
import EcommerceSetPanel from "./panels/EcommerceSetPanel";
|
||||||
import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
|
import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
|
||||||
@@ -787,6 +788,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
|
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
|
||||||
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
|
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
|
||||||
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>("detail");
|
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>("detail");
|
||||||
|
const [videoHistoryVisible, setVideoHistoryVisible] = useState(false);
|
||||||
|
const [videoPlanTrigger, setVideoPlanTrigger] = useState(0);
|
||||||
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
|
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
|
||||||
const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null);
|
const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null);
|
||||||
const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false);
|
const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false);
|
||||||
@@ -1413,7 +1416,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
pRatio: string,
|
pRatio: string,
|
||||||
pLanguage: string,
|
pLanguage: string,
|
||||||
pMarket: string,
|
pMarket: string,
|
||||||
setStatusFn: (status: "generating" | "done" | "idle") => void,
|
setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void,
|
||||||
setResultFn: (urls: string[]) => void,
|
setResultFn: (urls: string[]) => void,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
setStatusFn("generating");
|
setStatusFn("generating");
|
||||||
@@ -1486,11 +1489,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
|
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
|
||||||
resultFn?: (results: CloneImageItem[]) => void,
|
resultFn?: (results: CloneImageItem[]) => void,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
setStatusFn("generating");
|
statusFn?.("generating");
|
||||||
try {
|
try {
|
||||||
const referenceUrls = await uploadCloneImages(images);
|
const referenceUrls = await uploadCloneImages(images);
|
||||||
if (!referenceUrls.length) {
|
if (!referenceUrls.length) {
|
||||||
setStatusFn("idle");
|
statusFn?.("idle");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1514,22 +1517,22 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (resultUrl) {
|
if (resultUrl) {
|
||||||
setResultFn([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
|
resultFn?.([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
|
||||||
setStatusFn("done");
|
statusFn?.("done");
|
||||||
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
|
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
|
||||||
} else {
|
} else {
|
||||||
setStatusFn("idle");
|
statusFn?.("idle");
|
||||||
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
|
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ServerRequestError && err.status === 402) {
|
if (err instanceof ServerRequestError && err.status === 402) {
|
||||||
setResultFn([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
|
resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
|
||||||
toast.error("余额不足,请充值后继续");
|
toast.error("余额不足,请充值后继续");
|
||||||
} else {
|
} else {
|
||||||
const msg = err instanceof Error ? err.message : "生成失败";
|
const msg = err instanceof Error ? err.message : "生成失败";
|
||||||
toast.error(msg);
|
toast.error(msg);
|
||||||
}
|
}
|
||||||
setStatusFn("failed");
|
statusFn?.("failed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1563,10 +1566,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { waitForTask } = await import("../../api/taskSubscription");
|
const { waitForTask } = await import("../../api/taskSubscription");
|
||||||
abortRef.current = { current: false };
|
imageAbortRef.current = { current: false };
|
||||||
const resultUrl = await waitForTask(taskId, { abortRef: abortRef.current });
|
const resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
|
||||||
if (resultUrl) {
|
if (resultUrl) {
|
||||||
setResults([{ id: crypto.randomUUID(), name: "换装视频", src: resultUrl, type: "video", size: 0 }]);
|
setResults([{ id: crypto.randomUUID(), src: resultUrl, label: "换装视频" }]);
|
||||||
}
|
}
|
||||||
setStatus("done");
|
setStatus("done");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1602,7 +1605,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
void generateEcommerceImage(
|
void generateEcommerceImage(
|
||||||
cloneOutput, productImages, requirement,
|
cloneOutput, productImages, requirement,
|
||||||
platform, ratio, language, market,
|
platform, ratio, language, market,
|
||||||
(s) => setStatus(s as ProductCloneStatus), setResults,
|
undefined,
|
||||||
|
(s: string) => setStatus(s as ProductCloneStatus), setResults,
|
||||||
);
|
);
|
||||||
lastFailedActionRef.current = () => handleGenerate();
|
lastFailedActionRef.current = () => handleGenerate();
|
||||||
}
|
}
|
||||||
@@ -1681,7 +1685,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
void generateEcommerceImage(
|
void generateEcommerceImage(
|
||||||
"detail", detailProductImages, detailRequirement,
|
"detail", detailProductImages, detailRequirement,
|
||||||
detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket,
|
detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket,
|
||||||
(s) => setDetailStatus(s as DetailStatus),
|
undefined,
|
||||||
|
(s: string) => setDetailStatus(s as DetailStatus),
|
||||||
(res) => setDetailResultUrl(res[0]?.src ?? null),
|
(res) => setDetailResultUrl(res[0]?.src ?? null),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1905,6 +1910,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
handleGenerate={handleGenerate}
|
handleGenerate={handleGenerate}
|
||||||
formatRatioDisplayValue={formatRatioDisplayValue}
|
formatRatioDisplayValue={formatRatioDisplayValue}
|
||||||
setVideoOutfitFiles={(video, ref) => { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }}
|
setVideoOutfitFiles={(video, ref) => { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }}
|
||||||
|
onStartVideoPlan={() => setVideoPlanTrigger((n) => n + 1)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2404,6 +2410,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
durationSeconds={cloneVideoDuration}
|
durationSeconds={cloneVideoDuration}
|
||||||
resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"}
|
resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"}
|
||||||
onRequestLogin={() => ((_props as Record<string, unknown>).isAuthenticated ? undefined : (window.location.hash = "#/login"))}
|
onRequestLogin={() => ((_props as Record<string, unknown>).isAuthenticated ? undefined : (window.location.hash = "#/login"))}
|
||||||
|
onOpenHistory={() => setVideoHistoryVisible(true)}
|
||||||
|
triggerPlan={videoPlanTrigger}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
) : cloneOutput === "video-outfit" && results.length > 0 && results[0].type === "video" ? (
|
) : cloneOutput === "video-outfit" && results.length > 0 && results[0].type === "video" ? (
|
||||||
@@ -2472,6 +2480,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<EcommerceVideoHistoryPanel
|
||||||
|
visible={videoHistoryVisible}
|
||||||
|
onClose={() => setVideoHistoryVisible(false)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import {
|
|||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
FolderAddOutlined,
|
FolderAddOutlined,
|
||||||
|
HistoryOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
PlayCircleOutlined,
|
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
StopOutlined,
|
StopOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks } from "./ecommerceVideoService";
|
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks, saveVideoHistory } from "./ecommerceVideoService";
|
||||||
import {
|
import {
|
||||||
PLAN_STEP_LABELS,
|
PLAN_STEP_LABELS,
|
||||||
PLAN_STEPS_DISPLAY,
|
PLAN_STEPS_DISPLAY,
|
||||||
@@ -40,6 +40,8 @@ interface EcommerceVideoWorkspaceProps {
|
|||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
resolution: string;
|
resolution: string;
|
||||||
onRequestLogin?: () => void;
|
onRequestLogin?: () => void;
|
||||||
|
onOpenHistory?: () => void;
|
||||||
|
triggerPlan?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_STEPS: PlanStep[] = [
|
const ALL_STEPS: PlanStep[] = [
|
||||||
@@ -101,6 +103,8 @@ export default function EcommerceVideoWorkspace({
|
|||||||
durationSeconds,
|
durationSeconds,
|
||||||
resolution,
|
resolution,
|
||||||
onRequestLogin,
|
onRequestLogin,
|
||||||
|
onOpenHistory,
|
||||||
|
triggerPlan,
|
||||||
}: EcommerceVideoWorkspaceProps) {
|
}: EcommerceVideoWorkspaceProps) {
|
||||||
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
|
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
|
||||||
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
|
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
|
||||||
@@ -160,6 +164,32 @@ export default function EcommerceVideoWorkspace({
|
|||||||
}
|
}
|
||||||
}, [stage, scenes, planResult]);
|
}, [stage, scenes, planResult]);
|
||||||
|
|
||||||
|
// ── External trigger: start plan from parent ────────────────
|
||||||
|
const triggerPlanPrevRef = useRef(triggerPlan);
|
||||||
|
useEffect(() => {
|
||||||
|
if (triggerPlan != null && triggerPlan !== triggerPlanPrevRef.current) {
|
||||||
|
triggerPlanPrevRef.current = triggerPlan;
|
||||||
|
void handlePlan();
|
||||||
|
}
|
||||||
|
}, [triggerPlan]);
|
||||||
|
|
||||||
|
// ── Auto-save: persist completed results to server ──────────
|
||||||
|
const historySavedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (stage !== "completed") { historySavedRef.current = false; return; }
|
||||||
|
if (historySavedRef.current) return;
|
||||||
|
if (!planResult || !scenes.length) return;
|
||||||
|
historySavedRef.current = true;
|
||||||
|
const title = planResult.storyboard?.video_title || planResult.summary?.product_name || "电商广告视频";
|
||||||
|
saveVideoHistory({
|
||||||
|
title,
|
||||||
|
config: { platform, aspectRatio, durationSeconds, resolution },
|
||||||
|
plan: planResult as unknown as Record<string, unknown>,
|
||||||
|
scenes: scenes.map((s) => ({ sceneId: s.sceneId, prompt: s.prompt, imageUrl: s.imageUrl, videoUrl: s.resultUrl })),
|
||||||
|
sourceImageUrls,
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [stage, planResult, scenes, sourceImageUrls, platform, aspectRatio, durationSeconds, resolution]);
|
||||||
|
|
||||||
// ── Keep-alive: resume polling for running tasks ──────────
|
// ── Keep-alive: resume polling for running tasks ──────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (keepalivePollingStartedRef.current) return;
|
if (keepalivePollingStartedRef.current) return;
|
||||||
@@ -568,6 +598,11 @@ export default function EcommerceVideoWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ecom-video-flowbar__actions">
|
<div className="ecom-video-flowbar__actions">
|
||||||
|
{onOpenHistory ? (
|
||||||
|
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" onClick={onOpenHistory} title="生成记录">
|
||||||
|
<HistoryOutlined />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
|
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
|
||||||
{stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? (
|
{stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? (
|
||||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||||
@@ -575,12 +610,6 @@ export default function EcommerceVideoWorkspace({
|
|||||||
<ReloadOutlined /> 继续
|
<ReloadOutlined /> 继续
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
|
|
||||||
<button type="button" className="ecom-video-flow-action"
|
|
||||||
onClick={() => void handlePlan()} title={planProgress ? "从头重新策划" : "一键策划"}>
|
|
||||||
<PlayCircleOutlined />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{stage === "planned" || stage === "imaged" ? (
|
{stage === "planned" || stage === "imaged" ? (
|
||||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||||
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
|
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
|
||||||
|
|||||||
@@ -258,3 +258,73 @@ export function buildSceneTasks(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Video History API ──────────────────────────────────
|
||||||
|
|
||||||
|
export interface VideoHistoryScene {
|
||||||
|
sceneId: number;
|
||||||
|
prompt: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
videoUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoHistoryItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
scenes: VideoHistoryScene[];
|
||||||
|
sourceImageUrls: string[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoHistoryListResponse {
|
||||||
|
items: VideoHistoryItem[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { getStoredToken } from "../../api/serverConnection";
|
||||||
|
|
||||||
|
const API_BASE = "/api/ai/ecommerce/video-history";
|
||||||
|
|
||||||
|
function getAuthHeaders(): Record<string, string> {
|
||||||
|
const token = getStoredToken();
|
||||||
|
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 }> {
|
||||||
|
const res = await fetch(API_BASE, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("保存历史记录失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVideoHistory(
|
||||||
|
limit = 20,
|
||||||
|
offset = 0,
|
||||||
|
): Promise<VideoHistoryListResponse> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}?limit=${limit}&offset=${offset}`,
|
||||||
|
{ headers: getAuthHeaders() },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("获取历史记录失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteVideoHistory(id: number): Promise<void> {
|
||||||
|
const res = await fetch(`${API_BASE}/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("删除失败");
|
||||||
|
}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ interface EcommerceClonePanelProps {
|
|||||||
handleGenerate: () => void;
|
handleGenerate: () => void;
|
||||||
formatRatioDisplayValue: (value: string) => string;
|
formatRatioDisplayValue: (value: string) => string;
|
||||||
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
|
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
|
||||||
|
onStartVideoPlan?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EcommerceClonePanel({
|
export default function EcommerceClonePanel({
|
||||||
@@ -198,6 +199,7 @@ export default function EcommerceClonePanel({
|
|||||||
handleGenerate,
|
handleGenerate,
|
||||||
formatRatioDisplayValue,
|
formatRatioDisplayValue,
|
||||||
setVideoOutfitFiles,
|
setVideoOutfitFiles,
|
||||||
|
onStartVideoPlan,
|
||||||
}: EcommerceClonePanelProps) {
|
}: EcommerceClonePanelProps) {
|
||||||
const videoOutfitVideoRef = useRef<HTMLInputElement>(null);
|
const videoOutfitVideoRef = useRef<HTMLInputElement>(null);
|
||||||
const videoOutfitRefRef = useRef<HTMLInputElement>(null);
|
const videoOutfitRefRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -694,6 +696,12 @@ export default function EcommerceClonePanel({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{cloneOutput === "video" && onStartVideoPlan ? (
|
||||||
|
<button type="button" className="clone-ai-generate" onClick={onStartVideoPlan}>
|
||||||
|
✦ 一键策划
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{cloneOutput === "video-outfit" ? (
|
{cloneOutput === "video-outfit" ? (
|
||||||
<section className="clone-ai-video-panel" aria-label="视频换装">
|
<section className="clone-ai-video-panel" aria-label="视频换装">
|
||||||
<div className="clone-ai-video-section">
|
<div className="clone-ai-video-section">
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
CloseOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
HistoryOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import {
|
||||||
|
fetchVideoHistory,
|
||||||
|
deleteVideoHistory,
|
||||||
|
type VideoHistoryItem,
|
||||||
|
} from "../ecommerceVideoService";
|
||||||
|
|
||||||
|
interface EcommerceVideoHistoryPanelProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EcommerceVideoHistoryPanel({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
}: EcommerceVideoHistoryPanelProps) {
|
||||||
|
const [items, setItems] = useState<VideoHistoryItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [previewMedia, setPreviewMedia] = useState<{
|
||||||
|
url: string;
|
||||||
|
type: "image" | "video";
|
||||||
|
} | null>(null);
|
||||||
|
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
||||||
|
const limit = 10;
|
||||||
|
|
||||||
|
const load = useCallback(async (off: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetchVideoHistory(limit, off);
|
||||||
|
setItems(res.items);
|
||||||
|
setTotal(res.total);
|
||||||
|
setOffset(off);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) load(0);
|
||||||
|
}, [visible, load]);
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await deleteVideoHistory(id);
|
||||||
|
setItems((prev) => prev.filter((i) => i.id !== id));
|
||||||
|
setTotal((t) => t - 1);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
setConfirmDeleteId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
const currentPage = Math.floor(offset / limit) + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="ecom-video-history-panel">
|
||||||
|
<div className="ecom-video-history-panel__header">
|
||||||
|
<HistoryOutlined />
|
||||||
|
<span>生成记录</span>
|
||||||
|
<button className="ecom-video-history-panel__close" onClick={onClose}>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ecom-video-history-panel__body">
|
||||||
|
{loading && !items.length ? (
|
||||||
|
<div className="ecom-video-history-panel__empty">
|
||||||
|
<LoadingOutlined style={{ fontSize: 24 }} />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : !items.length ? (
|
||||||
|
<div className="ecom-video-history-panel__empty">
|
||||||
|
<HistoryOutlined style={{ fontSize: 32, opacity: 0.3 }} />
|
||||||
|
<span>暂无生成记录</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<div key={item.id} className="ecom-video-history-card">
|
||||||
|
<div className="ecom-video-history-card__header">
|
||||||
|
<span className="ecom-video-history-card__title">
|
||||||
|
{item.title || "未命名"}
|
||||||
|
</span>
|
||||||
|
<span className="ecom-video-history-card__date">
|
||||||
|
{new Date(item.createdAt).toLocaleDateString("zh-CN")}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="ecom-video-history-card__delete"
|
||||||
|
onClick={() => setConfirmDeleteId(item.id)}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ecom-video-history-card__scenes">
|
||||||
|
{item.scenes.map((scene, idx) => (
|
||||||
|
<div key={idx} className="ecom-video-history-card__scene">
|
||||||
|
{scene.imageUrl && (
|
||||||
|
<img
|
||||||
|
src={scene.imageUrl}
|
||||||
|
alt={`分镜${idx + 1}`}
|
||||||
|
onClick={() =>
|
||||||
|
setPreviewMedia({ url: scene.imageUrl!, type: "image" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{scene.videoUrl && (
|
||||||
|
<div
|
||||||
|
className="ecom-video-history-card__video-thumb"
|
||||||
|
onClick={() =>
|
||||||
|
setPreviewMedia({ url: scene.videoUrl!, type: "video" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PlayCircleOutlined />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="ecom-video-history-panel__pager">
|
||||||
|
<button disabled={currentPage <= 1} onClick={() => load(offset - limit)}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span>{currentPage}/{totalPages}</span>
|
||||||
|
<button disabled={currentPage >= totalPages} onClick={() => load(offset + limit)}>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirmDeleteId !== null && (
|
||||||
|
<div className="ecom-video-confirm-dialog-backdrop" onClick={() => setConfirmDeleteId(null)}>
|
||||||
|
<div className="ecom-video-confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<ExclamationCircleOutlined className="ecom-video-confirm-dialog__icon" />
|
||||||
|
<p className="ecom-video-confirm-dialog__text">
|
||||||
|
确定要删除这条记录吗?相关的图片和视频文件也将被永久删除,此操作不可恢复。
|
||||||
|
</p>
|
||||||
|
<div className="ecom-video-confirm-dialog__actions">
|
||||||
|
<button onClick={() => setConfirmDeleteId(null)}>取消</button>
|
||||||
|
<button className="is-danger" onClick={() => handleDelete(confirmDeleteId)}>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewMedia && (
|
||||||
|
<div
|
||||||
|
className="ecom-video-preview-overlay"
|
||||||
|
onClick={() => setPreviewMedia(null)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="ecom-video-preview-overlay__close"
|
||||||
|
onClick={() => setPreviewMedia(null)}
|
||||||
|
>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
{previewMedia.type === "image" ? (
|
||||||
|
<img src={previewMedia.url} alt="preview" />
|
||||||
|
) : (
|
||||||
|
<video src={previewMedia.url} controls autoPlay />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -148,22 +148,21 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
|||||||
keepaliveRestoredRef.current = true;
|
keepaliveRestoredRef.current = true;
|
||||||
const saved = loadToolTaskState("imagewb");
|
const saved = loadToolTaskState("imagewb");
|
||||||
if (!saved || saved.resultUrl) return;
|
if (!saved || saved.resultUrl) return;
|
||||||
setIsGenerating(true);
|
setGenerating(true);
|
||||||
abortRef.current = false;
|
abortRef.current = false;
|
||||||
taskIdRef.current = saved.taskId;
|
taskIdRef.current = saved.taskId;
|
||||||
void waitForTask(saved.taskId, {
|
void waitForTask(saved.taskId, {
|
||||||
onProgress: (e) => {
|
onProgress: (e) => {
|
||||||
setTaskProgress(Math.max(0, Math.min(100, Math.trunc(e.progress || 0))));
|
|
||||||
setStatus(`${e.status} / ${e.progress}%`);
|
setStatus(`${e.status} / ${e.progress}%`);
|
||||||
if (e.status === "completed" && e.resultUrl) {
|
if (e.status === "completed" && e.resultUrl) {
|
||||||
setResultImages([e.resultUrl]);
|
setResultImages([e.resultUrl]);
|
||||||
clearToolTaskState("imagewb");
|
clearToolTaskState("imagewb");
|
||||||
setIsGenerating(false);
|
setGenerating(false);
|
||||||
setStatus("恢复任务完成");
|
setStatus("恢复任务完成");
|
||||||
}
|
}
|
||||||
if (e.status === "failed") {
|
if (e.status === "failed") {
|
||||||
clearToolTaskState("imagewb");
|
clearToolTaskState("imagewb");
|
||||||
setIsGenerating(false);
|
setGenerating(false);
|
||||||
setStatus("恢复任务失败");
|
setStatus("恢复任务失败");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||||
import { evaluateScript } from "../../api/scriptEvalClient";
|
import { evaluateScript } from "../../api/scriptEvalClient";
|
||||||
|
import { buildApiUrl, getStoredToken } from "../../api/serverConnection";
|
||||||
import { useSessionStore } from "../../stores";
|
import { useSessionStore } from "../../stores";
|
||||||
|
|
||||||
interface ScoreDimension {
|
interface ScoreDimension {
|
||||||
@@ -175,61 +176,6 @@ function normalizeUploadedText(raw: string, ext: string): string {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractDocxText(bytes: Uint8Array): Promise<string> {
|
|
||||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
||||||
const entries: Array<{ name: string; offset: number; size: number; compressed: boolean }> = [];
|
|
||||||
let pos = 0;
|
|
||||||
while (pos < bytes.length - 30) {
|
|
||||||
if (view.getUint32(pos, true) !== 0x04034b50) break;
|
|
||||||
const compressed = view.getUint16(pos + 10, true) !== 0;
|
|
||||||
const compressedSize = view.getUint32(pos + 18, true);
|
|
||||||
const fileNameLen = view.getUint16(pos + 26, true);
|
|
||||||
const extraLen = view.getUint16(pos + 28, true);
|
|
||||||
const name = new TextDecoder().decode(bytes.slice(pos + 30, pos + 30 + fileNameLen));
|
|
||||||
const dataStart = pos + 30 + fileNameLen + extraLen;
|
|
||||||
entries.push({ name, offset: dataStart, size: compressedSize, compressed });
|
|
||||||
pos = dataStart + compressedSize;
|
|
||||||
}
|
|
||||||
const docEntry = entries.find((e) => e.name === "word/document.xml");
|
|
||||||
if (!docEntry) return "";
|
|
||||||
const xmlBytes = bytes.slice(docEntry.offset, docEntry.offset + docEntry.size);
|
|
||||||
let xmlText: string;
|
|
||||||
if (docEntry.compressed) {
|
|
||||||
try {
|
|
||||||
const ds = new DecompressionStream("deflate-raw");
|
|
||||||
const writer = ds.writable.getWriter();
|
|
||||||
writer.write(xmlBytes);
|
|
||||||
writer.close();
|
|
||||||
const reader = ds.readable.getReader();
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
chunks.push(value);
|
|
||||||
}
|
|
||||||
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
|
|
||||||
const combined = new Uint8Array(totalLen);
|
|
||||||
let offset = 0;
|
|
||||||
for (const c of chunks) { combined.set(c, offset); offset += c.length; }
|
|
||||||
xmlText = new TextDecoder().decode(combined);
|
|
||||||
} catch {
|
|
||||||
xmlText = new TextDecoder().decode(xmlBytes);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
xmlText = new TextDecoder().decode(xmlBytes);
|
|
||||||
}
|
|
||||||
const textMatches = xmlText.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
|
|
||||||
if (!textMatches) return "";
|
|
||||||
const paraMatches = xmlText.match(/<w:p[ >][\s\S]*?<\/w:p>/g);
|
|
||||||
if (paraMatches) {
|
|
||||||
return paraMatches.map((p) => {
|
|
||||||
const tMatches = p.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
|
|
||||||
if (!tMatches) return "";
|
|
||||||
return tMatches.map((m) => m.replace(/<[^>]+>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, "\"")).join("");
|
|
||||||
}).filter(Boolean).join("\n").trim();
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(size: number): string {
|
function formatFileSize(size: number): string {
|
||||||
if (size < 1024) return `${size} B`;
|
if (size < 1024) return `${size} B`;
|
||||||
@@ -321,22 +267,26 @@ function ScriptTokensPage() {
|
|||||||
const ext = getFileExtension(file.name);
|
const ext = getFileExtension(file.name);
|
||||||
const readable = isReadableTextFile(file, ext);
|
const readable = isReadableTextFile(file, ext);
|
||||||
setUploadedFile({ name: file.name, size: file.size });
|
setUploadedFile({ name: file.name, size: file.size });
|
||||||
if (ext === ".docx") {
|
if (ext === ".docx" || ext === ".doc") {
|
||||||
try {
|
try {
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
const formData = new FormData();
|
||||||
const text = await extractDocxText(bytes);
|
formData.append("file", file);
|
||||||
if (text) {
|
const token = getStoredToken();
|
||||||
setScript(text);
|
const resp = await fetch(buildApiUrl("files/extract-text"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
const { text } = await resp.json();
|
||||||
|
setScript(text || "");
|
||||||
} else {
|
} else {
|
||||||
setScript(`[已上传文件:${file.name}]\n\n无法从 DOCX 文件中提取文本,请尝试另存为 TXT 格式后重新上传。`);
|
const err = await resp.json().catch(() => ({ error: "解析失败" }));
|
||||||
|
setScript(`[已上传文件:${file.name}]\n\n${err.error || "文件解析失败,请尝试另存为 TXT 格式后重新上传。"}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setScript(`[已上传文件:${file.name}]\n\n解析 DOCX 文件失败,请尝试另存为 TXT 格式后重新上传。`);
|
setScript(`[已上传文件:${file.name}]\n\n文件解析请求失败,请检查网络连接后重试。`);
|
||||||
}
|
}
|
||||||
} else if (ext === ".doc") {
|
|
||||||
const text = await decodeTextFile(file);
|
|
||||||
const cleaned = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/\s{3,}/g, "\n\n").trim();
|
|
||||||
setScript(cleaned || `[已上传文件:${file.name}]\n\n无法从 .doc 文件中提取文本,请另存为 .docx 或 .txt 格式。`);
|
|
||||||
} else if (readable) {
|
} else if (readable) {
|
||||||
const text = normalizeUploadedText(await decodeTextFile(file), ext);
|
const text = normalizeUploadedText(await decodeTextFile(file), ext);
|
||||||
setScript(text);
|
setScript(text);
|
||||||
|
|||||||
@@ -1145,3 +1145,269 @@
|
|||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── History panel ──────────────────────────────── */
|
||||||
|
|
||||||
|
.ecom-video-history-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 9000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 420px;
|
||||||
|
max-width: 90vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: #1a1d24;
|
||||||
|
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5);
|
||||||
|
animation: ecom-history-slide-in 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ecom-history-slide-in {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-panel__close {
|
||||||
|
margin-left: auto;
|
||||||
|
display: grid;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
place-items: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-panel__close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-panel__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-panel__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-card {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-card__title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-card__date {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-card__delete {
|
||||||
|
display: grid;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
place-items: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-card__delete:hover {
|
||||||
|
background: rgba(255, 80, 80, 0.15);
|
||||||
|
color: #ff5050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-card__scenes {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-card__scene {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 80px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-card__scene img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-card__scene img:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-card__video-thumb {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-card__video-thumb:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-panel__pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-panel__pager button {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-panel__pager button:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-history-panel__pager button:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Delete confirmation dialog ─────────────────── */
|
||||||
|
|
||||||
|
.ecom-video-confirm-dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-confirm-dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 28px 32px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #1e2128;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
max-width: 340px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-confirm-dialog__icon {
|
||||||
|
font-size: 36px;
|
||||||
|
color: #faad14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-confirm-dialog__text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-confirm-dialog__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-confirm-dialog__actions button {
|
||||||
|
padding: 6px 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-confirm-dialog__actions button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-confirm-dialog__actions button.is-danger {
|
||||||
|
background: #ff4d4f;
|
||||||
|
border-color: #ff4d4f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-confirm-dialog__actions button.is-danger:hover {
|
||||||
|
background: #ff7875;
|
||||||
|
border-color: #ff7875;
|
||||||
|
}
|
||||||
|
|||||||
+3
-4
@@ -11,17 +11,16 @@ export default defineConfig(({ mode }) => {
|
|||||||
compression({ algorithms: ["gzip", "brotliCompress"], threshold: 1024 }),
|
compression({ algorithms: ["gzip", "brotliCompress"], threshold: 1024 }),
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 5174,
|
port: 5173,
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: env.VITE_DEV_PROXY || "http://47.110.225.76:3600",
|
target: env.VITE_DEV_PROXY || "https://omniai.net.cn",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
"/dashscope-api": {
|
"/dashscope-api": {
|
||||||
target: "https://dashscope.aliyuncs.com",
|
target: "https://omniai.net.cn",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/dashscope-api/, "/compatible-mode/v1"),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user