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
@@ -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>
)}
</>
);
}