fix: 修复多个运行时崩溃和功能bug,优化画布连接线和剧本评分

- 修复 EcommercePage generateEcommerceImage 调用不存在变量导致运行时崩溃
- 修复 DigitalHumanPage/ImageWorkbenchPage 变量名错误导致页面不可用
- 修复 ecommerceVideoService token 读取用错 key 导致请求 401
- 修复画布连接线在弹窗出现后仍跟随鼠标的问题
- 剧本评分 .docx 文件改为服务端 mammoth 解析(新增 /api/files/extract-text)
- ErrorBoundary 加 key 支持切换页面时自动重置
- Vite proxy 改为指向公网域名 omniai.net.cn
- 新增视频生成历史记录面板和删除确认弹窗

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