feat: 邮箱注册验证 + 9项功能修复与优化
【认证系统】 - 新增邮箱验证码注册/登录流程 (sendEmailCode / verifyEmail / forgotPassword / resetPassword) - register-email 现在需要验证码 - 服务端新增 email_verification_codes 表 + patch-email-verification.js - App.tsx 登录后 emailVerified 检查提醒 - keyServerClient token 显式传递修复 401 错误 【电商模块】 - 自动推进: 策划完成后自动生成分镜图/视频 - 模特图选项 (性别/年龄/种族/体型/场景) 注入 AI 提示词 - 任务持久化指纹修复 (图片数量替代 blob URL) - 新增「视频换装」入口 (happyhorse-1.0-video-edit) 【剧本评分】 - 新增 .docx/.doc Word 文档支持 (ZIP解压+XML提取) - 历史记录支持点击查看/恢复评测结果 【画布】 - ReactFlow 节点禁止内置拖拽避免冲突 - 连接线拖拽弹窗优化 (预览线不消失, 弹窗跟踪鼠标) 【页面修复】 - 首页轮播图改为 aspect-ratio: 16/9 解决尺寸问题 - 资产库新增悬停删除按钮 - scriptEvalClient 改用服务端 /api/ai/chat 端点 - TokenUsagePage 未登录跳过 API 调用
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}
|
||||
@@ -5531,6 +5534,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
|
||||
@@ -5542,8 +5550,6 @@ function CanvasPage({
|
||||
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
||||
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
|
||||
addTextNode(undefined, pos);
|
||||
setPendingLinkPort(null);
|
||||
setPendingLinkPreviewPoint(null);
|
||||
setConnectionDropMenu(null);
|
||||
}}
|
||||
>
|
||||
@@ -5559,8 +5565,6 @@ function CanvasPage({
|
||||
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
||||
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
|
||||
addImageNode("", "图片节点", pos);
|
||||
setPendingLinkPort(null);
|
||||
setPendingLinkPreviewPoint(null);
|
||||
setConnectionDropMenu(null);
|
||||
}}
|
||||
>
|
||||
@@ -5576,8 +5580,6 @@ function CanvasPage({
|
||||
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
||||
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
|
||||
addVideoNode(pos);
|
||||
setPendingLinkPort(null);
|
||||
setPendingLinkPreviewPoint(null);
|
||||
setConnectionDropMenu(null);
|
||||
}}
|
||||
>
|
||||
|
||||
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,
|
||||
@@ -23,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,
|
||||
@@ -45,6 +46,34 @@ 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";
|
||||
}
|
||||
@@ -85,14 +114,20 @@ export default function EcommerceVideoWorkspace({
|
||||
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
|
||||
@@ -102,13 +137,33 @@ export default function EcommerceVideoWorkspace({
|
||||
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, planProgress, scenes, sourceImageUrls });
|
||||
}, [stage, completedSteps, planResult, planProgress, 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(() => {
|
||||
@@ -286,6 +341,7 @@ export default function EcommerceVideoWorkspace({
|
||||
: [];
|
||||
const persist = (stageNow: EcommerceVideoStage) => {
|
||||
saveEcommerceVideoState({
|
||||
inputFingerprint,
|
||||
stage: stageNow,
|
||||
completedSteps: liveCompletedSteps,
|
||||
planResult: null,
|
||||
@@ -308,6 +364,9 @@ export default function EcommerceVideoWorkspace({
|
||||
livePlanProgress = { ...livePlanProgress, imageUrls: urls };
|
||||
persist("planning");
|
||||
},
|
||||
onUploadRejected: (messages) => {
|
||||
if (messages.length) showNotice(`已跳过 ${messages.length} 张上传失败的图片`);
|
||||
},
|
||||
onPartialProgress: (progress) => {
|
||||
livePlanProgress = progress;
|
||||
setPlanProgress(progress);
|
||||
@@ -322,7 +381,7 @@ export default function EcommerceVideoWorkspace({
|
||||
setPlanProgress(null);
|
||||
setScenes(builtScenes);
|
||||
setStage("planned");
|
||||
saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, 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" && controller.signal.aborted) return;
|
||||
const message = err instanceof Error ? err.message : "策划失败";
|
||||
@@ -362,7 +421,7 @@ 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 });
|
||||
};
|
||||
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry
|
||||
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
|
||||
@@ -374,10 +433,22 @@ export default function EcommerceVideoWorkspace({
|
||||
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,
|
||||
);
|
||||
@@ -389,7 +460,7 @@ 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 ──────────
|
||||
@@ -404,7 +475,7 @@ 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 });
|
||||
};
|
||||
// Only render scenes that haven't completed yet — preserves successful videos on partial retry
|
||||
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
|
||||
@@ -417,10 +488,22 @@ export default function EcommerceVideoWorkspace({
|
||||
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,
|
||||
);
|
||||
@@ -436,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"); };
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
const KEEPALIVE_KEY = "omniai:ecommerce-video-workspace";
|
||||
|
||||
interface EcommerceVideoKeepalive {
|
||||
inputFingerprint: string;
|
||||
stage: EcommerceVideoStage;
|
||||
completedSteps: PlanStep[];
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
@@ -19,6 +20,7 @@ interface EcommerceVideoKeepalive {
|
||||
}
|
||||
|
||||
export function saveEcommerceVideoState(state: {
|
||||
inputFingerprint: string;
|
||||
stage: EcommerceVideoStage;
|
||||
completedSteps: PlanStep[];
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
@@ -38,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;
|
||||
@@ -48,6 +50,7 @@ export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null {
|
||||
clearEcommerceVideoState();
|
||||
return null;
|
||||
}
|
||||
if (parsed.inputFingerprint !== inputFingerprint) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
|
||||
import type {
|
||||
EcommerceVideoPlanProgress,
|
||||
EcommerceVideoPlanResult,
|
||||
@@ -22,6 +23,7 @@ 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. */
|
||||
@@ -47,19 +49,29 @@ export async function runVideoPlan(
|
||||
if (!progress.imageUrls?.length) {
|
||||
onStepStart("upload");
|
||||
const imageUrls: string[] = [];
|
||||
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
||||
const rejected: string[] = [];
|
||||
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 mimeType = normalizeEcommerceImageMime(rawBlob.type);
|
||||
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
||||
const result = await aiGenerationClient.uploadAssetBinary(blob, { mimeType, scope: "ecommerce-product" });
|
||||
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 {
|
||||
// skip images that fail to upload
|
||||
} 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");
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface EcommerceVideoPlanResult {
|
||||
export interface EcommerceVideoPlanProgress {
|
||||
imageUrls?: string[];
|
||||
imageDescription?: string;
|
||||
uploadWarnings?: string[];
|
||||
summary?: ProductSummary;
|
||||
selling?: SellingPointResult;
|
||||
creatives?: CreativeOption[];
|
||||
|
||||
@@ -169,6 +169,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");
|
||||
@@ -245,6 +253,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()) {
|
||||
@@ -289,6 +361,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 "";
|
||||
}
|
||||
@@ -328,6 +404,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;
|
||||
@@ -354,7 +434,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);
|
||||
@@ -788,7 +868,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>
|
||||
@@ -819,13 +923,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">
|
||||
@@ -871,7 +975,7 @@ function ProfilePage({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{authTab === "phone" ? (
|
||||
{!showForgotPassword && authTab === "phone" ? (
|
||||
<>
|
||||
<label className={`auth-page__field${fieldErrors.phone ? " auth-page__field--error" : ""}`}>
|
||||
<span>
|
||||
@@ -912,9 +1016,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>
|
||||
|
||||
@@ -934,6 +1040,8 @@ function ProfilePage({
|
||||
<MobileOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -33,6 +33,8 @@ interface HistoryEntry {
|
||||
timestamp: number;
|
||||
score: number;
|
||||
grade: string;
|
||||
script?: string;
|
||||
result?: EvalResult;
|
||||
}
|
||||
|
||||
function getGrade(score: number): string {
|
||||
@@ -54,6 +56,8 @@ const TEXT_FILE_EXTENSIONS = [
|
||||
".fountain",
|
||||
".fdx",
|
||||
".rtf",
|
||||
".docx",
|
||||
".doc",
|
||||
".csv",
|
||||
".tsv",
|
||||
".json",
|
||||
@@ -99,7 +103,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 {
|
||||
@@ -168,6 +172,69 @@ 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 paragraphs: string[] = [];
|
||||
let currentLine = "";
|
||||
for (const match of textMatches) {
|
||||
const content = match.replace(/<[^>]+>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, "\"");
|
||||
currentLine += content;
|
||||
}
|
||||
// Try to find paragraph breaks
|
||||
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 currentLine.trim();
|
||||
}
|
||||
|
||||
const SCORE_DIMENSIONS: ScoreDimension[] = [
|
||||
{ key: "hook", label: "钩子设计", maxScore: 20, hint: "开篇吸引力·悬念设置·黄金三秒", detail: "开篇即抛出高概念钩子,悬念设置紧凑有力。" },
|
||||
{ key: "character", label: "角色塑造", maxScore: 15, hint: "人物立体度·动机合理性·弧光设计", detail: "主角动机有铺垫,配角功能性较强,人物弧光尚可进一步深化。" },
|
||||
@@ -222,6 +289,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);
|
||||
@@ -251,7 +319,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 {
|
||||
@@ -277,6 +361,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,
|
||||
@@ -289,6 +375,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);
|
||||
@@ -420,7 +520,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();
|
||||
@@ -241,9 +233,6 @@ function TokenUsagePage({
|
||||
</button>
|
||||
<strong>管理中心</strong>
|
||||
</div>
|
||||
<span className="management-center-status-pill">
|
||||
{enterpriseUsageLoading ? "正在同步企业用量" : enterpriseUsageError || "服务器已连接"}
|
||||
</span>
|
||||
<button type="button" onClick={refreshEnterpriseUsage}>
|
||||
<ReloadOutlined />
|
||||
刷新数据
|
||||
@@ -252,17 +241,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}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -238,6 +239,7 @@ function WorkbenchPage({
|
||||
const lastScrollTopRef = useRef(0);
|
||||
const shouldFollowNewMessagesRef = useRef(true);
|
||||
const pendingScrollToLatestRef = useRef(true);
|
||||
const genTracker = useGenerationTasks({ sourceView: "workbench" });
|
||||
const renderedMessageIdsRef = useRef<string[]>([]);
|
||||
const hasHandledInitialMessagesRef = useRef(false);
|
||||
|
||||
@@ -1851,6 +1853,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,
|
||||
@@ -1870,6 +1873,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