Merge origin/master into feat/commercial-saas-polish
This commit is contained in:
@@ -100,14 +100,14 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, asset });
|
||||
}, []);
|
||||
|
||||
const handleDeleteAsset = useCallback(async () => {
|
||||
if (!contextMenu) return;
|
||||
const { asset } = contextMenu;
|
||||
const handleDeleteAsset = useCallback(async (asset?: LibraryAssetItem) => {
|
||||
const target = asset || contextMenu?.asset;
|
||||
if (!target) return;
|
||||
setContextMenu(null);
|
||||
try {
|
||||
await assetClient.delete(asset.id);
|
||||
setServerAssets((prev) => prev.filter((a) => a.id !== asset.id));
|
||||
setServerNotice(`已删除 ${asset.name}`);
|
||||
await assetClient.delete(target.id);
|
||||
setServerAssets((prev) => prev.filter((a) => a.id !== target.id));
|
||||
setServerNotice(`已删除 ${target.name}`);
|
||||
} catch (err) {
|
||||
setServerNotice(err instanceof Error ? err.message : "删除失败");
|
||||
}
|
||||
@@ -287,32 +287,42 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
|
||||
{visibleAssets.length ? (
|
||||
<div className="asset-grid asset-grid--desktop motion-stagger">
|
||||
{visibleAssets.map((asset) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
className="asset-card asset-card--desktop"
|
||||
onClick={() => setPreviewAsset(asset)}
|
||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||
aria-label={`预览素材 ${asset.name}`}
|
||||
>
|
||||
<div className={`asset-card__thumb ${asset.thumbClass}`}>
|
||||
{asset.imageUrl ? <OptimizedImage src={asset.imageUrl} alt={asset.name} /> : null}
|
||||
</div>
|
||||
<div className="asset-card__body">
|
||||
<div className="asset-card__head">
|
||||
<strong>{asset.name}</strong>
|
||||
<span className={`studio-status-bar__badge ${statusBadgeClass[asset.status]}`}>
|
||||
{statusLabel[asset.status]}
|
||||
</span>
|
||||
<div key={asset.id} className="asset-card-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
className="asset-card asset-card--desktop"
|
||||
onClick={() => setPreviewAsset(asset)}
|
||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||
aria-label={`预览素材 ${asset.name}`}
|
||||
>
|
||||
<div className={`asset-card__thumb ${asset.thumbClass}`}>
|
||||
{asset.imageUrl ? <OptimizedImage src={asset.imageUrl} alt={asset.name} /> : null}
|
||||
</div>
|
||||
<p className="asset-card__desc">{asset.description}</p>
|
||||
<div className="asset-card__tags">
|
||||
{asset.tags.slice(0, 2).map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
<div className="asset-card__body">
|
||||
<div className="asset-card__head">
|
||||
<strong>{asset.name}</strong>
|
||||
<span className={`studio-status-bar__badge ${statusBadgeClass[asset.status]}`}>
|
||||
{statusLabel[asset.status]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="asset-card__desc">{asset.description}</p>
|
||||
<div className="asset-card__tags">
|
||||
{asset.tags.slice(0, 2).map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="asset-card__delete"
|
||||
title="删除素材"
|
||||
onClick={(e) => { e.stopPropagation(); void handleDeleteAsset(asset); }}
|
||||
aria-label={`删除 ${asset.name}`}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
|
||||
@@ -3717,6 +3717,9 @@ function CanvasPage({
|
||||
<ReactFlow
|
||||
nodes={[]}
|
||||
edges={[]}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
minZoom={0.3}
|
||||
maxZoom={1.6}
|
||||
panOnDrag={false}
|
||||
@@ -5529,6 +5532,11 @@ function CanvasPage({
|
||||
role="menu"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onMouseMove={(event) => {
|
||||
if (pendingLinkPort) {
|
||||
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="studio-canvas-add-node-menu__title">新建节点并连接</div>
|
||||
<button
|
||||
@@ -5540,8 +5548,6 @@ function CanvasPage({
|
||||
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
||||
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
|
||||
addTextNode(undefined, pos);
|
||||
setPendingLinkPort(null);
|
||||
setPendingLinkPreviewPoint(null);
|
||||
setConnectionDropMenu(null);
|
||||
}}
|
||||
>
|
||||
@@ -5557,8 +5563,6 @@ function CanvasPage({
|
||||
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
||||
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
|
||||
addImageNode("", "图片节点", pos);
|
||||
setPendingLinkPort(null);
|
||||
setPendingLinkPreviewPoint(null);
|
||||
setConnectionDropMenu(null);
|
||||
}}
|
||||
>
|
||||
@@ -5574,8 +5578,6 @@ function CanvasPage({
|
||||
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
||||
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
|
||||
addVideoNode(pos);
|
||||
setPendingLinkPort(null);
|
||||
setPendingLinkPreviewPoint(null);
|
||||
setConnectionDropMenu(null);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -567,7 +567,17 @@ function DigitalHumanPage({
|
||||
</button>
|
||||
)}
|
||||
{resultVideoUrl && (
|
||||
<div className="studio-result-actions studio-result-actions--with-clear">
|
||||
<button type="button" className="studio-generate-btn" onClick={() => {
|
||||
setResultVideoUrl("");
|
||||
setActiveTaskId("");
|
||||
setTaskProgress(0);
|
||||
setNotice("已清空工作区");
|
||||
}}>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
{resultVideoUrl && (
|
||||
<div className="studio-result-actions">
|
||||
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
|
||||
<DownloadOutlined />
|
||||
{isDownloadingResult ? "保存中" : "保存本地"}
|
||||
@@ -576,14 +586,6 @@ function DigitalHumanPage({
|
||||
<InboxOutlined />
|
||||
{isSavingResultAsset ? "加入中" : "加入资产库"}
|
||||
</button>
|
||||
<button type="button" onClick={() => {
|
||||
setResultVideoUrl("");
|
||||
setActiveTaskId("");
|
||||
setTaskProgress(0);
|
||||
setNotice("已清空工作区");
|
||||
}}>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
PLAN_STEPS_DISPLAY,
|
||||
type EcommerceVideoStage,
|
||||
type EcommerceVideoSceneTask,
|
||||
type EcommerceVideoPlanProgress,
|
||||
type EcommerceVideoPlanResult,
|
||||
type PlanStep,
|
||||
} from "./ecommerceVideoTypes";
|
||||
@@ -22,6 +23,7 @@ import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
|
||||
import { ServerRequestError } from "../../api/serverConnection";
|
||||
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
|
||||
import { useAppStore } from "../../stores";
|
||||
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
||||
import {
|
||||
saveEcommerceVideoState,
|
||||
loadEcommerceVideoState,
|
||||
@@ -44,10 +46,51 @@ const ALL_STEPS: PlanStep[] = [
|
||||
"creative", "storyboard", "prompts", "compliance",
|
||||
];
|
||||
|
||||
function hashString(value: string): string {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function buildInputFingerprint(input: {
|
||||
productImageDataUrls: string[];
|
||||
requirement: string;
|
||||
platform: string;
|
||||
aspectRatio: string;
|
||||
durationSeconds: number;
|
||||
resolution: string;
|
||||
}): string {
|
||||
const imageCount = input.productImageDataUrls.length;
|
||||
return hashString([
|
||||
String(imageCount),
|
||||
input.requirement.trim(),
|
||||
input.platform,
|
||||
input.aspectRatio,
|
||||
input.durationSeconds,
|
||||
input.resolution,
|
||||
].join("::"));
|
||||
}
|
||||
|
||||
function mapResolutionToQuality(res: string): "720P" | "1080P" {
|
||||
return res.includes("720") ? "720P" : "1080P";
|
||||
}
|
||||
|
||||
function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress): boolean {
|
||||
switch (step) {
|
||||
case "upload": return Boolean(p.imageUrls?.length);
|
||||
case "analyze": return p.imageDescription !== undefined;
|
||||
case "summary": return Boolean(p.summary);
|
||||
case "selling": return Boolean(p.selling);
|
||||
case "creative": return Boolean(p.creatives?.length);
|
||||
case "storyboard": return Boolean(p.storyboard);
|
||||
case "prompts": return Boolean(p.videoPrompts);
|
||||
case "compliance": return Boolean(p.compliance);
|
||||
}
|
||||
}
|
||||
|
||||
export default function EcommerceVideoWorkspace({
|
||||
isAuthenticated,
|
||||
productImageDataUrls,
|
||||
@@ -60,38 +103,67 @@ export default function EcommerceVideoWorkspace({
|
||||
}: EcommerceVideoWorkspaceProps) {
|
||||
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
|
||||
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
|
||||
const [planProgress, setPlanProgress] = useState<EcommerceVideoPlanProgress | null>(null);
|
||||
const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]);
|
||||
const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]);
|
||||
const [sourceImageUrls, setSourceImageUrls] = useState<string[]>([]);
|
||||
const [currentStep, setCurrentStep] = useState<PlanStep | null>(null);
|
||||
const [failedStep, setFailedStep] = useState<PlanStep | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const renderAbortRef = useRef({ current: false });
|
||||
const setView = useAppStore((s) => s.setView);
|
||||
const keepaliveRestoredRef = useRef(false);
|
||||
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
|
||||
const keepalivePollingStartedRef = useRef(false);
|
||||
const generation = useGenerationTasks({ sourceView: "ecommerce" });
|
||||
const sceneStoreIdMap = useRef<Map<number, string>>(new Map());
|
||||
const inputFingerprint = useMemo(
|
||||
() => buildInputFingerprint({ productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution }),
|
||||
[productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution],
|
||||
);
|
||||
|
||||
// ── Keep-alive: restore saved state on mount ─────────────
|
||||
useEffect(() => {
|
||||
if (keepaliveRestoredRef.current) return;
|
||||
keepaliveRestoredRef.current = true;
|
||||
const saved = loadEcommerceVideoState();
|
||||
if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return;
|
||||
keepaliveRestoredFingerprintRef.current = inputFingerprint;
|
||||
const saved = loadEcommerceVideoState(inputFingerprint);
|
||||
if (!saved) return;
|
||||
if (saved.stage === "idle" || saved.stage === "cancelled") return;
|
||||
// Restore completed / in-progress states — results persist across page switches
|
||||
setStage(saved.stage);
|
||||
setCompletedSteps(saved.completedSteps || []);
|
||||
setPlanResult(saved.planResult);
|
||||
setPlanProgress((saved as { planProgress?: EcommerceVideoPlanProgress | null }).planProgress || null);
|
||||
setScenes(saved.scenes || []);
|
||||
setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []);
|
||||
}, []);
|
||||
}, [inputFingerprint]);
|
||||
|
||||
// ── Keep-alive: save state on changes ───────────────────
|
||||
useEffect(() => {
|
||||
if (stage === "idle" || stage === "cancelled") return;
|
||||
saveEcommerceVideoState({ stage, completedSteps, planResult, scenes, sourceImageUrls });
|
||||
}, [stage, completedSteps, planResult, scenes, sourceImageUrls]);
|
||||
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
|
||||
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
|
||||
|
||||
// ── Auto-advance: skip manual "next step" clicks ─────────
|
||||
const autoAdvanceTriggeredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (autoAdvanceTriggeredRef.current) return;
|
||||
const delay = 600;
|
||||
if (stage === "planned" && planResult && scenes.length > 0) {
|
||||
autoAdvanceTriggeredRef.current = true;
|
||||
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
|
||||
autoAdvanceTriggeredRef.current = true;
|
||||
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (stage === "idle" || stage === "cancelled") {
|
||||
autoAdvanceTriggeredRef.current = false;
|
||||
}
|
||||
}, [stage, scenes, planResult]);
|
||||
|
||||
// ── Keep-alive: resume polling for running tasks ──────────
|
||||
useEffect(() => {
|
||||
@@ -253,40 +325,89 @@ export default function EcommerceVideoWorkspace({
|
||||
|
||||
// ── Phase 1: Planning ──────────────────────────────────────
|
||||
|
||||
const handlePlan = async () => {
|
||||
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
||||
if (!productImageDataUrls.length && !requirement.trim()) {
|
||||
setError("请先上传产品图片或填写商品说明"); return;
|
||||
}
|
||||
const runPlanFlow = async (resume: EcommerceVideoPlanProgress | null) => {
|
||||
abortControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
setStage("planning"); setError(null);
|
||||
setCompletedSteps([]); setCurrentStep(null);
|
||||
setPlanResult(null); setScenes([]); setSourceImageUrls([]);
|
||||
setStage("planning"); setError(null); setFailedStep(null);
|
||||
if (!resume) {
|
||||
setCompletedSteps([]); setPlanResult(null); setScenes([]); setSourceImageUrls([]); setPlanProgress(null);
|
||||
}
|
||||
setCurrentStep(null);
|
||||
// Mutable snapshot — async handlers must persist to localStorage directly since the component may unmount
|
||||
let livePlanProgress: EcommerceVideoPlanProgress = resume ? { ...resume } : {};
|
||||
let liveCompletedSteps: PlanStep[] = resume
|
||||
? ALL_STEPS.filter((s) => stepCompletedFromProgress(s, resume))
|
||||
: [];
|
||||
const persist = (stageNow: EcommerceVideoStage) => {
|
||||
saveEcommerceVideoState({
|
||||
inputFingerprint,
|
||||
stage: stageNow,
|
||||
completedSteps: liveCompletedSteps,
|
||||
planResult: null,
|
||||
planProgress: livePlanProgress,
|
||||
scenes: [],
|
||||
sourceImageUrls: livePlanProgress.imageUrls || [],
|
||||
});
|
||||
};
|
||||
try {
|
||||
const result = await runVideoPlan(
|
||||
productImageDataUrls, requirement, buildConfig(),
|
||||
{
|
||||
onStepStart: (step) => setCurrentStep(step),
|
||||
onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]),
|
||||
onImagesUploaded: (urls) => { setSourceImageUrls(urls); saveEcommerceVideoState({ stage: "planning", completedSteps: ["upload"], planResult: null, scenes: [], sourceImageUrls: urls }); },
|
||||
onStepDone: (step) => {
|
||||
liveCompletedSteps = [...liveCompletedSteps, step];
|
||||
setCompletedSteps((prev) => [...prev, step]);
|
||||
},
|
||||
onImagesUploaded: (urls) => {
|
||||
setSourceImageUrls(urls);
|
||||
livePlanProgress = { ...livePlanProgress, imageUrls: urls };
|
||||
persist("planning");
|
||||
},
|
||||
onUploadRejected: (messages) => {
|
||||
if (messages.length) showNotice(`已跳过 ${messages.length} 张上传失败的图片`);
|
||||
},
|
||||
onPartialProgress: (progress) => {
|
||||
livePlanProgress = progress;
|
||||
setPlanProgress(progress);
|
||||
persist("planning");
|
||||
},
|
||||
resumeFrom: resume || undefined,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
const builtScenes = buildSceneTasks(result);
|
||||
setPlanResult(result);
|
||||
setPlanProgress(null);
|
||||
setScenes(builtScenes);
|
||||
setStage("planned");
|
||||
// Persist immediately — component may be unmounted by the time React re-renders
|
||||
saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, scenes: builtScenes, sourceImageUrls: result.imageUrls });
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, scenes: builtScenes, sourceImageUrls: result.imageUrls });
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "策划失败");
|
||||
if ((err as Error).name === "AbortError" && controller.signal.aborted) return;
|
||||
const message = err instanceof Error ? err.message : "策划失败";
|
||||
setError(message);
|
||||
// Mark the step that was in-progress as failed so user can resume
|
||||
setFailedStep((prev) => prev || currentStep);
|
||||
setStage("idle");
|
||||
// Persist partial progress so the user can resume after a page switch
|
||||
persist("idle");
|
||||
} finally { setCurrentStep(null); }
|
||||
};
|
||||
|
||||
const handlePlan = async () => {
|
||||
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
||||
if (!productImageDataUrls.length && !requirement.trim()) {
|
||||
setError("请先上传产品图片或填写商品说明"); return;
|
||||
}
|
||||
await runPlanFlow(null);
|
||||
};
|
||||
|
||||
const handleResumePlan = async () => {
|
||||
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
||||
if (!planProgress) { void handlePlan(); return; }
|
||||
await runPlanFlow(planProgress);
|
||||
};
|
||||
|
||||
// ── Phase 2: Image generation per scene ──────────────────────
|
||||
|
||||
const handleGenerateImages = async () => {
|
||||
@@ -300,19 +421,34 @@ export default function EcommerceVideoWorkspace({
|
||||
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
||||
currentScenes = next;
|
||||
setScenes(next);
|
||||
saveEcommerceVideoState({ stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||
};
|
||||
for (const scene of currentScenes) {
|
||||
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry
|
||||
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
|
||||
if (!scenesToProcess.length) { setStage("imaged"); return; }
|
||||
for (const scene of scenesToProcess) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||
try {
|
||||
await renderSceneImage(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
|
||||
{
|
||||
onSceneImageSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)),
|
||||
onSceneImageSubmitted: (id, taskId) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s));
|
||||
const storeId = generation.submitTask({ title: `分镜${id}图片`, type: "image", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "imaging" } });
|
||||
sceneStoreIdMap.current.set(id, storeId);
|
||||
},
|
||||
onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||
onSceneImageCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)),
|
||||
onSceneImageFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)),
|
||||
onSceneImageCompleted: (id, url) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markCompleted(sid, url);
|
||||
},
|
||||
onSceneImageFailed: (id, err2) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markFailed(sid, err2);
|
||||
},
|
||||
},
|
||||
renderAbortRef.current,
|
||||
);
|
||||
@@ -324,15 +460,14 @@ export default function EcommerceVideoWorkspace({
|
||||
const allHaveImages = currentScenes.every((s) => s.imageUrl);
|
||||
const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const;
|
||||
setStage(finalStage);
|
||||
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||
};
|
||||
|
||||
// ── Phase 3: Video rendering from generated images ──────────
|
||||
|
||||
const handleRenderVideos = async () => {
|
||||
if (!scenes.length) return;
|
||||
const firstImage = scenes[0]?.imageUrl;
|
||||
if (!firstImage) { setError("请先生成分镜图片"); return; }
|
||||
if (!scenes.some((s) => s.imageUrl)) { setError("请先生成分镜图片"); return; }
|
||||
setStage("rendering"); setError(null);
|
||||
renderAbortRef.current = { current: false };
|
||||
const quality = mapResolutionToQuality(resolution);
|
||||
@@ -340,20 +475,35 @@ export default function EcommerceVideoWorkspace({
|
||||
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
||||
currentScenes = next;
|
||||
setScenes(next);
|
||||
saveEcommerceVideoState({ stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||
};
|
||||
for (const scene of currentScenes) {
|
||||
// Only render scenes that haven't completed yet — preserves successful videos on partial retry
|
||||
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
|
||||
if (!scenesToProcess.length) { setStage(currentScenes.every((s) => s.status === "completed") ? "completed" : "partial_failed"); return; }
|
||||
for (const scene of scenesToProcess) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
if (!scene.imageUrl) continue;
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||
try {
|
||||
await renderScene(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality },
|
||||
{
|
||||
onSceneSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
|
||||
onSceneSubmitted: (id, taskId) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s));
|
||||
const storeId = generation.submitTask({ title: `分镜${id}视频`, type: "video", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "rendering" } });
|
||||
sceneStoreIdMap.current.set(id, storeId);
|
||||
},
|
||||
onSceneProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||
onSceneCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
|
||||
onSceneFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
|
||||
onSceneCompleted: (id, url) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markCompleted(sid, url);
|
||||
},
|
||||
onSceneFailed: (id, err2) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markFailed(sid, err2);
|
||||
},
|
||||
},
|
||||
renderAbortRef.current,
|
||||
);
|
||||
@@ -369,7 +519,7 @@ export default function EcommerceVideoWorkspace({
|
||||
const finalStage = allDone ? (hasFailed ? "partial_failed" as const : "completed" as const) : "rendering" as const;
|
||||
setScenes(currentScenes);
|
||||
setStage(finalStage);
|
||||
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||
};
|
||||
|
||||
const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); };
|
||||
@@ -424,26 +574,32 @@ export default function EcommerceVideoWorkspace({
|
||||
|
||||
<div className="ecom-video-flowbar__actions">
|
||||
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
|
||||
{stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
onClick={() => void handleResumePlan()} title={`从「${failedStep ? PLAN_STEP_LABELS[failedStep] : "已中断处"}」继续策划`}>
|
||||
<ReloadOutlined /> 继续
|
||||
</button>
|
||||
) : null}
|
||||
{stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
|
||||
<button type="button" className="ecom-video-flow-action"
|
||||
onClick={() => void handlePlan()} title="一键策划">
|
||||
onClick={() => void handlePlan()} title={planProgress ? "从头重新策划" : "一键策划"}>
|
||||
<PlayCircleOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "planned" ? (
|
||||
{stage === "planned" || stage === "imaged" ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
onClick={() => void handleGenerateImages()} title="生成图片">
|
||||
<SendOutlined />
|
||||
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
|
||||
{stage === "imaged" ? <ReloadOutlined /> : <SendOutlined />}
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "imaged" ? (
|
||||
{stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
onClick={() => void handleRenderVideos()} title="生成视频">
|
||||
onClick={() => void handleRenderVideos()} title={stage === "partial_failed" ? "重新生成失败的视频" : "生成视频"}>
|
||||
<SendOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "planning" ? (
|
||||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 策划中</span>
|
||||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> {currentStep ? PLAN_STEP_LABELS[currentStep] : "策划中"}</span>
|
||||
) : null}
|
||||
{stage === "imaging" ? (
|
||||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 生成图片中</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
EcommerceVideoStage,
|
||||
EcommerceVideoSceneTask,
|
||||
EcommerceVideoPlanProgress,
|
||||
EcommerceVideoPlanResult,
|
||||
PlanStep,
|
||||
} from "./ecommerceVideoTypes";
|
||||
@@ -8,18 +9,22 @@ import type {
|
||||
const KEEPALIVE_KEY = "omniai:ecommerce-video-workspace";
|
||||
|
||||
interface EcommerceVideoKeepalive {
|
||||
inputFingerprint: string;
|
||||
stage: EcommerceVideoStage;
|
||||
completedSteps: PlanStep[];
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
planProgress?: EcommerceVideoPlanProgress | null;
|
||||
scenes: EcommerceVideoSceneTask[];
|
||||
sourceImageUrls: string[];
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
export function saveEcommerceVideoState(state: {
|
||||
inputFingerprint: string;
|
||||
stage: EcommerceVideoStage;
|
||||
completedSteps: PlanStep[];
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
planProgress?: EcommerceVideoPlanProgress | null;
|
||||
scenes: EcommerceVideoSceneTask[];
|
||||
sourceImageUrls?: string[];
|
||||
}): void {
|
||||
@@ -35,7 +40,7 @@ export function saveEcommerceVideoState(state: {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null {
|
||||
export function loadEcommerceVideoState(inputFingerprint: string): EcommerceVideoKeepalive | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEEPALIVE_KEY);
|
||||
if (!raw) return null;
|
||||
@@ -45,6 +50,7 @@ export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null {
|
||||
clearEcommerceVideoState();
|
||||
return null;
|
||||
}
|
||||
if (parsed.inputFingerprint !== inputFingerprint) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
|
||||
import type {
|
||||
EcommerceVideoPlanProgress,
|
||||
EcommerceVideoPlanResult,
|
||||
EcommerceVideoSceneTask,
|
||||
PlanStep,
|
||||
@@ -21,66 +23,129 @@ export interface PlanCallbacks {
|
||||
onStepStart: (step: PlanStep) => void;
|
||||
onStepDone: (step: PlanStep) => void;
|
||||
onImagesUploaded?: (urls: string[]) => void;
|
||||
onUploadRejected?: (messages: string[]) => void;
|
||||
onPartialProgress?: (progress: EcommerceVideoPlanProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Partial state from a previous run; steps with existing data are skipped. */
|
||||
resumeFrom?: EcommerceVideoPlanProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full ad video planning pipeline.
|
||||
* Supports resumption: if `resumeFrom` contains data for a step, that step is skipped.
|
||||
* After each step, `onPartialProgress` fires so callers can persist intermediate state.
|
||||
*/
|
||||
export async function runVideoPlan(
|
||||
imageDataUrls: string[],
|
||||
manualText: string,
|
||||
config: AdVideoUserConfig,
|
||||
callbacks: PlanCallbacks,
|
||||
): Promise<EcommerceVideoPlanResult> {
|
||||
const { onStepStart, onStepDone, signal } = callbacks;
|
||||
const { onStepStart, onStepDone, signal, resumeFrom = {} } = callbacks;
|
||||
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
|
||||
const emit = () => callbacks.onPartialProgress?.({ ...progress });
|
||||
|
||||
onStepStart("upload");
|
||||
const imageUrls: string[] = [];
|
||||
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
||||
for (const srcUrl of imageDataUrls) {
|
||||
try {
|
||||
const resp = await fetch(srcUrl);
|
||||
const rawBlob = await resp.blob();
|
||||
const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png";
|
||||
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
||||
const result = await aiGenerationClient.uploadAssetBinary(blob, { mimeType, scope: "ecommerce-product" });
|
||||
imageUrls.push(result.url);
|
||||
} catch {
|
||||
// skip images that fail to upload
|
||||
// ── Step: upload ──────────────────────────────────────
|
||||
if (!progress.imageUrls?.length) {
|
||||
onStepStart("upload");
|
||||
const imageUrls: string[] = [];
|
||||
const rejected: string[] = [];
|
||||
for (const srcUrl of imageDataUrls) {
|
||||
try {
|
||||
const resp = await fetch(srcUrl);
|
||||
const rawBlob = await resp.blob();
|
||||
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
|
||||
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
|
||||
imageUrls.push(result.url);
|
||||
} catch (err) {
|
||||
rejected.push(err instanceof Error ? err.message : "图片上传失败");
|
||||
}
|
||||
}
|
||||
if (rejected.length) {
|
||||
progress.uploadWarnings = rejected;
|
||||
callbacks.onUploadRejected?.(rejected);
|
||||
}
|
||||
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
|
||||
progress.imageUrls = imageUrls;
|
||||
onStepDone("upload");
|
||||
callbacks.onImagesUploaded?.(imageUrls);
|
||||
emit();
|
||||
}
|
||||
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
|
||||
onStepDone("upload");
|
||||
callbacks.onImagesUploaded?.(imageUrls);
|
||||
|
||||
onStepStart("analyze");
|
||||
const imageDesc = await analyzeProductImages(imageUrls, signal);
|
||||
onStepDone("analyze");
|
||||
// ── Step: analyze ─────────────────────────────────────
|
||||
if (progress.imageDescription === undefined) {
|
||||
onStepStart("analyze");
|
||||
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
|
||||
onStepDone("analyze");
|
||||
emit();
|
||||
}
|
||||
|
||||
onStepStart("summary");
|
||||
const summary = await buildProductSummary(imageDesc, manualText, signal);
|
||||
onStepDone("summary");
|
||||
// ── Step: summary ─────────────────────────────────────
|
||||
if (!progress.summary) {
|
||||
onStepStart("summary");
|
||||
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
|
||||
onStepDone("summary");
|
||||
emit();
|
||||
}
|
||||
|
||||
onStepStart("selling");
|
||||
const selling = await extractSellingPoints(summary, signal);
|
||||
onStepDone("selling");
|
||||
// ── Step: selling ─────────────────────────────────────
|
||||
if (!progress.selling) {
|
||||
onStepStart("selling");
|
||||
progress.selling = await extractSellingPoints(progress.summary, signal);
|
||||
onStepDone("selling");
|
||||
emit();
|
||||
}
|
||||
|
||||
onStepStart("creative");
|
||||
const creatives = await generateCreativeOptions(selling, config, signal);
|
||||
if (!creatives.length) throw new Error("未能生成有效的广告创意");
|
||||
onStepDone("creative");
|
||||
// ── Step: creative ────────────────────────────────────
|
||||
if (!progress.creatives?.length) {
|
||||
onStepStart("creative");
|
||||
progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
|
||||
if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
|
||||
onStepDone("creative");
|
||||
emit();
|
||||
}
|
||||
|
||||
onStepStart("storyboard");
|
||||
const storyboard = await generateStoryboard(creatives[0], summary, config, signal);
|
||||
onStepDone("storyboard");
|
||||
// ── Step: storyboard ──────────────────────────────────
|
||||
if (!progress.storyboard) {
|
||||
onStepStart("storyboard");
|
||||
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
|
||||
onStepDone("storyboard");
|
||||
emit();
|
||||
}
|
||||
|
||||
onStepStart("prompts");
|
||||
const videoPrompts = await generateVideoPrompts(storyboard, summary, signal);
|
||||
onStepDone("prompts");
|
||||
// ── Step: prompts ─────────────────────────────────────
|
||||
if (!progress.videoPrompts) {
|
||||
onStepStart("prompts");
|
||||
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
|
||||
onStepDone("prompts");
|
||||
emit();
|
||||
}
|
||||
|
||||
onStepStart("compliance");
|
||||
const compliance = await checkCompliance(summary, selling, storyboard, signal);
|
||||
onStepDone("compliance");
|
||||
// ── Step: compliance ──────────────────────────────────
|
||||
if (!progress.compliance) {
|
||||
onStepStart("compliance");
|
||||
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
|
||||
onStepDone("compliance");
|
||||
emit();
|
||||
}
|
||||
|
||||
return { imageUrls, summary, selling, creatives, storyboard, videoPrompts, compliance };
|
||||
return {
|
||||
imageUrls: progress.imageUrls!,
|
||||
imageDescription: progress.imageDescription,
|
||||
summary: progress.summary!,
|
||||
selling: progress.selling!,
|
||||
creatives: progress.creatives!,
|
||||
storyboard: progress.storyboard!,
|
||||
videoPrompts: progress.videoPrompts!,
|
||||
compliance: progress.compliance!,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RenderSceneImageInput {
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface EcommerceVideoSceneTask {
|
||||
|
||||
export interface EcommerceVideoPlanResult {
|
||||
imageUrls: string[];
|
||||
imageDescription?: string;
|
||||
summary: ProductSummary;
|
||||
selling: SellingPointResult;
|
||||
creatives: CreativeOption[];
|
||||
@@ -44,6 +45,19 @@ export interface EcommerceVideoPlanResult {
|
||||
compliance: ComplianceCheck;
|
||||
}
|
||||
|
||||
/** Partial plan state — used as resume input when an earlier run failed mid-flow. */
|
||||
export interface EcommerceVideoPlanProgress {
|
||||
imageUrls?: string[];
|
||||
imageDescription?: string;
|
||||
uploadWarnings?: string[];
|
||||
summary?: ProductSummary;
|
||||
selling?: SellingPointResult;
|
||||
creatives?: CreativeOption[];
|
||||
storyboard?: Storyboard;
|
||||
videoPrompts?: VideoPrompt[];
|
||||
compliance?: ComplianceCheck;
|
||||
}
|
||||
|
||||
export interface EcommerceVideoDelivery {
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
scenes: EcommerceVideoSceneTask[];
|
||||
|
||||
+361
-86
@@ -7,16 +7,13 @@ import {
|
||||
ShoppingOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
|
||||
import { useScrollEntrance } from "../../hooks/useScrollEntrance";
|
||||
import WelcomeSplash from "./WelcomeSplash";
|
||||
import ToolboxSection from "./ToolboxSection";
|
||||
import ScriptReviewShowcase from "./ScriptReviewShowcase";
|
||||
import ModelGenerationShowcase from "./ModelGenerationShowcase";
|
||||
const ecommerceTemplate1 = "https://www.omniai.net.cn/static/home-ecommerce-template-1.png";
|
||||
const ecommerceTemplate2 = "https://www.omniai.net.cn/static/home-ecommerce-template-2.png";
|
||||
const ecommerceTemplate3 = "https://www.omniai.net.cn/static/home-ecommerce-template-3.png";
|
||||
|
||||
function ScrollEntrance({ children, className, ...rest }: { children: React.ReactNode; className?: string } & React.HTMLAttributes<HTMLElement>) {
|
||||
const { ref, isVisible } = useScrollEntrance<HTMLElement>();
|
||||
@@ -54,16 +51,6 @@ const HOME_CAROUSEL_IMAGES = [
|
||||
];
|
||||
|
||||
const HOME_FEATURES = [
|
||||
{
|
||||
key: "script",
|
||||
eyebrow: "Script Review",
|
||||
title: "剧本智能测评",
|
||||
description: "用六维雷达评分拆解剧本质量,从结构、节奏、人物到商业潜力给出可执行的优化路径。",
|
||||
imageUrl: featureScriptImage,
|
||||
actionLabel: "开始测评",
|
||||
icon: <FileSearchOutlined />,
|
||||
stats: ["六维评分", "质量量化", "逐项优化"],
|
||||
},
|
||||
{
|
||||
key: "model",
|
||||
eyebrow: "AI Generation",
|
||||
@@ -84,6 +71,16 @@ const HOME_FEATURES = [
|
||||
icon: <ShoppingOutlined />,
|
||||
stats: ["多场景", "多角度", "批量输出"],
|
||||
},
|
||||
{
|
||||
key: "script",
|
||||
eyebrow: "Script Review",
|
||||
title: "剧本智能测评",
|
||||
description: "用六维雷达评分拆解剧本质量,从结构、节奏、人物到商业潜力给出可执行的优化路径。",
|
||||
imageUrl: featureScriptImage,
|
||||
actionLabel: "开始测评",
|
||||
icon: <FileSearchOutlined />,
|
||||
stats: ["六维评分", "质量量化", "逐项优化"],
|
||||
},
|
||||
];
|
||||
|
||||
const HOME_EXPERIENCE_POINTS = [
|
||||
@@ -93,37 +90,96 @@ const HOME_EXPERIENCE_POINTS = [
|
||||
{ label: "电商", meta: "商品视觉", tone: "amber" },
|
||||
];
|
||||
|
||||
const HOME_ECOMMERCE_TEMPLATES = [
|
||||
{
|
||||
title: "卖点详情图",
|
||||
tag: "详情",
|
||||
meta: "中文卖点标注",
|
||||
imageUrl: ecommerceTemplate1,
|
||||
},
|
||||
{
|
||||
title: "场景主图",
|
||||
tag: "主图",
|
||||
meta: "商品氛围构图",
|
||||
imageUrl: ecommerceTemplate2,
|
||||
},
|
||||
{
|
||||
title: "虚拟模特",
|
||||
tag: "模特",
|
||||
meta: "使用场景延展",
|
||||
imageUrl: ecommerceTemplate3,
|
||||
},
|
||||
const ECOMMERCE_MATRIX_FEATURES = [
|
||||
{ icon: "⚡", title: "高效工作流", description: "自动化处理,一键触发" },
|
||||
{ icon: "⊞", title: "矩阵式产出", description: "多场景、多尺寸批量生成" },
|
||||
{ icon: "◈", title: "一致性保证", description: "智能保持商品特征与风格统一" },
|
||||
];
|
||||
|
||||
const HOME_ECOMMERCE_TOOLS = [
|
||||
{ title: "主图", meta: "平台首图" },
|
||||
{ title: "详情", meta: "卖点拆解" },
|
||||
{ title: "模特", meta: "虚拟模特" },
|
||||
{ title: "短视频", meta: "首帧方案" },
|
||||
const ECOMMERCE_MATRIX_PROCESS = [
|
||||
{ icon: "📤", label: "上传原图", subLabel: "Upload" },
|
||||
{ icon: "🔍", label: "AI识别", subLabel: "Recognition" },
|
||||
{ icon: "⚙️", label: "生成处理", subLabel: "Processing" },
|
||||
{ icon: "📦", label: "矩阵产出", subLabel: "Output" },
|
||||
];
|
||||
|
||||
const ECOMMERCE_MATRIX_AI_STEPS = ["智能识别主体", "3D虚拟模特", "场景生成", "详情图生成", "批量导出"];
|
||||
|
||||
type EcommerceMatrixModelCard = {
|
||||
kind: "model";
|
||||
color: "brown" | "green" | "blue";
|
||||
tag: string;
|
||||
tagTone: string;
|
||||
resolution: string;
|
||||
square?: false;
|
||||
};
|
||||
|
||||
type EcommerceMatrixSceneCard = {
|
||||
kind: "scene";
|
||||
color: "p1" | "p2" | "p3";
|
||||
tag: string;
|
||||
tagTone: string;
|
||||
resolution: string;
|
||||
square: true;
|
||||
variant?: "greenery" | "blue";
|
||||
};
|
||||
|
||||
type EcommerceMatrixLayoutCard = {
|
||||
kind: "layout";
|
||||
color: "c1" | "c2" | "c3";
|
||||
tag: string;
|
||||
tagTone: string;
|
||||
resolution: string;
|
||||
square: true;
|
||||
badge: string;
|
||||
badgeTone?: "purple";
|
||||
};
|
||||
|
||||
type EcommerceMatrixCard = EcommerceMatrixModelCard | EcommerceMatrixSceneCard | EcommerceMatrixLayoutCard;
|
||||
|
||||
const ECOMMERCE_MATRIX_OUTPUTS: Array<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
cards: EcommerceMatrixCard[];
|
||||
}> = [
|
||||
{
|
||||
title: "3D 虚拟模特",
|
||||
subtitle: "Virtual Model",
|
||||
cards: [
|
||||
{ kind: "model", color: "brown", tag: "3D", tagTone: "tag-3d", resolution: "1024×1536" },
|
||||
{ kind: "model", color: "green", tag: "3D", tagTone: "tag-3d", resolution: "1024×1536" },
|
||||
{ kind: "model", color: "blue", tag: "3D", tagTone: "tag-3d", resolution: "1024×1536" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "场景图",
|
||||
subtitle: "Scene Image",
|
||||
cards: [
|
||||
{ kind: "scene", color: "p1", tag: "场景", tagTone: "tag-scene", resolution: "1024×1024", square: true },
|
||||
{ kind: "scene", color: "p2", tag: "场景", tagTone: "tag-scene", resolution: "1024×1024", square: true, variant: "greenery" },
|
||||
{ kind: "scene", color: "p3", tag: "场景", tagTone: "tag-scene", resolution: "1024×1024", square: true, variant: "blue" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "详情图",
|
||||
subtitle: "Detail Image",
|
||||
cards: [
|
||||
{ kind: "layout", color: "c1", tag: "详情", tagTone: "tag-layout", resolution: "1080×1080", square: true, badge: "优雅随行" },
|
||||
{ kind: "layout", color: "c2", tag: "详情", tagTone: "tag-layout", resolution: "1080×1080", square: true, badge: "限时特惠", badgeTone: "purple" },
|
||||
{ kind: "layout", color: "c3", tag: "详情", tagTone: "tag-layout", resolution: "1080×1080", square: true, badge: "新品首发" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const HOME_CAROUSEL_SLOTS = [-4, -3, -2, -1, 0, 1, 2, 3, 4];
|
||||
const HOME_CAROUSEL_TRANSITION_MS = 860;
|
||||
|
||||
type EcommerceFlowLine = {
|
||||
d: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
interface HomeCarouselMotion {
|
||||
direction: number;
|
||||
progress: 0 | 1;
|
||||
@@ -137,9 +193,9 @@ function getHomeCarouselCardStyle(offset: number): CSSProperties {
|
||||
const depth = Math.abs(offset);
|
||||
const direction = Math.sign(offset);
|
||||
const isActive = depth === 0;
|
||||
const xByDepth = [0, 286, 456, 610, 735, 840];
|
||||
const xByDepth = [0, 190, 320, 430, 520, 590];
|
||||
const yByDepth = [8, -2, -8, -13, -18, -24];
|
||||
const scaleByDepth = [1, 0.98, 0.94, 0.91, 0.88, 0.84];
|
||||
const scaleByDepth = [1, 1, 1, 1, 1, 1];
|
||||
const x = direction * (xByDepth[depth] ?? xByDepth[xByDepth.length - 1]!);
|
||||
const y = yByDepth[depth] ?? yByDepth[yByDepth.length - 1]!;
|
||||
const z = isActive ? 90 : 28 - depth;
|
||||
@@ -159,38 +215,253 @@ function getHomeCarouselCardStyle(offset: number): CSSProperties {
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
function EcommerceFeatureShowcase() {
|
||||
function EcommerceMatrixCardVisual({ card }: { card: EcommerceMatrixCard }) {
|
||||
if (card.kind === "model") {
|
||||
return (
|
||||
<div className="mock-model">
|
||||
<div className="silhouette" />
|
||||
<div className={`mock-product-hold ${card.color}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (card.kind === "scene") {
|
||||
return (
|
||||
<div className="mock-scene">
|
||||
{card.variant === "greenery" ? <div className="obj greenery" /> : <div className="obj decor-item is-soft-blue" />}
|
||||
<div className={`obj table-top${card.variant === "greenery" ? " is-warm" : ""}`} />
|
||||
<div className={`obj prod ${card.color}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="omni-home-ecommerce-showcase">
|
||||
<div className="omni-home-ecommerce-showcase__depth" />
|
||||
<div className="omni-home-ecommerce-showcase__grain" />
|
||||
|
||||
<div className="omni-home-ecommerce-showcase__prompt">
|
||||
<span>商品图 + 生成要求</span>
|
||||
<strong>生成整套电商视觉</strong>
|
||||
<p>主图、详情页、虚拟模特、短视频首帧一次整理。</p>
|
||||
<div className="mock-layout">
|
||||
<div className="lay-img">
|
||||
<div className={`mini-cup ${card.color}`} />
|
||||
</div>
|
||||
<div className="lay-text">
|
||||
<div className={`lay-line title${card.color === "c2" ? " is-short" : card.color === "c3" ? " is-wide" : ""}`} />
|
||||
<div className={`lay-line sub${card.color === "c2" ? " is-medium" : ""}`} />
|
||||
<div className={`lay-line short${card.color === "c3" ? " is-medium" : ""}`} />
|
||||
<div className={`lay-badge${card.badgeTone === "purple" ? " purple" : ""}`}>{card.badge}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="omni-home-ecommerce-showcase__tools" aria-hidden="true">
|
||||
{HOME_ECOMMERCE_TOOLS.map((item) => (
|
||||
<div key={item.title} className="omni-home-ecommerce-showcase__tool">
|
||||
<b>{item.title}</b>
|
||||
<small>{item.meta}</small>
|
||||
function EcommerceFeatureShowcase() {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputCardRef = useRef<HTMLDivElement | null>(null);
|
||||
const outputGroupRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||
const [flowLines, setFlowLines] = useState<EcommerceFlowLine[]>(() =>
|
||||
ECOMMERCE_MATRIX_OUTPUTS.map(() => ({ d: "", x: 0, y: 0 })),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let frameId: number | null = null;
|
||||
|
||||
const updateFlowLines = () => {
|
||||
const root = rootRef.current;
|
||||
const inputCard = inputCardRef.current;
|
||||
if (!root || !inputCard) return;
|
||||
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const inputRect = inputCard.getBoundingClientRect();
|
||||
const sx = inputRect.right - rootRect.left;
|
||||
const sy = inputRect.top - rootRect.top + inputRect.height / 2;
|
||||
const cornerRadius = 24;
|
||||
|
||||
const nextLines = outputGroupRefs.current.slice(0, ECOMMERCE_MATRIX_OUTPUTS.length).map((group) => {
|
||||
if (!group) return { d: "", x: 0, y: 0 };
|
||||
|
||||
const groupRect = group.getBoundingClientRect();
|
||||
const tx = groupRect.left - rootRect.left;
|
||||
const ty = groupRect.top - rootRect.top + groupRect.height / 2;
|
||||
const totalDistance = tx - sx;
|
||||
const splitX = sx + totalDistance * 0.3;
|
||||
const direction = ty > sy ? 1 : ty < sy ? -1 : 0;
|
||||
const verticalDistance = Math.abs(ty - sy);
|
||||
const resolvedRadius = Math.min(cornerRadius, verticalDistance / 2);
|
||||
|
||||
const d =
|
||||
direction === 0
|
||||
? `M ${sx} ${sy} L ${tx} ${ty}`
|
||||
: `M ${sx} ${sy} L ${splitX} ${sy} Q ${splitX + resolvedRadius} ${sy}, ${splitX + resolvedRadius} ${
|
||||
sy + direction * resolvedRadius
|
||||
} L ${splitX + resolvedRadius} ${ty - direction * resolvedRadius} Q ${splitX + resolvedRadius} ${ty}, ${
|
||||
splitX + resolvedRadius * 2
|
||||
} ${ty} L ${tx} ${ty}`;
|
||||
|
||||
return { d, x: tx, y: ty };
|
||||
});
|
||||
|
||||
setFlowLines(nextLines);
|
||||
};
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
frameId = window.requestAnimationFrame(updateFlowLines);
|
||||
};
|
||||
|
||||
scheduleUpdate();
|
||||
window.addEventListener("resize", scheduleUpdate);
|
||||
|
||||
const resizeObserver = new ResizeObserver(scheduleUpdate);
|
||||
if (rootRef.current) {
|
||||
resizeObserver.observe(rootRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
window.removeEventListener("resize", scheduleUpdate);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="omni-home-ecommerce-matrix">
|
||||
<div className="bg-base" />
|
||||
<div className="bg-grid" />
|
||||
<div className="bg-stars" />
|
||||
<div className="bg-vignette" />
|
||||
<div className="bg-noise" />
|
||||
|
||||
<div className="page">
|
||||
<div className="left-panel">
|
||||
<h3 className="hero-title">
|
||||
一张原图
|
||||
<br />
|
||||
矩阵生产全场景图文
|
||||
</h3>
|
||||
|
||||
<p className="hero-desc">
|
||||
从商品原图到3D虚拟模特、场景图、详情图
|
||||
<br />
|
||||
AI工作流自动化,批量生成,高效出图
|
||||
</p>
|
||||
|
||||
<div className="features">
|
||||
{ECOMMERCE_MATRIX_FEATURES.map((item) => (
|
||||
<div key={item.title} className="feature-item">
|
||||
<div className="feature-icon">{item.icon}</div>
|
||||
<div className="feature-text">
|
||||
<h4>{item.title}</h4>
|
||||
<p>{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="omni-home-ecommerce-showcase__gallery" aria-hidden="true">
|
||||
{HOME_ECOMMERCE_TEMPLATES.map((item, index) => (
|
||||
<article key={item.title} className={`omni-home-ecommerce-showcase__shot is-${index + 1}`}>
|
||||
<img src={item.imageUrl} alt="" />
|
||||
<div>
|
||||
<span>{item.tag}</span>
|
||||
<strong>{item.title}</strong>
|
||||
<small>{item.meta}</small>
|
||||
<div className="process-flow">
|
||||
{ECOMMERCE_MATRIX_PROCESS.map((item, index) => (
|
||||
<Fragment key={item.label}>
|
||||
{index > 0 ? <span className="process-arrow">▸</span> : null}
|
||||
<div className="process-step">
|
||||
<span className="step-icon">{item.icon}</span>
|
||||
<span className="step-label">{item.label}</span>
|
||||
<span className="step-sub">{item.subLabel}</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="center-panel">
|
||||
<div ref={inputCardRef} className="input-card">
|
||||
<div className="input-card-header">
|
||||
<span className="input-card-label">商品原图 Input</span>
|
||||
<span className="input-card-res">3000×3000</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<div className="input-card-img">
|
||||
<div className="product-placeholder">
|
||||
<div className="cup cup-1">
|
||||
<div className="cup-lid" />
|
||||
<div className="cup-straw" />
|
||||
<div className="cup-tag">DRINK MORE</div>
|
||||
</div>
|
||||
<div className="cup cup-2">
|
||||
<div className="cup-lid" />
|
||||
<div className="cup-straw" />
|
||||
<div className="cup-tag">DRINK MORE</div>
|
||||
</div>
|
||||
<div className="cup cup-3">
|
||||
<div className="cup-lid" />
|
||||
<div className="cup-straw" />
|
||||
<div className="cup-tag">DRINK MORE</div>
|
||||
</div>
|
||||
<div className="books">
|
||||
<div className="book" />
|
||||
<div className="book" />
|
||||
<div className="book" />
|
||||
<div className="book" />
|
||||
</div>
|
||||
<div className="table-surface" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="right-panel">
|
||||
<div className="ai-node">
|
||||
<div className="ai-node-title">AI 工作流</div>
|
||||
<div className="ai-node-list">
|
||||
{ECOMMERCE_MATRIX_AI_STEPS.map((item) => (
|
||||
<div key={item} className="ai-node-item">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ECOMMERCE_MATRIX_OUTPUTS.map((group, groupIndex) => (
|
||||
<div
|
||||
key={group.title}
|
||||
ref={(node) => {
|
||||
outputGroupRefs.current[groupIndex] = node;
|
||||
}}
|
||||
className="output-group"
|
||||
>
|
||||
<div className="output-label">
|
||||
<h4>{group.title}</h4>
|
||||
<p>{group.subtitle}</p>
|
||||
</div>
|
||||
<div className="output-cards">
|
||||
{group.cards.map((card, cardIndex) => (
|
||||
<div key={`${group.title}-${cardIndex}`} className={`output-card${card.square ? " square" : ""}`}>
|
||||
<div className="output-card-img">
|
||||
<span className={`output-card-tag ${card.tagTone}`}>{card.tag}</span>
|
||||
<EcommerceMatrixCardVisual card={card} />
|
||||
<span className="output-card-res">{card.resolution}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<svg className="flow-svg" aria-hidden="true">
|
||||
<defs>
|
||||
<filter id="home-ecommerce-flow-glow">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{flowLines.map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<path className={`flow-path flow-path-${index + 1}`} d={line.d} filter="url(#home-ecommerce-flow-glow)" />
|
||||
<circle className={`flow-dot flow-dot-${index + 1}`} cx={line.x} cy={line.y} r="4" filter="url(#home-ecommerce-flow-glow)" />
|
||||
</Fragment>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -367,19 +638,21 @@ function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptR
|
||||
<main className="omni-home__feature-pages" aria-label="OmniAI 功能介绍">
|
||||
{HOME_FEATURES.map((feature, index) => (
|
||||
<section key={feature.key} className={`omni-home__feature-page is-${feature.key}${index % 2 ? " is-alt" : ""}`}>
|
||||
<div className="omni-home__feature-copy">
|
||||
<span>
|
||||
{feature.icon}
|
||||
{feature.eyebrow}
|
||||
</span>
|
||||
<h2>{feature.title}</h2>
|
||||
<p>{feature.description}</p>
|
||||
<button type="button" onClick={() => handleFeatureOpen(feature.key)}>
|
||||
{feature.actionLabel}
|
||||
<ArrowRightOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="omni-home__feature-visual" aria-hidden="true">
|
||||
{feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce" ? (
|
||||
<div className="omni-home__feature-copy">
|
||||
<span>
|
||||
{feature.icon}
|
||||
{feature.eyebrow}
|
||||
</span>
|
||||
<h2>{feature.title}</h2>
|
||||
<p>{feature.description}</p>
|
||||
<button type="button" onClick={() => handleFeatureOpen(feature.key)}>
|
||||
{feature.actionLabel}
|
||||
<ArrowRightOutlined />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="omni-home__feature-visual" aria-hidden={feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce"}>
|
||||
{feature.key === "script" ? (
|
||||
<ScriptReviewShowcase />
|
||||
) : feature.key === "model" ? (
|
||||
@@ -390,14 +663,18 @@ function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptR
|
||||
<img src={feature.imageUrl} alt="" />
|
||||
)}
|
||||
</div>
|
||||
<div className="omni-home__feature-stats" aria-hidden="true">
|
||||
{feature.stats.map((item) => (
|
||||
<span key={item}>{item}</span>
|
||||
))}
|
||||
</div>
|
||||
{feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce" ? (
|
||||
<div className="omni-home__feature-stats" aria-hidden="true">
|
||||
{feature.stats.map((item) => (
|
||||
<span key={item}>{item}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<ToolboxSection onSelectView={onSelectView} onOpenImageTool={onOpenImageTool} />
|
||||
|
||||
<section className="omni-home__experience" aria-label="点击体验">
|
||||
<div className="omni-home__experience-copy">
|
||||
<span>
|
||||
@@ -430,8 +707,6 @@ function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptR
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ToolboxSection onSelectView={onSelectView} onOpenImageTool={onOpenImageTool} />
|
||||
</main>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const DIMS = [
|
||||
{ name: "钩子设计", score: 16, max: 20, hue: 145, desc: "吸引力·悬念·黄金三秒", isPerfect: false, isLow: false },
|
||||
{ name: "角色塑造", score: 15, max: 15, hue: 155, desc: "立体度·动机·弧光", isPerfect: true, isLow: false },
|
||||
{ name: "剧情结构", score: 16, max: 20, hue: 165, desc: "起承转合·节奏·冲突", isPerfect: false, isLow: false },
|
||||
{ name: "角色塑造", score: 15, max: 15, hue: 155, desc: "立体度·动机·弧光", isPerfect: true, isLow: false },
|
||||
{ name: "逻辑严密", score: 12, max: 15, hue: 175, desc: "自洽·伏笔·因果链", isPerfect: false, isLow: false },
|
||||
{ name: "场景构建", score: 10, max: 15, hue: 185, desc: "空间·视听·画面感", isPerfect: false, isLow: true },
|
||||
{ name: "内容深度", score: 8, max: 15, hue: 195, desc: "主题·情感·思想内核", isPerfect: false, isLow: true },
|
||||
@@ -27,6 +27,12 @@ const OPTIMIZATIONS = [
|
||||
{ dim: "逻辑严密 → 补强", priority: "中优先", priorityClass: "badge-orange", text: "补充世界观细节,强化因果链与伏笔回收" },
|
||||
];
|
||||
|
||||
const SHOWCASE_POINTS = [
|
||||
{ icon: "⚡", title: "六维评分", text: "结构、节奏、人物到商业潜力全面量化" },
|
||||
{ icon: "◈", title: "质量量化", text: "用雷达评分拆解剧本质量与短板" },
|
||||
{ icon: "↗", title: "逐项优化", text: "给出可执行的优化路径和打磨方向" },
|
||||
];
|
||||
|
||||
function animateNumber(el: HTMLElement | null, target: number, duration: number) {
|
||||
if (!el) return;
|
||||
const start = performance.now();
|
||||
@@ -79,125 +85,154 @@ function ScriptReviewShowcase() {
|
||||
|
||||
return (
|
||||
<div className="omni-script-review-showcase" id="script-review-showcase">
|
||||
{/* Score Hero */}
|
||||
<div className="srs-score-hero">
|
||||
<div className="srs-score-left">
|
||||
<div className="srs-score-circle">
|
||||
<div className="srs-score-circle-inner">
|
||||
<span className="srs-score-num" ref={scoreRef}>0</span>
|
||||
<span className="srs-score-den">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="srs-score-meta">
|
||||
<div className="srs-score-grade">A 级</div>
|
||||
<div className="srs-score-tags">
|
||||
<span className="srs-score-tag">现实剧情</span>
|
||||
<span className="srs-score-tag">58min</span>
|
||||
<span className="srs-score-tag">6角色</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="srs-left-panel">
|
||||
<div className="srs-brand-section">
|
||||
<h1>剧本智能测评</h1>
|
||||
<p>用六维雷达评分拆解剧本质量,从结构、节奏、人物到商业潜力给出可执行的优化路径。</p>
|
||||
</div>
|
||||
<div className="srs-score-divider" />
|
||||
<div className="srs-score-right">
|
||||
<div className="srs-score-proj">电商广告片生成项目计划 · 评测结果</div>
|
||||
<div className="srs-score-summary">
|
||||
现实剧情特征清晰,角色塑造表现突出。当前最值得继续打磨的是内容深度,建议围绕人物选择、冲突升级和可拍摄细节继续压实。
|
||||
</div>
|
||||
|
||||
<div className="srs-point-list">
|
||||
{SHOWCASE_POINTS.map((item) => (
|
||||
<div key={item.title} className="srs-point-card">
|
||||
<div className="srs-point-icon">{item.icon}</div>
|
||||
<div>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="srs-flow-card">
|
||||
<span>上传剧本</span>
|
||||
<b>→</b>
|
||||
<span>六维评分</span>
|
||||
<b>→</b>
|
||||
<span>优化建议</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vertical Bar Chart */}
|
||||
<div className="srs-chart-card">
|
||||
<div className="srs-chart-title">六维评分 Dimension Breakdown</div>
|
||||
<div className="srs-chart-body">
|
||||
{DIMS.map((dim, i) => {
|
||||
const pct = dim.score / dim.max;
|
||||
return (
|
||||
<div key={dim.name} className="srs-chart-col">
|
||||
<div className="srs-chart-bar-wrap">
|
||||
<div className="srs-chart-bar-bg" style={{ height: "100%" }} />
|
||||
<div
|
||||
ref={(el) => { barRefs.current[i] = el; }}
|
||||
className={`srs-chart-bar-fill${dim.isPerfect ? " is-perfect" : ""}${dim.isLow ? " is-low" : ""}`}
|
||||
data-pct={String(Math.round(pct * 100))}
|
||||
style={{ height: "0%" }}
|
||||
>
|
||||
<div className="srs-chart-bar-score">
|
||||
<span
|
||||
ref={(el) => { scoreValRefs.current[i] = el; }}
|
||||
data-target={String(dim.score)}
|
||||
>0</span>
|
||||
<span className="srs-chart-bar-sub">/{dim.max}</span>
|
||||
{dim.isPerfect && <span className="srs-chart-bar-star">★</span>}
|
||||
<div className="srs-results-panel">
|
||||
{/* Score Hero */}
|
||||
<div className="srs-score-hero">
|
||||
<div className="srs-score-left">
|
||||
<div className="srs-score-circle">
|
||||
<div className="srs-score-circle-inner">
|
||||
<span className="srs-score-num" ref={scoreRef}>0</span>
|
||||
<span className="srs-score-den">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="srs-score-meta">
|
||||
<div className="srs-score-grade">A 级</div>
|
||||
<div className="srs-score-tags">
|
||||
<span className="srs-score-tag">现实剧情</span>
|
||||
<span className="srs-score-tag">58min</span>
|
||||
<span className="srs-score-tag">6角色</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="srs-score-divider" />
|
||||
<div className="srs-score-right">
|
||||
<div className="srs-score-proj">电商广告片生成项目计划 · 评测结果</div>
|
||||
<div className="srs-score-summary">
|
||||
现实剧情特征清晰,角色塑造表现突出。当前最值得继续打磨的是内容深度,建议围绕人物选择、冲突升级和可拍摄细节继续压实。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vertical Bar Chart */}
|
||||
<div className="srs-chart-card">
|
||||
<div className="srs-chart-title">六维评分 Dimension Breakdown</div>
|
||||
<div className="srs-chart-body">
|
||||
{DIMS.map((dim, i) => {
|
||||
const pct = dim.score / dim.max;
|
||||
return (
|
||||
<div key={dim.name} className="srs-chart-col">
|
||||
<div className="srs-chart-bar-wrap">
|
||||
<div className="srs-chart-bar-bg" style={{ height: "100%" }} />
|
||||
<div
|
||||
ref={(el) => { barRefs.current[i] = el; }}
|
||||
className={`srs-chart-bar-fill${dim.isPerfect ? " is-perfect" : ""}${dim.isLow ? " is-low" : ""}`}
|
||||
data-pct={String(Math.round(pct * 100))}
|
||||
style={{ height: "0%" }}
|
||||
>
|
||||
<div className="srs-chart-bar-score">
|
||||
<span
|
||||
ref={(el) => { scoreValRefs.current[i] = el; }}
|
||||
data-target={String(dim.score)}
|
||||
>0</span>
|
||||
<span className="srs-chart-bar-sub">/{dim.max}</span>
|
||||
{dim.isPerfect && <span className="srs-chart-bar-star">★</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="srs-chart-col-label">
|
||||
<div className="srs-chart-col-name">{dim.name}</div>
|
||||
<div className="srs-chart-col-desc">{dim.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="srs-chart-col-label">
|
||||
<div className="srs-chart-col-name">{dim.name}</div>
|
||||
<div className="srs-chart-col-desc">{dim.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Triple Section */}
|
||||
<div className="srs-triple-section">
|
||||
{/* Highlights */}
|
||||
<div className="srs-section-card is-highlight">
|
||||
<div className="srs-section-header">
|
||||
<div className="srs-section-icon">✦</div>
|
||||
<span className="srs-section-label">亮点</span>
|
||||
</div>
|
||||
<div className="srs-section-list">
|
||||
{HIGHLIGHTS.map((item) => (
|
||||
<div key={item.dim} className="srs-section-item">
|
||||
<div className="srs-section-item-head">
|
||||
<span className="srs-section-item-dim">{item.dim}</span>
|
||||
<span className="srs-section-item-score is-green">{item.score}</span>
|
||||
</div>
|
||||
<div className="srs-section-item-text">{item.text}</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weaknesses */}
|
||||
<div className="srs-section-card is-weakness">
|
||||
<div className="srs-section-header">
|
||||
<div className="srs-section-icon">✗</div>
|
||||
<span className="srs-section-label">缺点</span>
|
||||
</div>
|
||||
<div className="srs-section-list">
|
||||
{WEAKNESSES.map((item) => (
|
||||
<div key={item.dim} className="srs-section-item">
|
||||
<div className="srs-section-item-head">
|
||||
<span className="srs-section-item-dim">{item.dim}</span>
|
||||
<span className="srs-section-item-score is-red">{item.score}</span>
|
||||
{/* Triple Section */}
|
||||
<div className="srs-triple-section">
|
||||
{/* Highlights */}
|
||||
<div className="srs-section-card is-highlight">
|
||||
<div className="srs-section-header">
|
||||
<div className="srs-section-icon">✦</div>
|
||||
<span className="srs-section-label">亮点</span>
|
||||
</div>
|
||||
<div className="srs-section-list">
|
||||
{HIGHLIGHTS.map((item) => (
|
||||
<div key={item.dim} className="srs-section-item">
|
||||
<div className="srs-section-item-head">
|
||||
<span className="srs-section-item-dim">{item.dim}</span>
|
||||
<span className="srs-section-item-score is-green">{item.score}</span>
|
||||
</div>
|
||||
<div className="srs-section-item-text">{item.text}</div>
|
||||
</div>
|
||||
<div className="srs-section-item-text">{item.text}</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optimization */}
|
||||
<div className="srs-section-card is-optimize">
|
||||
<div className="srs-section-header">
|
||||
<div className="srs-section-icon">⚡</div>
|
||||
<span className="srs-section-label">优化路径</span>
|
||||
</div>
|
||||
<div className="srs-section-list">
|
||||
{OPTIMIZATIONS.map((item) => (
|
||||
<div key={item.dim} className="srs-section-item">
|
||||
<div className="srs-section-item-head">
|
||||
<span className="srs-section-item-dim">{item.dim}</span>
|
||||
<span className={`srs-section-item-badge ${item.priorityClass}`}>{item.priority}</span>
|
||||
{/* Weaknesses */}
|
||||
<div className="srs-section-card is-weakness">
|
||||
<div className="srs-section-header">
|
||||
<div className="srs-section-icon">✗</div>
|
||||
<span className="srs-section-label">缺点</span>
|
||||
</div>
|
||||
<div className="srs-section-list">
|
||||
{WEAKNESSES.map((item) => (
|
||||
<div key={item.dim} className="srs-section-item">
|
||||
<div className="srs-section-item-head">
|
||||
<span className="srs-section-item-dim">{item.dim}</span>
|
||||
<span className="srs-section-item-score is-red">{item.score}</span>
|
||||
</div>
|
||||
<div className="srs-section-item-text">{item.text}</div>
|
||||
</div>
|
||||
<div className="srs-section-item-text">{item.text}</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optimization */}
|
||||
<div className="srs-section-card is-optimize">
|
||||
<div className="srs-section-header">
|
||||
<div className="srs-section-icon">⚡</div>
|
||||
<span className="srs-section-label">优化路径</span>
|
||||
</div>
|
||||
<div className="srs-section-list">
|
||||
{OPTIMIZATIONS.map((item) => (
|
||||
<div key={item.dim} className="srs-section-item">
|
||||
<div className="srs-section-item-head">
|
||||
<span className="srs-section-item-dim">{item.dim}</span>
|
||||
<span className={`srs-section-item-badge ${item.priorityClass}`}>{item.priority}</span>
|
||||
</div>
|
||||
<div className="srs-section-item-text">{item.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -787,7 +787,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
</div>
|
||||
) : inpaintResultImages.length && activeTool === "inpaint" ? (
|
||||
<div className="image-workbench-inpaint-stage">
|
||||
<img src={inpaintResultImages[0]} alt="重绘结果" style={{ maxWidth: "90%", maxHeight: "90%", borderRadius: 8, objectFit: "contain" }} />
|
||||
<img src={inpaintResultImages[0]} alt="重绘结果" style={{ maxWidth: "95%", maxHeight: "95%", borderRadius: 8, objectFit: "contain" }} />
|
||||
<div className="image-workbench-inpaint-bottom-bar">
|
||||
<button type="button" className="image-workbench-inpaint-edit-btn" onClick={() => { setInpaintResultImages([]); setIsMaskEditing(true); setInpaintTool("brush"); setCanvasInitCounter((c) => c + 1); }}>
|
||||
<HighlightOutlined /> 重新编辑遮罩
|
||||
@@ -1284,12 +1284,16 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
))}
|
||||
</div>
|
||||
) : referenceImage ? (
|
||||
<img src={referenceImage} alt="参考图预览" />
|
||||
<div className="studio-canvas-image">
|
||||
<img src={referenceImage} alt="参考图预览" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="image-workbench-empty">
|
||||
<PictureOutlined />
|
||||
<strong>上传参考图后在此预览</strong>
|
||||
<span>生成结果也会显示在这里</span>
|
||||
<div className="studio-canvas-ghost">
|
||||
<div className="studio-canvas-ghost__icon">
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
<div className="studio-canvas-ghost__title">上传参考图后在此预览</div>
|
||||
<div className="studio-canvas-ghost__hint">生成结果也会显示在这里</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -230,6 +230,14 @@ function ProfilePage({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [smsCooldown, setSmsCooldown] = useState(0);
|
||||
const [isSendingSms, setIsSendingSms] = useState(false);
|
||||
const [emailCode, setEmailCode] = useState("");
|
||||
const [emailCooldown, setEmailCooldown] = useState(0);
|
||||
const [isSendingEmail, setIsSendingEmail] = useState(false);
|
||||
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
||||
const [forgotStep, setForgotStep] = useState<"email" | "code" | "newPassword">("email");
|
||||
const [forgotEmail, setForgotEmail] = useState("");
|
||||
const [forgotCode, setForgotCode] = useState("");
|
||||
const [forgotPassword, setForgotPassword] = useState("");
|
||||
|
||||
const [activePanel, setActivePanel] = useState<ProfilePanel>("works");
|
||||
const [accountPanel, setAccountPanel] = useState<AccountPanel>("credits");
|
||||
@@ -312,6 +320,70 @@ function ProfilePage({
|
||||
return () => window.clearInterval(timer);
|
||||
}, [smsCooldown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (emailCooldown <= 0) return;
|
||||
const timer = window.setInterval(() => {
|
||||
setEmailCooldown((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [emailCooldown]);
|
||||
|
||||
const handleSendEmailCode = async (purpose: "register" | "login" | "reset" = "register") => {
|
||||
const targetEmail = purpose === "reset" ? forgotEmail : email;
|
||||
if (emailCooldown > 0 || !targetEmail.trim() || isSendingEmail) return;
|
||||
if (purpose === "register" && !betaCode.trim()) {
|
||||
setNotice("请输入企业邀请码 / 内测码后再获取验证码");
|
||||
return;
|
||||
}
|
||||
setIsSendingEmail(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await keyServerClient.sendEmailCode(targetEmail, purpose, betaCode);
|
||||
setEmailCooldown(result.cooldownSeconds || 60);
|
||||
if (result.devCode) {
|
||||
setNotice(`验证码已发送(开发模式: ${result.devCode})`);
|
||||
} else {
|
||||
setNotice("验证码已发送,请查收邮件");
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "验证码发送失败");
|
||||
} finally {
|
||||
setIsSendingEmail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPassword = async () => {
|
||||
if (forgotStep === "email") {
|
||||
if (!forgotEmail.trim()) { setNotice("请输入邮箱"); return; }
|
||||
try {
|
||||
await keyServerClient.forgotPassword({ email: forgotEmail });
|
||||
setForgotStep("code");
|
||||
setNotice("重置验证码已发送到您的邮箱");
|
||||
await handleSendEmailCode("reset");
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "发送失败");
|
||||
}
|
||||
} else if (forgotStep === "code") {
|
||||
if (!forgotCode.trim()) { setNotice("请输入验证码"); return; }
|
||||
setForgotStep("newPassword");
|
||||
setNotice(null);
|
||||
} else {
|
||||
if (forgotPassword.length < 6) { setNotice("密码至少 6 位"); return; }
|
||||
try {
|
||||
const result = await keyServerClient.resetPassword({ email: forgotEmail, code: forgotCode, newPassword: forgotPassword });
|
||||
setNotice(result.message || "密码重置成功,请重新登录");
|
||||
setShowForgotPassword(false);
|
||||
setForgotStep("email");
|
||||
setForgotEmail("");
|
||||
setForgotCode("");
|
||||
setForgotPassword("");
|
||||
setMode("login");
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "重置失败");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendSms = async () => {
|
||||
if (smsCooldown > 0 || !phone.trim() || isSendingSms) return;
|
||||
if (mode === "register" && !betaCode.trim()) {
|
||||
@@ -356,6 +428,10 @@ function ProfilePage({
|
||||
if (!value.trim()) return "请输入验证码";
|
||||
if (value.length !== 6) return "验证码为 6 位数字";
|
||||
return "";
|
||||
case "emailCode":
|
||||
if (!value.trim()) return "请输入邮箱验证码";
|
||||
if (value.length !== 6) return "验证码为 6 位数字";
|
||||
return "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -395,6 +471,10 @@ function ProfilePage({
|
||||
if (emailErr) errors.email = emailErr;
|
||||
const pwErr = validateField("password", password);
|
||||
if (pwErr) errors.password = pwErr;
|
||||
if (mode === "register") {
|
||||
const codeErr = validateField("emailCode", emailCode);
|
||||
if (codeErr) errors.emailCode = codeErr;
|
||||
}
|
||||
} else {
|
||||
const userErr = validateField("username", username);
|
||||
if (userErr) errors.username = userErr;
|
||||
@@ -421,7 +501,7 @@ function ProfilePage({
|
||||
const nextSession =
|
||||
mode === "login"
|
||||
? await keyServerClient.loginEmail({ email, password })
|
||||
: await keyServerClient.registerEmail({ email, password, username: username.trim() || undefined, betaCode });
|
||||
: await keyServerClient.registerEmail({ email, password, code: emailCode, username: username.trim() || undefined, betaCode });
|
||||
await onAuthComplete?.(nextSession);
|
||||
} else if (mode === "login") {
|
||||
await onLogin(username.trim(), password);
|
||||
@@ -572,22 +652,24 @@ function ProfilePage({
|
||||
const renderActivePanel = () => {
|
||||
if (activePanel === "works") {
|
||||
return visibleWorks.length ? (
|
||||
<div className="profile-page__list-grid motion-stagger">
|
||||
{visibleWorks.map((task) => (
|
||||
<article key={task.id} className="profile-page__list-card 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>
|
||||
<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 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>
|
||||
</div>
|
||||
<p>{task.prompt}</p>
|
||||
<div className="profile-page__list-card-meta">
|
||||
<span>{formatTaskStatus(task.status)}</span>
|
||||
<span>{formatProfileDate(task.createdAt)}</span>
|
||||
</div>
|
||||
</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>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
renderEmptyState("向全世界展示你最得意的创作。", "开始创作", onOpenWorkbench)
|
||||
@@ -965,7 +1047,31 @@ function ProfilePage({
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{authTab === "password" ? (
|
||||
{showForgotPassword ? (
|
||||
<div className="auth-page__forgot-box">
|
||||
<p className="auth-page__forgot-title">重置密码</p>
|
||||
{forgotStep === "email" ? (
|
||||
<input value={forgotEmail} onChange={(e) => setForgotEmail(e.target.value)} placeholder="输入注册邮箱" type="email" className="auth-page__forgot-input" />
|
||||
) : forgotStep === "code" ? (
|
||||
<div className="auth-page__sms-row">
|
||||
<input value={forgotCode} onChange={(e) => setForgotCode(e.target.value)} placeholder="输入验证码" maxLength={6} />
|
||||
<button type="button" className="auth-page__sms-btn" disabled={emailCooldown > 0 || isSendingEmail} onClick={() => void handleSendEmailCode("reset")}>
|
||||
{isSendingEmail ? "发送中" : emailCooldown > 0 ? `${emailCooldown}s` : "重新发送"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<input type="password" value={forgotPassword} onChange={(e) => setForgotPassword(e.target.value)} placeholder="输入新密码(至少 6 位)" className="auth-page__forgot-input" />
|
||||
)}
|
||||
<div className="auth-page__forgot-actions">
|
||||
<button type="button" className="auth-page__forgot-cancel" onClick={() => { setShowForgotPassword(false); setForgotStep("email"); setForgotEmail(""); setForgotCode(""); setForgotPassword(""); setNotice(null); }}>取消</button>
|
||||
<button type="button" className="auth-page__forgot-confirm" onClick={() => void handleForgotPassword()}>
|
||||
{forgotStep === "newPassword" ? "重置密码" : "下一步"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!showForgotPassword && authTab === "password" ? (
|
||||
<>
|
||||
<label className={`auth-page__field${fieldErrors.username ? " auth-page__field--error" : ""}`}>
|
||||
<span>
|
||||
@@ -1001,13 +1107,13 @@ function ProfilePage({
|
||||
</label>
|
||||
{mode === "login" ? (
|
||||
<div className="auth-page__forgot">
|
||||
<button type="button">忘记密码?</button>
|
||||
<button type="button" onClick={() => { setShowForgotPassword(true); setForgotStep("email"); }}>忘记密码?</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{authTab === "email" ? (
|
||||
{!showForgotPassword && authTab === "email" ? (
|
||||
<>
|
||||
{mode === "register" ? (
|
||||
<label className="auth-page__field">
|
||||
@@ -1063,7 +1169,7 @@ function ProfilePage({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{authTab === "phone" ? (
|
||||
{!showForgotPassword && authTab === "phone" ? (
|
||||
<>
|
||||
<label className={`auth-page__field${fieldErrors.phone ? " auth-page__field--error" : ""}`}>
|
||||
<span>
|
||||
@@ -1114,9 +1220,11 @@ function ProfilePage({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{notice ? <p className="auth-page__notice">{notice}</p> : null}
|
||||
{!showForgotPassword ? (
|
||||
<>
|
||||
{notice ? <p className="auth-page__notice">{notice}</p> : null}
|
||||
|
||||
<button type="submit" className="auth-page__submit" disabled={isSubmitting}>
|
||||
<button type="submit" className="auth-page__submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"}
|
||||
</button>
|
||||
|
||||
@@ -1136,6 +1244,8 @@ function ProfilePage({
|
||||
<MobileOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -36,6 +36,8 @@ interface HistoryEntry {
|
||||
timestamp: number;
|
||||
score: number;
|
||||
grade: string;
|
||||
script?: string;
|
||||
result?: EvalResult;
|
||||
}
|
||||
|
||||
function getGrade(score: number): string {
|
||||
@@ -57,6 +59,8 @@ const TEXT_FILE_EXTENSIONS = [
|
||||
".fountain",
|
||||
".fdx",
|
||||
".rtf",
|
||||
".docx",
|
||||
".doc",
|
||||
".csv",
|
||||
".tsv",
|
||||
".json",
|
||||
@@ -102,7 +106,7 @@ const TEXT_FILE_EXTENSIONS = [
|
||||
] as const;
|
||||
const TEXT_FILE_EXTENSION_SET = new Set<string>(TEXT_FILE_EXTENSIONS);
|
||||
const TEXT_FILE_ACCEPT = TEXT_FILE_EXTENSIONS.join(",");
|
||||
const TEXT_FILE_HINT = "支持常见文本格式:TXT / MD / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等";
|
||||
const TEXT_FILE_HINT = "支持常见文本格式:TXT / MD / DOCX / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等";
|
||||
|
||||
function loadHistory(): HistoryEntry[] {
|
||||
try {
|
||||
@@ -171,6 +175,62 @@ function normalizeUploadedText(raw: string, ext: string): string {
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function extractDocxText(bytes: Uint8Array): Promise<string> {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const entries: Array<{ name: string; offset: number; size: number; compressed: boolean }> = [];
|
||||
let pos = 0;
|
||||
while (pos < bytes.length - 30) {
|
||||
if (view.getUint32(pos, true) !== 0x04034b50) break;
|
||||
const compressed = view.getUint16(pos + 10, true) !== 0;
|
||||
const compressedSize = view.getUint32(pos + 18, true);
|
||||
const fileNameLen = view.getUint16(pos + 26, true);
|
||||
const extraLen = view.getUint16(pos + 28, true);
|
||||
const name = new TextDecoder().decode(bytes.slice(pos + 30, pos + 30 + fileNameLen));
|
||||
const dataStart = pos + 30 + fileNameLen + extraLen;
|
||||
entries.push({ name, offset: dataStart, size: compressedSize, compressed });
|
||||
pos = dataStart + compressedSize;
|
||||
}
|
||||
const docEntry = entries.find((e) => e.name === "word/document.xml");
|
||||
if (!docEntry) return "";
|
||||
const xmlBytes = bytes.slice(docEntry.offset, docEntry.offset + docEntry.size);
|
||||
let xmlText: string;
|
||||
if (docEntry.compressed) {
|
||||
try {
|
||||
const ds = new DecompressionStream("deflate-raw");
|
||||
const writer = ds.writable.getWriter();
|
||||
writer.write(xmlBytes);
|
||||
writer.close();
|
||||
const reader = ds.readable.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
|
||||
const combined = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const c of chunks) { combined.set(c, offset); offset += c.length; }
|
||||
xmlText = new TextDecoder().decode(combined);
|
||||
} catch {
|
||||
xmlText = new TextDecoder().decode(xmlBytes);
|
||||
}
|
||||
} else {
|
||||
xmlText = new TextDecoder().decode(xmlBytes);
|
||||
}
|
||||
const textMatches = xmlText.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
|
||||
if (!textMatches) return "";
|
||||
const paraMatches = xmlText.match(/<w:p[ >][\s\S]*?<\/w:p>/g);
|
||||
if (paraMatches) {
|
||||
return paraMatches.map((p) => {
|
||||
const tMatches = p.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
|
||||
if (!tMatches) return "";
|
||||
return tMatches.map((m) => m.replace(/<[^>]+>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, "\"")).join("");
|
||||
}).filter(Boolean).join("\n").trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
@@ -231,6 +291,7 @@ function ScriptTokensPage() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [activeDim, setActiveDim] = useState<number | null>(null);
|
||||
const [animatedScore, setAnimatedScore] = useState(0);
|
||||
const [activeHistoryIndex, setActiveHistoryIndex] = useState<number>(0);
|
||||
const [history, setHistory] = useState<HistoryEntry[]>(loadHistory);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const scoreFrameRef = useRef<number | null>(null);
|
||||
@@ -260,7 +321,23 @@ function ScriptTokensPage() {
|
||||
const ext = getFileExtension(file.name);
|
||||
const readable = isReadableTextFile(file, ext);
|
||||
setUploadedFile({ name: file.name, size: file.size });
|
||||
if (readable) {
|
||||
if (ext === ".docx") {
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const text = await extractDocxText(bytes);
|
||||
if (text) {
|
||||
setScript(text);
|
||||
} else {
|
||||
setScript(`[已上传文件:${file.name}]\n\n无法从 DOCX 文件中提取文本,请尝试另存为 TXT 格式后重新上传。`);
|
||||
}
|
||||
} catch {
|
||||
setScript(`[已上传文件:${file.name}]\n\n解析 DOCX 文件失败,请尝试另存为 TXT 格式后重新上传。`);
|
||||
}
|
||||
} else if (ext === ".doc") {
|
||||
const text = await decodeTextFile(file);
|
||||
const cleaned = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/\s{3,}/g, "\n\n").trim();
|
||||
setScript(cleaned || `[已上传文件:${file.name}]\n\n无法从 .doc 文件中提取文本,请另存为 .docx 或 .txt 格式。`);
|
||||
} else if (readable) {
|
||||
const text = normalizeUploadedText(await decodeTextFile(file), ext);
|
||||
setScript(text);
|
||||
} else {
|
||||
@@ -286,6 +363,8 @@ function ScriptTokensPage() {
|
||||
timestamp: Date.now(),
|
||||
score: aiResult.totalScore,
|
||||
grade: g,
|
||||
script,
|
||||
result: aiResult,
|
||||
};
|
||||
const updated = [entry, ...loadHistory().filter((h) => h.name !== entry.name || h.score !== entry.score)].sort(
|
||||
(a, b) => b.timestamp - a.timestamp,
|
||||
@@ -298,6 +377,20 @@ function ScriptTokensPage() {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleHistoryClick = (item: HistoryEntry, index: number) => {
|
||||
setActiveHistoryIndex(index);
|
||||
if (item.script) {
|
||||
setScript(item.script);
|
||||
setUploadedFile({ name: `${item.name}.txt`, size: item.script.length });
|
||||
}
|
||||
if (item.result) {
|
||||
setResult(item.result);
|
||||
} else {
|
||||
setResult(null);
|
||||
}
|
||||
setEvalError(null);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setScript("");
|
||||
setResult(null);
|
||||
@@ -434,7 +527,9 @@ function ScriptTokensPage() {
|
||||
<div className="script-eval-v5-history-empty">暂无评测记录</div>
|
||||
) : (
|
||||
history.map((item, i) => (
|
||||
<div key={i} className={`script-eval-v5-history-item${i === 0 ? " is-active" : ""}`}>
|
||||
<div key={i} className={`script-eval-v5-history-item${i === activeHistoryIndex ? " is-active" : ""}`}
|
||||
onClick={() => handleHistoryClick(item, i)} role="button" tabIndex={0}
|
||||
onKeyDown={(e) => { if ((e as React.KeyboardEvent).key === "Enter") handleHistoryClick(item, i); }}>
|
||||
<div className="script-eval-v5-hi-left">
|
||||
<div className="script-eval-v5-hi-name">{item.name}</div>
|
||||
<div className="script-eval-v5-hi-date">{item.date}</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
LineChartOutlined,
|
||||
ReloadOutlined,
|
||||
RightOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
WarningOutlined,
|
||||
@@ -143,29 +142,22 @@ function TokenUsagePage({
|
||||
onSelectView,
|
||||
}: TokenUsagePageProps) {
|
||||
const [enterpriseUsage, setEnterpriseUsage] = useState<WebEnterpriseUsageSummary | null>(null);
|
||||
const [enterpriseUsageLoading, setEnterpriseUsageLoading] = useState(false);
|
||||
const [enterpriseUsageError, setEnterpriseUsageError] = useState<string | null>(null);
|
||||
const isEnterpriseAdmin = session?.user.enterpriseRole === "admin";
|
||||
const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise");
|
||||
|
||||
const refreshEnterpriseUsage = useCallback(async () => {
|
||||
if (!session) return;
|
||||
const loader = isEnterpriseAdmin ? loadEnterpriseUsage : loadPersonalUsage;
|
||||
if (!loader) {
|
||||
setEnterpriseUsage(null);
|
||||
setEnterpriseUsageError(null);
|
||||
return;
|
||||
}
|
||||
setEnterpriseUsageLoading(true);
|
||||
setEnterpriseUsageError(null);
|
||||
try {
|
||||
setEnterpriseUsage(await loader());
|
||||
} catch (error) {
|
||||
setEnterpriseUsage(null);
|
||||
setEnterpriseUsageError(error instanceof Error ? error.message : "用量数据暂时不可用");
|
||||
} finally {
|
||||
setEnterpriseUsageLoading(false);
|
||||
}
|
||||
}, [isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
|
||||
}, [session, isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshEnterpriseUsage();
|
||||
@@ -262,17 +254,12 @@ function TokenUsagePage({
|
||||
<UserOutlined />
|
||||
成员管理
|
||||
</button>
|
||||
<button type="button" className="is-primary" onClick={() => onSelectView?.("settings")}>
|
||||
<SettingOutlined />
|
||||
服务设置
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{isLowBalance ? (
|
||||
<div className="management-balance-alert" role="alert">
|
||||
<WarningOutlined />
|
||||
<span>当前余额 {formatCredits(availableBalanceCents)},可能不足以完成下一次生成,请及时充值。</span>
|
||||
<button type="button" onClick={() => onSelectView?.("settings")}>去充值</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -356,13 +356,13 @@ function WatermarkRemovalPage({
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="image-workbench-actions">
|
||||
<div className="image-workbench-actions watermark-removal-actions">
|
||||
<button type="button" className="image-workbench-primary" onClick={() => void handleStart()} disabled={isProcessing}>
|
||||
<DeleteOutlined />
|
||||
{isProcessing ? "处理中" : "开始去水印"}
|
||||
{isProcessing ? "处理中..." : "开始去水印"}
|
||||
</button>
|
||||
{isProcessing && (
|
||||
<button type="button" className="image-workbench-cancel" onClick={handleCancel} style={{ marginTop: 6 }}>
|
||||
<button type="button" className="image-workbench-cancel" onClick={handleCancel}>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -41,6 +41,7 @@ import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUp
|
||||
import { assetClient } from "../../api/assetClient";
|
||||
import { communityClient } from "../../api/communityClient";
|
||||
import { RechargeModal } from "../../components/RechargeModal/RechargeModal";
|
||||
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
||||
|
||||
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
|
||||
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
||||
@@ -239,6 +240,7 @@ function WorkbenchPage({
|
||||
const scrollActionsHideTimerRef = useRef<number | null>(null);
|
||||
const shouldFollowNewMessagesRef = useRef(true);
|
||||
const pendingScrollToLatestRef = useRef(true);
|
||||
const genTracker = useGenerationTasks({ sourceView: "workbench" });
|
||||
const renderedMessageIdsRef = useRef<string[]>([]);
|
||||
const hasHandledInitialMessagesRef = useRef(false);
|
||||
|
||||
@@ -1880,6 +1882,7 @@ function WorkbenchPage({
|
||||
referenceUrls: refUrls.length ? refUrls : undefined,
|
||||
});
|
||||
taskId = result.taskId;
|
||||
genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "image", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
|
||||
} else {
|
||||
let requestModel = resolveVideoRequestModel({
|
||||
model: taskInput.params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL,
|
||||
@@ -1899,6 +1902,7 @@ function WorkbenchPage({
|
||||
hasReferenceVideo: requestReferenceItems.some((item) => item.kind === "video"),
|
||||
});
|
||||
taskId = result.taskId;
|
||||
genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "video", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
|
||||
}
|
||||
|
||||
onRefreshUsage?.();
|
||||
|
||||
Reference in New Issue
Block a user