Compare commits

..

1 Commits

Author SHA1 Message Date
stringadmin f90502b864 feat: add bug feedback feature, fix canvas context menu on zoom, remove billing note from chat
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 14:10:23 +08:00
69 changed files with 1382 additions and 2959 deletions
+2 -2
View File
@@ -42,9 +42,9 @@ assertNoMatch(
/dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i, /dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i,
); );
assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/); assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/);
assertMatch("video generation must go through the app API", generationClient, /serverRequest<\{ taskId: string \}>\("ai\/video"/); assertMatch("video generation must go through the app API", generationClient, /buildApiUrl\("ai\/video"\)/);
assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/); assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/);
assertMatch("URL uploads must go through the app OSS API", generationClient, /serverRequest<\{ url: string; signedUrl\?: string; ossKey\?: string \}>\("oss\/upload-by-url"/); assertMatch("URL uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-by-url"\)/);
assertMatch( assertMatch(
"ecommerce video history must durable-copy media before saving", "ecommerce video history must durable-copy media before saving",
ecommerceVideoService, ecommerceVideoService,
+79 -183
View File
@@ -1,5 +1,20 @@
import {
BarChartOutlined,
BranchesOutlined,
CustomerServiceOutlined,
DeleteOutlined,
FolderOpenOutlined,
GlobalOutlined,
HeartOutlined,
HomeOutlined,
LayoutOutlined,
RobotOutlined,
ShoppingOutlined,
SwapOutlined,
ToolOutlined,
WalletOutlined,
} from "@ant-design/icons";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import { reportError } from "./utils/errorReporting"; import { reportError } from "./utils/errorReporting";
import { initNotificationPermission } from "./utils/generationNotifier"; import { initNotificationPermission } from "./utils/generationNotifier";
@@ -21,7 +36,6 @@ import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGene
import { translateTaskError } from "./utils/translateTaskError"; import { translateTaskError } from "./utils/translateTaskError";
import { recoverAndResumeTasks } from "./services/backgroundTaskRunner"; import { recoverAndResumeTasks } from "./services/backgroundTaskRunner";
import AppShell from "./components/AppShell"; import AppShell from "./components/AppShell";
import { ShellIcon } from "./components/ShellIcon";
const NotFoundPage = lazy(() => import("./components/NotFoundPage")); const NotFoundPage = lazy(() => import("./components/NotFoundPage"));
const CompliancePage = lazy(() => import("./features/compliance/CompliancePage")); const CompliancePage = lazy(() => import("./features/compliance/CompliancePage"));
import { cloneWorkflow, createBlankWorkflow } from "./data/workflows"; import { cloneWorkflow, createBlankWorkflow } from "./data/workflows";
@@ -36,7 +50,6 @@ const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarCons
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage")); const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage")); const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage")); const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
const EcommerceTemplatesPage = lazy(() => import("./features/ecommerce/EcommerceTemplatesPage"));
const HomePage = lazy(() => import("./features/home/HomePage")); const HomePage = lazy(() => import("./features/home/HomePage"));
const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage")); const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage"));
const MorePage = lazy(() => import("./features/more/MorePage")); const MorePage = lazy(() => import("./features/more/MorePage"));
@@ -47,7 +60,6 @@ const ResolutionUpscalePage = lazy(() => import("./features/resolution-upscale/R
const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/WatermarkRemovalPage")); const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/WatermarkRemovalPage"));
const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage")); const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage"));
const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage")); const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage"));
const SizeTemplatePage = lazy(() => import("./features/size-template/SizeTemplatePage"));
const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage")); const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage"));
const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage")); const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage"));
import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage"; import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage";
@@ -93,8 +105,6 @@ const VIEW_KEYS = new Set<WebViewKey>([
"assets", "assets",
"ecommerceHub", "ecommerceHub",
"ecommerce", "ecommerce",
"ecommerceTemplates",
"sizeTemplate",
"scriptTokens", "scriptTokens",
"tokenUsage", "tokenUsage",
"imageWorkbench", "imageWorkbench",
@@ -116,29 +126,6 @@ const VIEW_KEYS = new Set<WebViewKey>([
]); ]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]); const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]);
const LEGACY_PAGE_STYLE_VIEWS = new Set<WebViewKey>([
"login",
"workbench",
"canvas",
"community",
"communityReview",
"communityCaseAdd",
"assets",
"ecommerce",
"ecommerceHub",
"ecommerceTemplates",
"sizeTemplate",
"digitalHuman",
"characterMix",
"more",
]);
let legacyPageStylesPromise: Promise<unknown> | null = null;
function loadLegacyPageStyles(): Promise<unknown> {
legacyPageStylesPromise ??= import("./styles/pages/legacy-pages.css");
return legacyPageStylesPromise;
}
function normalizeViewKey(rawView: string): WebViewKey { function normalizeViewKey(rawView: string): WebViewKey {
const normalized = const normalized =
@@ -246,122 +233,61 @@ function App() {
const canvasAutoOpenedRecentRef = useRef(false); const canvasAutoOpenedRecentRef = useRef(false);
// Session store // Session store
const { const session = useSessionStore((s) => s.session);
session, const loginPromptOpen = useSessionStore((s) => s.loginPromptOpen);
loginPromptOpen, const pendingAction = useSessionStore((s) => s.pendingAction);
pendingAction, const sessionReplacedOpen = useSessionStore((s) => s.sessionReplacedOpen);
sessionReplacedOpen, const sessionReplacedMessage = useSessionStore((s) => s.sessionReplacedMessage);
sessionReplacedMessage, const setSession = useSessionStore((s) => s.setSession);
setSession, const openLoginPrompt = useSessionStore((s) => s.openLoginPrompt);
openLoginPrompt, const closeLoginPrompt = useSessionStore((s) => s.closeLoginPrompt);
closeLoginPrompt, const showSessionReplaced = useSessionStore((s) => s.showSessionReplaced);
showSessionReplaced, const hideSessionReplaced = useSessionStore((s) => s.hideSessionReplaced);
hideSessionReplaced, const clearSessionState = useSessionStore((s) => s.clearSession);
clearSession: clearSessionState,
} = useSessionStore(useShallow((s) => ({
session: s.session,
loginPromptOpen: s.loginPromptOpen,
pendingAction: s.pendingAction,
sessionReplacedOpen: s.sessionReplacedOpen,
sessionReplacedMessage: s.sessionReplacedMessage,
setSession: s.setSession,
openLoginPrompt: s.openLoginPrompt,
closeLoginPrompt: s.closeLoginPrompt,
showSessionReplaced: s.showSessionReplaced,
hideSessionReplaced: s.hideSessionReplaced,
clearSession: s.clearSession,
})));
// Project store // Project store
const { const projects = useProjectStore((s) => s.projects);
projects, const projectsLoaded = useProjectStore((s) => s.projectsLoaded);
projectsLoaded, const canvasWorkflow = useProjectStore((s) => s.canvasWorkflow);
canvasWorkflow, const currentCanvasProjectId = useProjectStore((s) => s.currentCanvasProjectId);
currentCanvasProjectId, const pendingDeleteProject = useProjectStore((s) => s.pendingDeleteProject);
pendingDeleteProject, const deleteProjectSubmitting = useProjectStore((s) => s.deleteProjectSubmitting);
deleteProjectSubmitting, const setProjects = useProjectStore((s) => s.setProjects);
setProjects, const setProjectsLoaded = useProjectStore((s) => s.setProjectsLoaded);
setProjectsLoaded, const setCanvasWorkflow = useProjectStore((s) => s.setCanvasWorkflow);
setCanvasWorkflow, const setCurrentCanvasProjectId = useProjectStore((s) => s.setCurrentCanvasProjectId);
setCurrentCanvasProjectId, const openDeleteProjectModal = useProjectStore((s) => s.openDeleteProject);
openDeleteProject: openDeleteProjectModal, const closeDeleteProjectModal = useProjectStore((s) => s.closeDeleteProject);
closeDeleteProject: closeDeleteProjectModal, const setDeleteProjectSubmitting = useProjectStore((s) => s.setDeleteProjectSubmitting);
setDeleteProjectSubmitting, const clearProjectState = useProjectStore((s) => s.clearProjectState);
clearProjectState,
} = useProjectStore(useShallow((s) => ({
projects: s.projects,
projectsLoaded: s.projectsLoaded,
canvasWorkflow: s.canvasWorkflow,
currentCanvasProjectId: s.currentCanvasProjectId,
pendingDeleteProject: s.pendingDeleteProject,
deleteProjectSubmitting: s.deleteProjectSubmitting,
setProjects: s.setProjects,
setProjectsLoaded: s.setProjectsLoaded,
setCanvasWorkflow: s.setCanvasWorkflow,
setCurrentCanvasProjectId: s.setCurrentCanvasProjectId,
openDeleteProject: s.openDeleteProject,
closeDeleteProject: s.closeDeleteProject,
setDeleteProjectSubmitting: s.setDeleteProjectSubmitting,
clearProjectState: s.clearProjectState,
})));
// Task store // Task store
const { const tasks = useTaskStore((s) => s.tasks);
tasks, const appendTask = useTaskStore((s) => s.appendTask);
appendTask, const mergeServerTasks = useTaskStore((s) => s.mergeServerTasks);
mergeServerTasks, const clearTasks = useTaskStore((s) => s.clearTasks);
clearTasks,
} = useTaskStore(useShallow((s) => ({
tasks: s.tasks,
appendTask: s.appendTask,
mergeServerTasks: s.mergeServerTasks,
clearTasks: s.clearTasks,
})));
// App store // App store
const { const usage = useAppStore((s) => s.usage);
usage, const runtimeNotifications = useAppStore((s) => s.runtimeNotifications);
runtimeNotifications, const serverNotifications = useAppStore((s) => s.serverNotifications);
serverNotifications, const activeView = useAppStore((s) => s.activeView);
activeView, const workspaceExpanded = useAppStore((s) => s.workspaceExpanded);
workspaceExpanded, const imageWorkbenchTool = useAppStore((s) => s.imageWorkbenchTool);
imageWorkbenchTool, const pendingEcommerceTemplate = useAppStore((s) => s.pendingEcommerceTemplate);
pendingEcommerceTemplate, const backendHealth = useAppStore((s) => s.backendHealth);
backendHealth, const setUsage = useAppStore((s) => s.setUsage);
setUsage, const pushNotification = useAppStore((s) => s.pushNotification);
pushNotification, const setRuntimeNotifications = useAppStore((s) => s.setRuntimeNotifications);
setRuntimeNotifications, const setServerNotifications = useAppStore((s) => s.setServerNotifications);
setServerNotifications, const setView = useAppStore((s) => s.setView);
setView, const setWorkspaceExpanded = useAppStore((s) => s.setWorkspaceExpanded);
setWorkspaceExpanded, const setImageWorkbenchTool = useAppStore((s) => s.setImageWorkbenchTool);
setImageWorkbenchTool, const setPendingEcommerceTemplate = useAppStore((s) => s.setPendingEcommerceTemplate);
setPendingEcommerceTemplate, const setBackendHealth = useAppStore((s) => s.setBackendHealth);
setBackendHealth, const markNotificationRead = useAppStore((s) => s.markNotificationRead);
markNotificationRead, const markAllNotificationsRead = useAppStore((s) => s.markAllNotificationsRead);
markAllNotificationsRead, const clearAppState = useAppStore((s) => s.clearAppState);
clearAppState,
} = useAppStore(useShallow((s) => ({
usage: s.usage,
runtimeNotifications: s.runtimeNotifications,
serverNotifications: s.serverNotifications,
activeView: s.activeView,
workspaceExpanded: s.workspaceExpanded,
imageWorkbenchTool: s.imageWorkbenchTool,
pendingEcommerceTemplate: s.pendingEcommerceTemplate,
backendHealth: s.backendHealth,
setUsage: s.setUsage,
pushNotification: s.pushNotification,
setRuntimeNotifications: s.setRuntimeNotifications,
setServerNotifications: s.setServerNotifications,
setView: s.setView,
setWorkspaceExpanded: s.setWorkspaceExpanded,
setImageWorkbenchTool: s.setImageWorkbenchTool,
setPendingEcommerceTemplate: s.setPendingEcommerceTemplate,
setBackendHealth: s.setBackendHealth,
markNotificationRead: s.markNotificationRead,
markAllNotificationsRead: s.markAllNotificationsRead,
clearAppState: s.clearAppState,
})));
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false); const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub"; const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
@@ -369,12 +295,6 @@ function App() {
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true); if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
}, [isEcommerceActive]); // eslint-disable-line react-hooks/exhaustive-deps }, [isEcommerceActive]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (LEGACY_PAGE_STYLE_VIEWS.has(activeView) || ecommerceEverMounted) {
void loadLegacyPageStyles();
}
}, [activeView, ecommerceEverMounted]);
// Dismiss boot splash after first render // Dismiss boot splash after first render
useEffect(() => { useEffect(() => {
const splash = document.getElementById("app-boot-splash"); const splash = document.getElementById("app-boot-splash");
@@ -426,24 +346,24 @@ function App() {
const navItems = useMemo<WebNavItem[]>( const navItems = useMemo<WebNavItem[]>(
() => [ () => [
{ key: "home", label: "首页", hint: "项目入口", icon: <ShellIcon name="home" /> }, { key: "home", label: "首页", hint: "项目入口", icon: <HomeOutlined /> },
{ key: "workbench", label: "生成", hint: "对话生成页面", icon: <ShellIcon name="robot" /> }, { key: "workbench", label: "生成", hint: "对话生成页面", icon: <RobotOutlined /> },
{ {
key: "ecommerce", key: "ecommerce",
label: "电商生成", label: "电商生成",
hint: "AI创作与海报生成", hint: "AI创作与海报生成",
icon: <ShellIcon name="shopping" />, icon: <ShoppingOutlined />,
}, },
{ key: "canvas", label: "画布", hint: "进入自由画布编排", icon: <ShellIcon name="branches" /> }, { key: "canvas", label: "画布", hint: "进入自由画布编排", icon: <BranchesOutlined /> },
{ key: "community", label: "社区", hint: "案例分享与导入", icon: <ShellIcon name="global" /> }, { key: "community", label: "社区", hint: "案例分享与导入", icon: <GlobalOutlined /> },
{ key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: <ShellIcon name="bar-chart" /> }, { key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: <BarChartOutlined /> },
{ key: "tokenUsage", label: "Token消耗", hint: "成员、服务与调用记录", icon: <ShellIcon name="wallet" /> }, { key: "tokenUsage", label: "Token消耗", hint: "成员、服务与调用记录", icon: <WalletOutlined /> },
{ key: "providerHealth", label: "服务商健康", hint: "AI 服务商状态与监控", icon: <ShellIcon name="heart" /> }, { key: "providerHealth", label: "服务商健康", hint: "AI 服务商状态与监控", icon: <HeartOutlined /> },
{ key: "assets", label: "资产库", hint: "角色、场景、道具", icon: <ShellIcon name="folder" /> }, { key: "assets", label: "资产库", hint: "角色、场景、道具", icon: <FolderOpenOutlined /> },
{ key: "agent", label: "Agent", hint: "拆解与规划", icon: <ShellIcon name="robot" /> }, { key: "agent", label: "Agent", hint: "拆解与规划", icon: <RobotOutlined /> },
{ key: "digitalHuman", label: "数字人", hint: "口播与人像生成", icon: <ShellIcon name="customer-service" /> }, { key: "digitalHuman", label: "数字人", hint: "口播与人像生成", icon: <CustomerServiceOutlined /> },
{ key: "characterMix", label: "角色迁移", hint: "人物视频迁移", icon: <ShellIcon name="swap" /> }, { key: "characterMix", label: "角色迁移", hint: "人物视频迁移", icon: <SwapOutlined /> },
{ key: "more", label: "工具盒", hint: "图像与镜头工具", icon: <ShellIcon name="tool" /> }, { key: "more", label: "工具盒", hint: "图像与镜头工具", icon: <ToolOutlined /> },
], ],
[], [],
); );
@@ -1167,30 +1087,6 @@ function App() {
case "ecommerce": case "ecommerce":
case "ecommerceHub": case "ecommerceHub":
return null; return null;
case "ecommerceTemplates":
return (
<EcommerceTemplatesPage
projects={projects}
onOpenMore={() => handleSetView("more")}
onOpenEcommerce={() => handleSetView("ecommerce")}
onSelectTemplate={(template) => {
setPendingEcommerceTemplate(template);
handleSetView("ecommerce");
}}
onStartCreate={handleStartTemplateCanvasCreate}
onOpenProject={handleOpenProject}
onDeleteProject={handleDeleteProject}
/>
);
case "sizeTemplate":
return (
<SizeTemplatePage
isAuthenticated={Boolean(session)}
onOpenMore={() => handleSetView("more")}
onOpenEcommerce={() => handleSetView("ecommerce")}
onSelectView={handleSetView}
/>
);
case "digitalHuman": case "digitalHuman":
return ( return (
<DigitalHumanPage <DigitalHumanPage
@@ -1440,7 +1336,7 @@ function App() {
/> />
<section className="project-delete-modal__panel"> <section className="project-delete-modal__panel">
<span className="project-delete-modal__icon"> <span className="project-delete-modal__icon">
<ShellIcon name="delete" /> <DeleteOutlined />
</span> </span>
<h2 id="project-delete-title"></h2> <h2 id="project-delete-title"></h2>
<p>{pendingDeleteProject.name}</p> <p>{pendingDeleteProject.name}</p>
+91 -70
View File
@@ -3,7 +3,6 @@ import {
buildAuthHeaders, buildAuthHeaders,
isRecord, isRecord,
readJsonResponse, readJsonResponse,
serverRequest,
throwResponseError, throwResponseError,
} from "./serverConnection"; } from "./serverConnection";
import { isOptionalApiRouteMissing } from "./apiErrorUtils"; import { isOptionalApiRouteMissing } from "./apiErrorUtils";
@@ -244,10 +243,6 @@ function emitImageRouteDebug(label: string, payload: Record<string, unknown>): v
let taskHistoryRouteMissing = false; let taskHistoryRouteMissing = false;
const TASK_SUBMIT_TIMEOUT_MS = 90_000;
const TASK_STATUS_TIMEOUT_MS = 20_000;
const NON_RETRYING_REQUEST = { maxRetries: 0 };
export const aiGenerationClient = { export const aiGenerationClient = {
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> { async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
const requestUrl = buildApiUrl("ai/image"); const requestUrl = buildApiUrl("ai/image");
@@ -261,13 +256,15 @@ export const aiGenerationClient = {
projectId: input.projectId, projectId: input.projectId,
conversationId: input.conversationId, conversationId: input.conversationId,
}); });
const payload = await serverRequest<ImageTaskCreateResponse>("ai/image", { const res = await fetch(requestUrl, {
method: "POST", method: "POST",
body: input, headers: buildAuthHeaders(),
timeoutMs: TASK_SUBMIT_TIMEOUT_MS, body: JSON.stringify(input),
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Image generation request failed",
}); });
if (!res.ok) {
await throwResponseError(res, "Image generation request failed");
}
const payload = await readJsonResponse<ImageTaskCreateResponse>(res, "Image generation response failed");
if (payload.providerDebug) { if (payload.providerDebug) {
emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>); emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>);
} }
@@ -275,83 +272,96 @@ export const aiGenerationClient = {
}, },
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> { async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/video", { const res = await fetch(buildApiUrl("ai/video"), {
method: "POST", method: "POST",
body: input, headers: buildAuthHeaders(),
timeoutMs: TASK_SUBMIT_TIMEOUT_MS, body: JSON.stringify(input),
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video generation request failed",
}); });
if (!res.ok) {
await throwResponseError(res, "Video generation request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Video generation response failed");
}, },
async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> { async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/video/super-resolve", { const res = await fetch(buildApiUrl("ai/video/super-resolve"), {
method: "POST", method: "POST",
body: input, headers: buildAuthHeaders(),
timeoutMs: TASK_SUBMIT_TIMEOUT_MS, body: JSON.stringify(input),
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video super-resolution request failed",
}); });
if (!res.ok) {
await throwResponseError(res, "Video super-resolution request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Video super-resolution response failed");
}, },
async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> { async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/video/erase-subtitles", { const res = await fetch(buildApiUrl("ai/video/erase-subtitles"), {
method: "POST", method: "POST",
body: input, headers: buildAuthHeaders(),
timeoutMs: TASK_SUBMIT_TIMEOUT_MS, body: JSON.stringify(input),
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Subtitle removal request failed",
}); });
if (!res.ok) {
await throwResponseError(res, "Subtitle removal request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Subtitle removal response failed");
}, },
async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> { async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/video/edit", { const res = await fetch(buildApiUrl("ai/video/edit"), {
method: "POST", method: "POST",
body: { ...input, model: input.model || "happyhorse-1.0-video-edit" }, headers: buildAuthHeaders(),
timeoutMs: TASK_SUBMIT_TIMEOUT_MS, body: JSON.stringify({ ...input, model: input.model || "happyhorse-1.0-video-edit" }),
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video edit request failed",
}); });
if (!res.ok) {
await throwResponseError(res, "Video edit request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Video edit response failed");
}, },
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> { async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/image/super-resolve", { const res = await fetch(buildApiUrl("ai/image/super-resolve"), {
method: "POST", method: "POST",
body: input, headers: buildAuthHeaders(),
timeoutMs: TASK_SUBMIT_TIMEOUT_MS, body: JSON.stringify(input),
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Image super-resolution request failed",
}); });
if (!res.ok) {
await throwResponseError(res, "Image super-resolution request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Image super-resolution response failed");
}, },
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> { async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/image/edit", { const res = await fetch(buildApiUrl("ai/image/edit"), {
method: "POST", method: "POST",
body: input, headers: buildAuthHeaders(),
timeoutMs: TASK_SUBMIT_TIMEOUT_MS, body: JSON.stringify(input),
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Image edit request failed",
}); });
if (!res.ok) {
await throwResponseError(res, "Image edit request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Image edit response failed");
}, },
async cancelTask(taskId: string): Promise<void> { async cancelTask(taskId: string): Promise<void> {
try { const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/cancel`), {
await serverRequest<void>(`ai/tasks/${taskId}/cancel`, {
method: "PATCH", method: "PATCH",
maxRetries: NON_RETRYING_REQUEST.maxRetries, headers: buildAuthHeaders(),
fallbackMessage: "Task cancel failed",
}); });
} catch (error) { if (!res.ok && res.status !== 404) {
if (isOptionalApiRouteMissing(error)) return; await throwResponseError(res, "Task cancel failed");
throw error;
} }
}, },
async getTaskStatus(taskId: string): Promise<AiTaskStatus> { async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, { const res = await fetch(buildApiUrl(`ai/tasks/${taskId}`), {
timeoutMs: TASK_STATUS_TIMEOUT_MS, method: "GET",
fallbackMessage: "Task status request failed", headers: buildAuthHeaders(),
}); });
if (!res.ok) {
await throwResponseError(res, "Task status request failed");
}
return readJsonResponse<AiTaskStatus>(res, "Task status response failed");
}, },
async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> { async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> {
@@ -377,11 +387,13 @@ export const aiGenerationClient = {
if (params?.status) search.set("status", params.status); if (params?.status) search.set("status", params.status);
if (params?.type) search.set("type", params.type); if (params?.type) search.set("type", params.type);
if (params?.projectId) search.set("projectId", params.projectId); if (params?.projectId) search.set("projectId", params.projectId);
try { const res = await fetch(buildApiUrl(`ai/tasks${search.toString() ? `?${search}` : ""}`), {
const payload = await serverRequest<unknown>(`ai/tasks${search.toString() ? `?${search}` : ""}`, { method: "GET",
fallbackMessage: "Task history request failed", headers: buildAuthHeaders(),
}); });
return extractTaskList(payload).map(toPreviewTask); if (!res.ok) {
try {
await throwResponseError(res, "Task history request failed");
} catch (error) { } catch (error) {
if (isOptionalApiRouteMissing(error)) { if (isOptionalApiRouteMissing(error)) {
taskHistoryRouteMissing = true; taskHistoryRouteMissing = true;
@@ -389,29 +401,35 @@ export const aiGenerationClient = {
} }
throw error; throw error;
} }
}
const payload = await readJsonResponse<unknown>(res, "Task history response failed");
return extractTaskList(payload).map(toPreviewTask);
}, },
async bindTaskToConversation(taskId: string, conversationId: number): Promise<void> { async bindTaskToConversation(taskId: string, conversationId: number): Promise<void> {
try { const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/conversation`), {
await serverRequest<void>(`ai/tasks/${taskId}/conversation`, {
method: "PATCH", method: "PATCH",
body: { conversationId }, headers: buildAuthHeaders(),
maxRetries: NON_RETRYING_REQUEST.maxRetries, body: JSON.stringify({ conversationId }),
fallbackMessage: "Task conversation binding failed",
}); });
} catch (error) { if (res.status === 404) {
if (isOptionalApiRouteMissing(error)) return; return;
throw error; }
if (!res.ok) {
await throwResponseError(res, "Task conversation binding failed");
} }
}, },
async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload", { const res = await fetch(buildApiUrl("oss/upload"), {
method: "POST", method: "POST",
body: input, headers: buildAuthHeaders(),
maxRetries: NON_RETRYING_REQUEST.maxRetries, body: JSON.stringify(input),
fallbackMessage: "Asset upload failed",
}); });
if (!res.ok) {
await throwResponseError(res, "Asset upload failed");
}
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload response failed");
}, },
async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
@@ -433,12 +451,15 @@ export const aiGenerationClient = {
}, },
async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload-by-url", { const res = await fetch(buildApiUrl("oss/upload-by-url"), {
method: "POST", method: "POST",
body: input, headers: buildAuthHeaders(),
maxRetries: NON_RETRYING_REQUEST.maxRetries, body: JSON.stringify(input),
fallbackMessage: "Asset upload by URL failed",
}); });
if (!res.ok) {
await throwResponseError(res, "Asset upload by URL failed");
}
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload by URL response failed");
}, },
subscribeTaskStatus( subscribeTaskStatus(
+1 -7
View File
@@ -67,13 +67,7 @@ function normalizeAssetStatus(value: unknown): WebAssetItem["status"] {
} }
function normalizeTags(value: unknown): string[] { function normalizeTags(value: unknown): string[] {
if (!Array.isArray(value)) return []; return Array.isArray(value) ? value.map((item) => toStringValue(item)).filter(Boolean) : [];
const tags: string[] = [];
for (const item of value) {
const tag = toStringValue(item);
if (tag) tags.push(tag);
}
return tags;
} }
function normalizeAsset(raw: unknown): ServerAssetItem { function normalizeAsset(raw: unknown): ServerAssetItem {
+32
View File
@@ -0,0 +1,32 @@
import { serverRequest } from "./serverConnection";
export interface BugFeedbackInput {
title: string;
description: string;
screenshotUrl?: string;
}
export interface BugFeedbackItem {
id: number;
title: string;
description: string;
screenshotUrl?: string | null;
status: "pending" | "approved" | "rejected";
adminNote?: string | null;
createdAt: string;
}
export const bugFeedbackClient = {
async submit(input: BugFeedbackInput): Promise<{ id: number; status: string; createdAt: string }> {
const payload = await serverRequest<{ feedback: { id: number; status: string; createdAt: string } }>(
"bug-feedback",
{ method: "POST", body: input },
);
return payload.feedback;
},
async listMine(): Promise<BugFeedbackItem[]> {
const payload = await serverRequest<{ feedbacks?: BugFeedbackItem[] }>("bug-feedback/mine");
return Array.isArray(payload.feedbacks) ? payload.feedbacks : [];
},
};
+3 -7
View File
@@ -62,13 +62,9 @@ function toStringValue(value: unknown, fallback = ""): string {
} }
function toStringArray(value: unknown): string[] { function toStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []; return Array.isArray(value)
const result: string[] = []; ? value.map((item) => toStringValue(item)).filter(Boolean)
for (const item of value) { : [];
const text = toStringValue(item);
if (text) result.push(text);
}
return result;
} }
function toMetadata(value: unknown): Record<string, unknown> { function toMetadata(value: unknown): Record<string, unknown> {
+2 -7
View File
@@ -376,18 +376,13 @@ function createWorkflowFingerprint(workflow: WebCanvasWorkflow): string {
function toActivePackages(value: unknown): WebUserSession["user"]["activePackages"] { function toActivePackages(value: unknown): WebUserSession["user"]["activePackages"] {
if (!Array.isArray(value)) return undefined; if (!Array.isArray(value)) return undefined;
const packages: NonNullable<WebUserSession["user"]["activePackages"]> = []; return value.filter(isRecord).map((entry) => ({
for (const entry of value) {
if (!isRecord(entry)) continue;
packages.push({
name: toStringValue(entry.name, "Preview package"), name: toStringValue(entry.name, "Preview package"),
expiresAt: toStringValue(entry.expiresAt ?? entry.expires_at, ""), expiresAt: toStringValue(entry.expiresAt ?? entry.expires_at, ""),
remainingImage: toNumber(entry.remainingImage ?? entry.remaining_image), remainingImage: toNumber(entry.remainingImage ?? entry.remaining_image),
remainingVideo: toNumber(entry.remainingVideo ?? entry.remaining_video), remainingVideo: toNumber(entry.remainingVideo ?? entry.remaining_video),
remainingText: toNumber(entry.remainingText ?? entry.remaining_text), remainingText: toNumber(entry.remainingText ?? entry.remaining_text),
}); }));
}
return packages;
} }
function normalizeUser(raw: unknown): WebUserSession["user"] | null { function normalizeUser(raw: unknown): WebUserSession["user"] | null {
+3 -7
View File
@@ -49,13 +49,9 @@ function normalizeModelOption(raw: unknown): ModelCapabilityOption | null {
} }
function normalizeModelList(value: unknown): ModelCapabilityOption[] { function normalizeModelList(value: unknown): ModelCapabilityOption[] {
if (!Array.isArray(value)) return []; return Array.isArray(value)
const options: ModelCapabilityOption[] = []; ? value.map(normalizeModelOption).filter((item): item is ModelCapabilityOption => Boolean(item))
for (const item of value) { : [];
const option = normalizeModelOption(item);
if (option) options.push(option);
}
return options;
} }
function createFallbackCapabilities(): WebModelCapabilities { function createFallbackCapabilities(): WebModelCapabilities {
+4 -17
View File
@@ -71,19 +71,10 @@ function normalizeTask(raw: unknown): ServerProjectTask | null {
} }
function extractTasks(payload: unknown): ServerProjectTask[] { function extractTasks(payload: unknown): ServerProjectTask[] {
const normalizeTasks = (rows: unknown[]): ServerProjectTask[] => { if (Array.isArray(payload)) return payload.map(normalizeTask).filter(Boolean) as ServerProjectTask[];
const tasks: ServerProjectTask[] = [];
for (const row of rows) {
const task = normalizeTask(row);
if (task) tasks.push(task);
}
return tasks;
};
if (Array.isArray(payload)) return normalizeTasks(payload);
if (!isRecord(payload)) return []; if (!isRecord(payload)) return [];
const rows = payload.tasks ?? payload.items; const rows = payload.tasks ?? payload.items;
return Array.isArray(rows) ? normalizeTasks(rows) : []; return Array.isArray(rows) ? (rows.map(normalizeTask).filter(Boolean) as ServerProjectTask[]) : [];
} }
function taskTitle(task: ServerProjectTask): string { function taskTitle(task: ServerProjectTask): string {
@@ -119,12 +110,8 @@ export const projectTaskClient = {
}, },
async listForProjects(projectIds: string[]): Promise<WebGenerationPreviewTask[]> { async listForProjects(projectIds: string[]): Promise<WebGenerationPreviewTask[]> {
const uniqueIds = new Set<string>(); const uniqueIds = Array.from(new Set(projectIds.map((id) => id.trim()).filter(Boolean)));
for (const projectId of projectIds) { const results = await Promise.all(uniqueIds.map((id) => listProjectTasks(id)));
const id = projectId.trim();
if (id) uniqueIds.add(id);
}
const results = await Promise.all(Array.from(uniqueIds, (id) => listProjectTasks(id)));
return results.flat(); return results.flat();
}, },
+8 -3
View File
@@ -1,4 +1,4 @@
import { serverRequest } from "./serverConnection"; import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
export interface ProviderHealthEntry { export interface ProviderHealthEntry {
status: string; status: string;
@@ -32,8 +32,13 @@ export interface ProviderHealthResponse {
export const providerHealthClient = { export const providerHealthClient = {
async getStatus(): Promise<ProviderHealthResponse> { async getStatus(): Promise<ProviderHealthResponse> {
return serverRequest<ProviderHealthResponse>("admin/providers/status", { const res = await fetch(buildApiUrl("admin/providers/status"), {
fallbackMessage: "Provider health request failed", method: "GET",
headers: buildAuthHeaders(),
}); });
if (!res.ok) {
throw new Error(`Provider health request failed (${res.status})`);
}
return res.json() as Promise<ProviderHealthResponse>;
}, },
}; };
+12 -23
View File
@@ -1,4 +1,4 @@
import { serverRequest } from "./serverConnection"; import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
export interface ScriptEvalResult { export interface ScriptEvalResult {
totalScore: number; totalScore: number;
@@ -107,17 +107,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value)); return Boolean(value && typeof value === "object" && !Array.isArray(value));
} }
function normalizeEvidenceItems(source: unknown[], limit: number): string[] {
const items: string[] = [];
for (const item of source) {
const value = String(item).trim();
if (!value) continue;
items.push(value);
if (items.length >= limit) break;
}
return items;
}
function normalizeNestedScores(value: unknown): Record<string, Record<string, number>> { function normalizeNestedScores(value: unknown): Record<string, Record<string, number>> {
if (!isRecord(value)) return {}; if (!isRecord(value)) return {};
@@ -143,7 +132,7 @@ function normalizeEvidence(value: unknown): Record<string, string[]> {
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined); const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
if (!Array.isArray(source)) continue; if (!Array.isArray(source)) continue;
const items = normalizeEvidenceItems(source, 3); const items = source.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3);
if (items.length > 0) normalized[dimensionKey] = items; if (items.length > 0) normalized[dimensionKey] = items;
} }
@@ -151,13 +140,10 @@ function normalizeEvidence(value: unknown): Record<string, string[]> {
} }
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> { export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
const payload = await serverRequest<{ const res = await fetch(buildApiUrl("ai/chat"), {
content?: string;
choices?: Array<{ message?: { content?: string } }>;
text?: string;
}>("ai/chat", {
method: "POST", method: "POST",
body: { headers: buildAuthHeaders(),
body: JSON.stringify({
model: MODEL, model: MODEL,
messages: [ messages: [
{ role: "system", content: EVAL_SYSTEM_PROMPT }, { role: "system", content: EVAL_SYSTEM_PROMPT },
@@ -167,13 +153,16 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
stream: false, stream: false,
temperature: 0.3, temperature: 0.3,
max_tokens: 4096, max_tokens: 4096,
}, }),
signal, signal,
timeoutMs: 180_000,
maxRetries: 0,
fallbackMessage: "评测请求失败",
}); });
if (!res.ok) {
const errText = await res.text().catch(() => "");
throw new Error(`评测请求失败 (${res.status}): ${errText.slice(0, 200)}`);
}
const payload = await res.json();
const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!content) throw new Error("模型未返回有效内容"); if (!content) throw new Error("模型未返回有效内容");
+3 -8
View File
@@ -22,9 +22,6 @@ export interface ServerRequestOptions {
signal?: AbortSignal; signal?: AbortSignal;
/** Per-request timeout in ms. Defaults to DEFAULT_REQUEST_TIMEOUT_MS. Pass 0 to disable. */ /** Per-request timeout in ms. Defaults to DEFAULT_REQUEST_TIMEOUT_MS. Pass 0 to disable. */
timeoutMs?: number; timeoutMs?: number;
/** Defaults to 2. Use 0 for non-idempotent task submission endpoints. */
maxRetries?: number;
fallbackMessage?: string;
} }
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
@@ -346,10 +343,8 @@ const MAX_RETRIES = 2;
export async function serverRequest<T>(path: string, options?: ServerRequestOptions): Promise<T> { export async function serverRequest<T>(path: string, options?: ServerRequestOptions): Promise<T> {
let lastError: unknown; let lastError: unknown;
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const maxRetries = options?.maxRetries ?? MAX_RETRIES;
const fallbackMessage = options?.fallbackMessage || "Request failed";
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const controller = timeoutMs > 0 ? new AbortController() : null; const controller = timeoutMs > 0 ? new AbortController() : null;
const timeoutId = const timeoutId =
controller && typeof window !== "undefined" controller && typeof window !== "undefined"
@@ -371,11 +366,11 @@ export async function serverRequest<T>(path: string, options?: ServerRequestOpti
credentials: "include", credentials: "include",
}); });
const payload = await readJsonResponse<unknown>(response, fallbackMessage); const payload = await readJsonResponse<unknown>(response, "Request failed");
return (options?.raw ? payload : unwrapApiPayload(payload)) as T; return (options?.raw ? payload : unwrapApiPayload(payload)) as T;
} catch (error) { } catch (error) {
lastError = error; lastError = error;
if (attempt < maxRetries && isRetryable(error) && !options?.signal?.aborted) { if (attempt < MAX_RETRIES && isRetryable(error) && !options?.signal?.aborted) {
await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error))); await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error)));
continue; continue;
} }
+62 -63
View File
@@ -1,18 +1,30 @@
import {
ArrowDownOutlined,
ArrowUpOutlined,
BugOutlined,
CheckCircleOutlined,
FlagOutlined,
InfoCircleOutlined,
LoginOutlined,
LogoutOutlined,
PlusCircleOutlined,
UserOutlined,
WalletOutlined,
} from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient"; import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient";
import { toast } from "./toast/toastStore"; import { toast } from "./toast/toastStore";
import { BugFeedbackModal } from "./BugFeedbackModal";
import type { ServerConnectionHealth } from "../api/serverConnection"; import type { ServerConnectionHealth } from "../api/serverConnection";
import { ossAssets } from "../data/ossAssets"; import { ossAssets } from "../data/ossAssets";
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions"; import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types"; import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types";
import NotificationCenter from "./NotificationCenter"; import NotificationCenter from "./NotificationCenter";
import { RechargeModal } from "./RechargeModal/RechargeModal";
import { AnimatedPanel } from "./AnimatedPanel"; import { AnimatedPanel } from "./AnimatedPanel";
import AdminMonitor from "./AdminMonitor"; import AdminMonitor from "./AdminMonitor";
import CookieConsentBanner from "./CookieConsentBanner"; import CookieConsentBanner from "./CookieConsentBanner";
import { loadRechargeModal, type RechargeModalComponent } from "./RechargeModal/loadRechargeModal";
import { ShellIcon } from "./ShellIcon";
import { loadDarkGreenTheme } from "../styles/loadDarkGreenTheme";
interface AppShellProps { interface AppShellProps {
activeView: WebViewKey; activeView: WebViewKey;
@@ -31,32 +43,6 @@ interface AppShellProps {
} }
const BRAND_LOGO_URL = ossAssets.brand.logo; const BRAND_LOGO_URL = ossAssets.brand.logo;
const TOOL_SURFACE_VIEW_SET = new Set<WebViewKey>([
"workbench",
"canvas",
"more",
"scriptTokens",
"tokenUsage",
"ecommerceTemplates",
"sizeTemplate",
"imageWorkbench",
"resolutionUpscale",
"digitalHuman",
"dialogGenerator",
"avatarConsole",
"characterMix",
] as WebViewKey[]);
const PRIMARY_NAV_ORDER: WebViewKey[] = [
"workbench",
"ecommerce",
"sizeTemplate",
"canvas",
"scriptTokens",
"tokenUsage",
"community",
"assets",
"more",
];
function formatBalance(cents: number): string { function formatBalance(cents: number): string {
const value = Math.max(0, cents) / 100; const value = Math.max(0, cents) / 100;
@@ -83,8 +69,8 @@ function AppShell({
const submenuHideTimerRef = useRef<number | null>(null); const submenuHideTimerRef = useRef<number | null>(null);
const [profileOpen, setProfileOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false);
const [rechargeOpen, setRechargeOpen] = useState(false); const [rechargeOpen, setRechargeOpen] = useState(false);
const [RechargeModal, setRechargeModal] = useState<RechargeModalComponent | null>(null);
const [infoOpen, setInfoOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false);
const [bugFeedbackOpen, setBugFeedbackOpen] = useState(false);
const infoRef = useRef<HTMLDivElement>(null); const infoRef = useRef<HTMLDivElement>(null);
const [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null); const [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null);
const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({}); const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({});
@@ -92,14 +78,39 @@ function AppShell({
const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null); const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null);
const isAuthView = activeView === "login"; const isAuthView = activeView === "login";
const isImmersiveView = activeView === "agent" || activeView === "avatarConsole"; const isImmersiveView = activeView === "agent" || activeView === "avatarConsole";
const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home"; const showFloatingNav = (!isAuthView || !!session) && !isImmersiveView && activeView !== "home";
const showPageScrollActions = showFloatingNav && !TOOL_SURFACE_VIEW_SET.has(activeView); const toolSurfaceViews = [
"workbench",
"canvas",
"more",
"scriptTokens",
"tokenUsage",
"ecommerceTemplates",
"sizeTemplate",
"imageWorkbench",
"resolutionUpscale",
"digitalHuman",
"dialogGenerator",
"avatarConsole",
"characterMix",
] as WebViewKey[];
const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView);
const visibleNavItems = useMemo( const visibleNavItems = useMemo(
() => { () => {
const navItemByKey = new Map(navItems.map((item) => [item.key, item])); const orderedKeys: WebViewKey[] = [
return PRIMARY_NAV_ORDER "workbench",
.map((key) => navItemByKey.get(key)) "ecommerce",
"sizeTemplate",
"canvas",
"scriptTokens",
"tokenUsage",
"community",
"assets",
"more",
];
return orderedKeys
.map((key) => navItems.find((item) => item.key === key))
.filter((item): item is WebNavItem => Boolean(item)); .filter((item): item is WebNavItem => Boolean(item));
}, },
[navItems], [navItems],
@@ -119,7 +130,6 @@ function AppShell({
return; return;
} }
void loadDarkGreenTheme();
document.documentElement.dataset.theme = "dark"; document.documentElement.dataset.theme = "dark";
document.documentElement.dataset.uiTheme = "dark-green"; document.documentElement.dataset.uiTheme = "dark-green";
document.documentElement.style.colorScheme = "dark"; document.documentElement.style.colorScheme = "dark";
@@ -184,21 +194,6 @@ function AppShell({
}; };
}, []); }, []);
useEffect(() => {
if (!rechargeOpen || RechargeModal) return;
let cancelled = false;
void loadRechargeModal().then((component) => {
if (!cancelled) {
setRechargeModal(() => component);
}
});
return () => {
cancelled = true;
};
}, [RechargeModal, rechargeOpen]);
const showSubmenu = (key: WebViewKey) => { const showSubmenu = (key: WebViewKey) => {
if (submenuHideTimerRef.current) { if (submenuHideTimerRef.current) {
window.clearTimeout(submenuHideTimerRef.current); window.clearTimeout(submenuHideTimerRef.current);
@@ -319,7 +314,7 @@ function AppShell({
aria-label="返回页面顶部" aria-label="返回页面顶部"
onClick={() => scrollActivePage("top")} onClick={() => scrollActivePage("top")}
> >
<ShellIcon name="arrow-up" /> <ArrowUpOutlined />
</button> </button>
<button <button
type="button" type="button"
@@ -328,7 +323,7 @@ function AppShell({
aria-label="到达页面底部" aria-label="到达页面底部"
onClick={() => scrollActivePage("bottom")} onClick={() => scrollActivePage("bottom")}
> >
<ShellIcon name="arrow-down" /> <ArrowDownOutlined />
</button> </button>
</div> </div>
) : null} ) : null}
@@ -351,6 +346,11 @@ function AppShell({
onMarkAllRead={onMarkAllNotificationsRead} onMarkAllRead={onMarkAllNotificationsRead}
/> />
)} )}
{session && (
<button className="info-button" type="button" aria-label="Bug反馈" title="Bug反馈" onClick={() => setBugFeedbackOpen(true)}>
<BugOutlined />
</button>
)}
<div className="info-popover-anchor" ref={infoRef}> <div className="info-popover-anchor" ref={infoRef}>
<button <button
className="info-button" className="info-button"
@@ -358,7 +358,7 @@ function AppShell({
aria-label="网站信息" aria-label="网站信息"
onClick={() => setInfoOpen((c) => !c)} onClick={() => setInfoOpen((c) => !c)}
> >
<ShellIcon name="info-circle" /> <InfoCircleOutlined />
</button> </button>
<AnimatedPanel open={infoOpen} className="info-popover panel-surface"> <AnimatedPanel open={infoOpen} className="info-popover panel-surface">
<dl> <dl>
@@ -381,7 +381,7 @@ function AppShell({
aria-label={`积分余额 ${displayedBalanceLabel}`} aria-label={`积分余额 ${displayedBalanceLabel}`}
onClick={() => toast.info("充值功能即将开放,敬请期待")} onClick={() => toast.info("充值功能即将开放,敬请期待")}
> >
<ShellIcon name="wallet" /> <WalletOutlined />
<span className="member-button__label">{displayedBalanceLabel}</span> <span className="member-button__label">{displayedBalanceLabel}</span>
</button> </button>
<div className="profile-popover-anchor" ref={profileRef}> <div className="profile-popover-anchor" ref={profileRef}>
@@ -405,7 +405,7 @@ function AppShell({
</> </>
) : ( ) : (
<> <>
<ShellIcon name="login" /> <LoginOutlined />
<span> / </span> <span> / </span>
</> </>
)} )}
@@ -433,7 +433,7 @@ function AppShell({
<div className="profile-popover__footer"> <div className="profile-popover__footer">
<span>{session?.source === "server" ? "服务器会话" : "预览会话"}</span> <span>{session?.source === "server" ? "服务器会话" : "预览会话"}</span>
<button type="button" onClick={onLogout}> <button type="button" onClick={onLogout}>
<ShellIcon name="logout" /> <LogoutOutlined />
退 退
</button> </button>
</div> </div>
@@ -445,7 +445,7 @@ function AppShell({
onSelectView("login"); onSelectView("login");
}} }}
> >
<ShellIcon name="user" /> <UserOutlined />
</button> </button>
<button <button
@@ -456,7 +456,7 @@ function AppShell({
onSelectView("report"); onSelectView("report");
}} }}
> >
<ShellIcon name="flag" /> <FlagOutlined />
</button> </button>
{showCommunityReview ? ( {showCommunityReview ? (
@@ -469,7 +469,7 @@ function AppShell({
onSelectView("communityReview"); onSelectView("communityReview");
}} }}
> >
<ShellIcon name="check-circle" /> <CheckCircleOutlined />
</button> </button>
</> </>
@@ -484,7 +484,7 @@ function AppShell({
onSelectView("communityCaseAdd"); onSelectView("communityCaseAdd");
}} }}
> >
<ShellIcon name="plus-circle" /> <PlusCircleOutlined />
</button> </button>
</> </>
@@ -498,9 +498,8 @@ function AppShell({
</main> </main>
</div> </div>
{session?.user.role === "admin" ? <AdminMonitor /> : null} {session?.user.role === "admin" ? <AdminMonitor /> : null}
{rechargeOpen && RechargeModal ? (
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} /> <RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
) : null} <BugFeedbackModal open={bugFeedbackOpen} onClose={() => setBugFeedbackOpen(false)} />
<CookieConsentBanner /> <CookieConsentBanner />
</div> </div>
); );
+131
View File
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { BugOutlined, CameraOutlined, CloseOutlined } from "@ant-design/icons";
import { bugFeedbackClient, type BugFeedbackItem } from "../api/bugFeedbackClient";
import { uploadAssetWithProgress } from "../api/uploadWithProgress";
import { toast } from "./toast/toastStore";
interface BugFeedbackModalProps {
open: boolean;
onClose: () => void;
}
export function BugFeedbackModal({ open, onClose }: BugFeedbackModalProps) {
const [tab, setTab] = useState<"submit" | "history">("submit");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [screenshotUrl, setScreenshotUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [history, setHistory] = useState<BugFeedbackItem[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open && tab === "history") loadHistory();
}, [open, tab]);
const loadHistory = useCallback(async () => {
setHistoryLoading(true);
try {
const items = await bugFeedbackClient.listMine();
setHistory(items);
} catch { /* silent */ }
finally { setHistoryLoading(false); }
}, []);
const handleScreenshot = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve) => {
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(file);
});
const result = await uploadAssetWithProgress({ dataUrl, name: file.name, scope: "bug-feedback" });
setScreenshotUrl(result.url);
} catch { toast.error("截图上传失败"); }
finally { setUploading(false); }
}, []);
const handleSubmit = useCallback(async () => {
if (!title.trim()) { toast.error("请输入标题"); return; }
if (!description.trim()) { toast.error("请输入问题描述"); return; }
setSubmitting(true);
try {
await bugFeedbackClient.submit({ title: title.trim(), description: description.trim(), screenshotUrl: screenshotUrl || undefined });
toast.success("反馈提交成功,审核通过后将奖励 1 积分");
setTitle("");
setDescription("");
setScreenshotUrl(null);
onClose();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "提交失败");
} finally { setSubmitting(false); }
}, [title, description, screenshotUrl, onClose]);
if (!open) return null;
return (
<div className="bug-feedback-overlay" ref={backdropRef} onClick={(e) => { if (e.target === backdropRef.current) onClose(); }}>
<div className="bug-feedback-modal" role="dialog" aria-modal="true" aria-label="Bug反馈">
<div className="bug-feedback-modal__header">
<div className="bug-feedback-modal__tabs">
<button type="button" className={tab === "submit" ? "is-active" : ""} onClick={() => setTab("submit")}></button>
<button type="button" className={tab === "history" ? "is-active" : ""} onClick={() => setTab("history")}></button>
</div>
<button type="button" className="bug-feedback-modal__close" onClick={onClose} aria-label="关闭"><CloseOutlined /></button>
</div>
{tab === "submit" && (
<div className="bug-feedback-modal__form">
<p className="bug-feedback-modal__tip"><BugOutlined /> Bug 1 </p>
<label className="bug-feedback-modal__label"></label>
<input className="bug-feedback-modal__input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="简述遇到的问题" maxLength={200} />
<label className="bug-feedback-modal__label"></label>
<textarea className="bug-feedback-modal__textarea" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="详细描述操作步骤、预期结果和实际结果" maxLength={5000} rows={5} />
<label className="bug-feedback-modal__label"></label>
<div className="bug-feedback-modal__screenshot">
{screenshotUrl ? (
<div className="bug-feedback-modal__preview">
<img src={screenshotUrl} alt="截图预览" />
<button type="button" onClick={() => setScreenshotUrl(null)}></button>
</div>
) : (
<button type="button" className="bug-feedback-modal__upload-btn" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
<CameraOutlined /> {uploading ? "上传中..." : "添加截图"}
</button>
)}
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={handleScreenshot} />
</div>
<button type="button" className="bug-feedback-modal__submit" disabled={submitting} onClick={handleSubmit}>
{submitting ? "提交中..." : "提交反馈"}
</button>
</div>
)}
{tab === "history" && (
<div className="bug-feedback-modal__history">
{historyLoading ? <p className="bug-feedback-modal__empty">...</p> : history.length === 0 ? <p className="bug-feedback-modal__empty"></p> : (
<ul className="bug-feedback-modal__list">
{history.map((item) => (
<li key={item.id} className="bug-feedback-modal__item">
<div className="bug-feedback-modal__item-head">
<span className="bug-feedback-modal__item-title">{item.title}</span>
<span className={`bug-feedback-modal__status bug-feedback-modal__status--${item.status}`}>
{item.status === "pending" ? "审核中" : item.status === "approved" ? "已通过 +1积分" : "未通过"}
</span>
</div>
<p className="bug-feedback-modal__item-desc">{item.description}</p>
{item.adminNote && <p className="bug-feedback-modal__item-note">{item.adminNote}</p>}
<span className="bug-feedback-modal__item-date">{new Date(item.createdAt).toLocaleDateString()}</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
</div>
);
}
-1
View File
@@ -1,5 +1,4 @@
import { useCallback, useRef, useState, type ReactNode } from "react"; import { useCallback, useRef, useState, type ReactNode } from "react";
import "../styles/components/dropzone.css";
interface DropZoneProps { interface DropZoneProps {
accept?: string; accept?: string;
-1
View File
@@ -1,5 +1,4 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import "../styles/components/empty-state.css";
interface EmptyStateProps { interface EmptyStateProps {
icon?: ReactNode; icon?: ReactNode;
-1
View File
@@ -1,6 +1,5 @@
import { HomeOutlined } from "@ant-design/icons"; import { HomeOutlined } from "@ant-design/icons";
import { useCallback } from "react"; import { useCallback } from "react";
import "../styles/pages/not-found.css";
interface NotFoundPageProps { interface NotFoundPageProps {
onGoHome: () => void; onGoHome: () => void;
+21 -12
View File
@@ -1,17 +1,26 @@
import {
BellOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
DeleteOutlined,
DislikeOutlined,
ExclamationCircleOutlined,
LikeOutlined,
LockOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { WebNotification, WebNotificationType, WebViewKey } from "../types"; import type { WebNotification, WebNotificationType, WebViewKey } from "../types";
import { AnimatedPanel } from "./AnimatedPanel"; import { AnimatedPanel } from "./AnimatedPanel";
import { ShellIcon } from "./ShellIcon";
const NOTIFICATION_ICONS: Record<WebNotificationType, React.ReactNode> = { const NOTIFICATION_ICONS: Record<WebNotificationType, React.ReactNode> = {
task_completed: <ShellIcon name="check-circle" style={{ color: "#10b981" }} />, task_completed: <CheckCircleOutlined style={{ color: "#10b981" }} />,
task_failed: <ShellIcon name="close-circle" style={{ color: "#ef4444" }} />, task_failed: <CloseCircleOutlined style={{ color: "#ef4444" }} />,
review_pending: <ShellIcon name="exclamation-circle" style={{ color: "#f59e0b" }} />, review_pending: <ExclamationCircleOutlined style={{ color: "#f59e0b" }} />,
review_passed: <ShellIcon name="like" style={{ color: "#10b981" }} />, review_passed: <LikeOutlined style={{ color: "#10b981" }} />,
review_rejected: <ShellIcon name="dislike" style={{ color: "#f59e0b" }} />, review_rejected: <DislikeOutlined style={{ color: "#f59e0b" }} />,
credits_low: <ShellIcon name="exclamation-circle" style={{ color: "#f59e0b" }} />, credits_low: <ExclamationCircleOutlined style={{ color: "#f59e0b" }} />,
session_expired: <ShellIcon name="lock" style={{ color: "#ef4444" }} />, session_expired: <LockOutlined style={{ color: "#ef4444" }} />,
info: <ShellIcon name="bell" style={{ color: "#2563eb" }} />, info: <BellOutlined style={{ color: "#2563eb" }} />,
}; };
function parseTimestamp(dateStr: string): number { function parseTimestamp(dateStr: string): number {
@@ -102,7 +111,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl
aria-label={`通知中心${unreadCount > 0 ? `${unreadCount}条未读` : ""}`} aria-label={`通知中心${unreadCount > 0 ? `${unreadCount}条未读` : ""}`}
onClick={() => { setOpen((v) => !v); setNow(Date.now()); }} onClick={() => { setOpen((v) => !v); setNow(Date.now()); }}
> >
<ShellIcon name="bell" /> <BellOutlined />
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className="notification-center__badge">{unreadCount > 99 ? "99+" : unreadCount}</span> <span className="notification-center__badge">{unreadCount > 99 ? "99+" : unreadCount}</span>
)} )}
@@ -118,7 +127,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl
)} )}
{notifications.length > 0 && onClear && ( {notifications.length > 0 && onClear && (
<button className="notification-center__clear" type="button" onClick={() => { onClear(); setOpen(false); }}> <button className="notification-center__clear" type="button" onClick={() => { onClear(); setOpen(false); }}>
<ShellIcon name="delete" /> <DeleteOutlined />
</button> </button>
)} )}
</div> </div>
@@ -126,7 +135,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl
<div className="notification-center__list"> <div className="notification-center__list">
{notifications.length === 0 ? ( {notifications.length === 0 ? (
<div className="notification-center__empty"> <div className="notification-center__empty">
<ShellIcon name="bell" style={{ fontSize: 28, opacity: 0.3 }} /> <BellOutlined style={{ fontSize: 28, opacity: 0.3 }} />
<span></span> <span></span>
</div> </div>
) : ( ) : (
@@ -1,6 +1,5 @@
import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons"; import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons";
import { useMemo, useState, type ReactNode } from "react"; import { useMemo, useState, type ReactNode } from "react";
import "../../styles/components/recharge-modal.css";
import { keyServerClient, type RechargeOrderResult } from "../../api/keyServerClient"; import { keyServerClient, type RechargeOrderResult } from "../../api/keyServerClient";
import { toast } from "../toast/toastStore"; import { toast } from "../toast/toastStore";
@@ -117,7 +116,7 @@ const paymentMethods: Array<{ id: PaymentMethod; label: string; hint: string }>
{ id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" }, { id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" },
]; ];
export interface RechargeModalProps { interface RechargeModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
currentBalance?: number; currentBalance?: number;
@@ -1,14 +0,0 @@
import type { ComponentType } from "react";
import type { RechargeModalProps } from "./RechargeModal";
export type RechargeModalComponent = ComponentType<RechargeModalProps>;
let rechargeModalPromise: Promise<RechargeModalComponent> | null = null;
export function loadRechargeModal(): Promise<RechargeModalComponent> {
if (!rechargeModalPromise) {
rechargeModalPromise = import("./RechargeModal").then((module) => module.RechargeModal);
}
return rechargeModalPromise;
}
-344
View File
@@ -1,344 +0,0 @@
import type { CSSProperties } from "react";
export type ShellIconName =
| "arrow-down"
| "arrow-left"
| "arrow-up"
| "bar-chart"
| "bell"
| "branches"
| "check-circle"
| "chevron-left"
| "chevron-right"
| "close-circle"
| "copy"
| "customer-service"
| "delete"
| "dislike"
| "download"
| "exclamation-circle"
| "flag"
| "file-text"
| "folder"
| "global"
| "heart"
| "home"
| "info-circle"
| "like"
| "line-chart"
| "lock"
| "login"
| "logout"
| "loading"
| "plus-circle"
| "reload"
| "robot"
| "shopping"
| "swap"
| "team"
| "thunderbolt"
| "tool"
| "upload"
| "user"
| "wallet"
| "warning";
interface ShellIconProps {
name: ShellIconName;
className?: string;
style?: CSSProperties;
}
function renderIcon(name: ShellIconName) {
switch (name) {
case "arrow-down":
return <path d="M12 5v14m0 0 6-6m-6 6-6-6" />;
case "arrow-left":
return <path d="M19 12H5m0 0 6-6m-6 6 6 6" />;
case "arrow-up":
return <path d="M12 19V5m0 0 6 6m-6-6-6 6" />;
case "bar-chart":
return (
<>
<path d="M4 19V5" />
<path d="M4 19h16" />
<path d="M8 16v-5" />
<path d="M12 16V8" />
<path d="M16 16v-9" />
</>
);
case "bell":
return (
<>
<path d="M18 9a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
<path d="M10 21h4" />
</>
);
case "branches":
return (
<>
<circle cx="6" cy="6" r="2" />
<circle cx="18" cy="6" r="2" />
<circle cx="12" cy="18" r="2" />
<path d="M8 7.5 12 12l4-4.5" />
<path d="M12 12v4" />
</>
);
case "check-circle":
return (
<>
<circle cx="12" cy="12" r="9" />
<path d="m8 12 2.5 2.5L16 9" />
</>
);
case "chevron-left":
return <path d="m15 18-6-6 6-6" />;
case "chevron-right":
return <path d="m9 18 6-6-6-6" />;
case "close-circle":
return (
<>
<circle cx="12" cy="12" r="9" />
<path d="m9 9 6 6m0-6-6 6" />
</>
);
case "copy":
return (
<>
<rect x="8" y="8" width="11" height="11" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" />
</>
);
case "customer-service":
return (
<>
<path d="M4 13a8 8 0 0 1 16 0" />
<path d="M5 13h3v5H5a2 2 0 0 1-2-2v-1a2 2 0 0 1 2-2Z" />
<path d="M16 13h3a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2h-3v-5Z" />
<path d="M18 18c0 2-2 3-6 3" />
</>
);
case "delete":
return (
<>
<path d="M4 7h16" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M6 7l1 14h10l1-14" />
<path d="M9 7V4h6v3" />
</>
);
case "download":
return (
<>
<path d="M12 4v11" />
<path d="m7 10 5 5 5-5" />
<path d="M5 20h14" />
</>
);
case "dislike":
return (
<>
<path d="M7 3v12" />
<path d="M7 15h9l-1 5a2 2 0 0 1-3 1l-3-6H5a2 2 0 0 1-2-2V6a3 3 0 0 1 3-3h1" />
<path d="M17 3h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-3" />
</>
);
case "exclamation-circle":
return (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v6" />
<path d="M12 17h.01" />
</>
);
case "flag":
return (
<>
<path d="M5 21V4" />
<path d="M5 5h11l-1.5 4L16 13H5" />
</>
);
case "file-text":
return (
<>
<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9Z" />
<path d="M14 3v6h6" />
<path d="M8 13h8" />
<path d="M8 17h6" />
</>
);
case "folder":
return <path d="M3 7h7l2 2h9v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />;
case "global":
return (
<>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18" />
<path d="M12 3c3 3 3 15 0 18" />
<path d="M12 3c-3 3-3 15 0 18" />
</>
);
case "heart":
return <path d="M20 8.5c0 5-8 10.5-8 10.5S4 13.5 4 8.5A4.5 4.5 0 0 1 12 6a4.5 4.5 0 0 1 8 2.5Z" />;
case "home":
return (
<>
<path d="M3 11 12 4l9 7" />
<path d="M5 10v10h14V10" />
<path d="M10 20v-6h4v6" />
</>
);
case "info-circle":
return (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v6" />
<path d="M12 7h.01" />
</>
);
case "like":
return (
<>
<path d="M7 21V9" />
<path d="M7 9h3l3-6a2 2 0 0 1 3 1l-1 5h4a2 2 0 0 1 2 2l-2 8a3 3 0 0 1-3 2H7" />
<path d="M3 10h4v10H3z" />
</>
);
case "line-chart":
return (
<>
<path d="M4 19V5" />
<path d="M4 19h16" />
<path d="m7 15 4-4 3 3 5-7" />
</>
);
case "lock":
return (
<>
<rect x="5" y="10" width="14" height="10" rx="2" />
<path d="M8 10V7a4 4 0 0 1 8 0v3" />
</>
);
case "login":
return (
<>
<path d="M14 4h5v16h-5" />
<path d="M4 12h10" />
<path d="m10 8 4 4-4 4" />
</>
);
case "logout":
return (
<>
<path d="M10 4H5v16h5" />
<path d="M20 12H10" />
<path d="m14 8-4 4 4 4" />
</>
);
case "loading":
return (
<>
<path d="M12 3a9 9 0 1 1-8 5" />
<path d="M4 3v5h5" />
</>
);
case "plus-circle":
return (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 8v8" />
<path d="M8 12h8" />
</>
);
case "reload":
return (
<>
<path d="M20 12a8 8 0 1 1-2.3-5.7" />
<path d="M20 4v6h-6" />
</>
);
case "robot":
return (
<>
<rect x="5" y="8" width="14" height="11" rx="3" />
<path d="M12 8V4" />
<path d="M8 13h.01" />
<path d="M16 13h.01" />
<path d="M9 17h6" />
</>
);
case "shopping":
return (
<>
<path d="M6 7h15l-2 8H8L6 7Z" />
<path d="M6 7 5 4H2" />
<circle cx="9" cy="20" r="1.5" />
<circle cx="18" cy="20" r="1.5" />
</>
);
case "swap":
return (
<>
<path d="M7 7h13m0 0-4-4m4 4-4 4" />
<path d="M17 17H4m0 0 4-4m-4 4 4 4" />
</>
);
case "team":
return (
<>
<circle cx="9" cy="8" r="3" />
<path d="M3 20a6 6 0 0 1 12 0" />
<path d="M16 11a3 3 0 1 0-1-5.8" />
<path d="M17 20a5 5 0 0 0-3-4.6" />
</>
);
case "thunderbolt":
return <path d="M13 2 4 14h7l-1 8 10-13h-7l1-7Z" />;
case "tool":
return <path d="M14.5 5.5a5 5 0 0 0 4 6.5L9 21l-6-6 9-9.5a5 5 0 0 0 2.5 0Z" />;
case "upload":
return (
<>
<path d="M12 20V9" />
<path d="m7 14 5-5 5 5" />
<path d="M5 4h14" />
</>
);
case "user":
return (
<>
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</>
);
case "wallet":
return (
<>
<path d="M4 7h15a2 2 0 0 1 2 2v10H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h12" />
<path d="M16 13h5" />
<path d="M17 16h.01" />
</>
);
case "warning":
return (
<>
<path d="M12 3 2 20h20L12 3Z" />
<path d="M12 9v5" />
<path d="M12 17h.01" />
</>
);
default:
return <circle cx="12" cy="12" r="8" />;
}
}
export function ShellIcon({ name, className, style }: ShellIconProps) {
return (
<span className={["anticon", "shell-icon", className].filter(Boolean).join(" ")} style={style} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{renderIcon(name)}
</svg>
</span>
);
}
-1
View File
@@ -1,5 +1,4 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import "../styles/components/skeleton.css";
interface SkeletonProps { interface SkeletonProps {
width?: string | number; width?: string | number;
-1
View File
@@ -1,5 +1,4 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import "../styles/pages/studio-layout.css";
interface StudioToolLayoutProps { interface StudioToolLayoutProps {
toolstrip?: ReactNode; toolstrip?: ReactNode;
-1
View File
@@ -14,7 +14,6 @@ import {
ThunderboltOutlined, ThunderboltOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import "../../styles/pages/agent.css";
import WorkspacePageShell from "../../components/WorkspacePageShell"; import WorkspacePageShell from "../../components/WorkspacePageShell";
import type { WebGenerationPreviewTask } from "../../types"; import type { WebGenerationPreviewTask } from "../../types";
-1
View File
@@ -11,7 +11,6 @@ import {
UserOutlined, UserOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react";
import "../../styles/pages/assets.css";
import { assetClient, type ServerAssetItem } from "../../api/assetClient"; import { assetClient, type ServerAssetItem } from "../../api/assetClient";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { useDebounce } from "../../hooks/useDebounce"; import { useDebounce } from "../../hooks/useDebounce";
@@ -1,40 +0,0 @@
interface CanvasMarkingPopoverProps {
value?: string;
placeholder: string;
onChange: (value: string) => void;
onClear: () => void;
onDone: () => void;
}
export function CanvasMarkingPopover({
value,
placeholder,
onChange,
onClear,
onDone,
}: CanvasMarkingPopoverProps) {
return (
<div
className="studio-canvas-marking-popover"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<textarea
className="studio-canvas-marking-input"
placeholder={placeholder}
value={value || ""}
onChange={(event) => onChange(event.target.value)}
/>
<div className="studio-canvas-marking-actions">
{value ? (
<button type="button" className="studio-canvas-marking-clear" onClick={onClear}>
</button>
) : null}
<button type="button" className="studio-canvas-marking-done" onClick={onDone}>
</button>
</div>
</div>
);
}
+325 -162
View File
@@ -28,13 +28,10 @@
import { import {
ReactFlow, ReactFlow,
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import "../../styles/pages/canvas.css";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { assetClient, type ServerAssetItem } from "../../api/assetClient"; import { assetClient, type ServerAssetItem } from "../../api/assetClient";
import { communityClient } from "../../api/communityClient"; import { communityClient } from "../../api/communityClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway"; import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import WorkspacePageShell from "../../components/WorkspacePageShell"; import WorkspacePageShell from "../../components/WorkspacePageShell";
import type { import type {
@@ -55,7 +52,6 @@ import { useCanvasHistory, type CanvasHistorySnapshot } from "./useCanvasHistory
import { useCanvasKeyboard } from "./useCanvasKeyboard"; import { useCanvasKeyboard } from "./useCanvasKeyboard";
import { useCanvasNodeDrag } from "./useCanvasNodeDrag"; import { useCanvasNodeDrag } from "./useCanvasNodeDrag";
import { useCanvasGeneration, addCanvasGenKeepalive, removeCanvasGenKeepalive } from "./useCanvasGeneration"; import { useCanvasGeneration, addCanvasGenKeepalive, removeCanvasGenKeepalive } from "./useCanvasGeneration";
import { useCanvasAssetSummary, useCanvasVisibleNodes } from "./useCanvasDerivedState";
import { import {
toHappyHorseDisplayModel, toHappyHorseDisplayModel,
} from "../../utils/happyHorseRouting"; } from "../../utils/happyHorseRouting";
@@ -122,7 +118,7 @@ import {
defaultVideoModel, defaultVideoModel,
image4kCapableModels, image4kCapableModels,
imageFocusRatioOptions, imageFocusRatioOptions,
imageModelOptions as fallbackCanvasImageModelOptions, imageModelOptions,
imageRatioOptions, imageRatioOptions,
textModelOptions, textModelOptions,
videoDurationOptions, videoDurationOptions,
@@ -186,8 +182,6 @@ import {
} from "./canvasWorkflowDeserialize"; } from "./canvasWorkflowDeserialize";
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents"; import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
import type { CanvasNodeToolbarAction } from "./canvasComponents"; import type { CanvasNodeToolbarAction } from "./canvasComponents";
import { CanvasMarkingPopover } from "./CanvasMarkingPopover";
import { CanvasPromptMentionTextarea, CanvasTextPromptComposer } from "./CanvasTextPromptComposer";
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels"; import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing"; import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
@@ -199,6 +193,7 @@ const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL
// --- Canvas generation keep-alive (survives page refresh / view switch) --- // --- Canvas generation keep-alive (survives page refresh / view switch) ---
const MENTION_TOKEN_RE = /@(?:图片|视频|文本)\d+/g; const MENTION_TOKEN_RE = /@(?:图片|视频|文本)\d+/g;
const MENTION_BOUNDARY_RE = /\s|[,。、;:!??(){}[\]<>]/;
function buildNodeMentionOptions( function buildNodeMentionOptions(
kind: CanvasNodeKind, kind: CanvasNodeKind,
@@ -359,8 +354,6 @@ function CanvasPage({
const [projectNameEditing, setProjectNameEditing] = useState(false); const [projectNameEditing, setProjectNameEditing] = useState(false);
const [videoNodeMenu, setVideoNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null); const [videoNodeMenu, setVideoNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
const [videoNodes, setVideoNodes] = useState<CanvasVideoNode[]>([]); const [videoNodes, setVideoNodes] = useState<CanvasVideoNode[]>([]);
const [canvasImageModelOptions, setCanvasImageModelOptions] = useState<CanvasOption[]>(fallbackCanvasImageModelOptions);
const [canvasVideoModelOptions, setCanvasVideoModelOptions] = useState<CanvasOption[]>(canvasEnterpriseVideoModelOptions);
const [selectedNode, setSelectedNode] = useState<CanvasSelectedNode | null>(null); const [selectedNode, setSelectedNode] = useState<CanvasSelectedNode | null>(null);
const [selectedNodes, setSelectedNodes] = useState<CanvasSelectedNode[]>([]); const [selectedNodes, setSelectedNodes] = useState<CanvasSelectedNode[]>([]);
const [selectionContextMenu, setSelectionContextMenu] = useState<CanvasFloatingMenuPosition | null>(null); const [selectionContextMenu, setSelectionContextMenu] = useState<CanvasFloatingMenuPosition | null>(null);
@@ -401,12 +394,10 @@ function CanvasPage({
const suppressNextPaneClickRef = useRef(false); const suppressNextPaneClickRef = useRef(false);
const canvasAutoSaveTimerRef = useRef<number | null>(null); const canvasAutoSaveTimerRef = useRef<number | null>(null);
const canvasAutoSaveIdleHandleRef = useRef<number | null>(null); const canvasAutoSaveIdleHandleRef = useRef<number | null>(null);
const canvasAutoSaveRetryTimerRef = useRef<number | null>(null);
const canvasAutoSaveInFlightRef = useRef(false); const canvasAutoSaveInFlightRef = useRef(false);
const canvasAutoSavePendingRef = useRef(false); const canvasAutoSavePendingRef = useRef(false);
const lastAutoSavedWorkflowFingerprintRef = useRef(""); const lastAutoSavedWorkflowFingerprintRef = useRef("");
const canvasAutoSaveHydrationRef = useRef(true); const canvasAutoSaveHydrationRef = useRef(true);
const textNodeMentionFocusTimerRef = useRef<number | null>(null);
const textNodeIdRef = useRef(9); const textNodeIdRef = useRef(9);
const imageNodeIdRef = useRef(1); const imageNodeIdRef = useRef(1);
const videoNodeIdRef = useRef(1); const videoNodeIdRef = useRef(1);
@@ -467,39 +458,9 @@ function CanvasPage({
callbacksRef: dragCallbacksRef, callbacksRef: dragCallbacksRef,
suppressNextPaneClickRef, suppressNextPaneClickRef,
}); });
useEffect(() => {
let cancelled = false;
if (!isAuthenticated) {
setCanvasImageModelOptions(fallbackCanvasImageModelOptions);
setCanvasVideoModelOptions(canvasEnterpriseVideoModelOptions);
return () => {
cancelled = true;
};
}
modelCapabilitiesClient
.get()
.then((capabilities) => {
if (cancelled) return;
setCanvasImageModelOptions(capabilities.imageModels.length ? capabilities.imageModels : fallbackCanvasImageModelOptions);
setCanvasVideoModelOptions(capabilities.videoModels.length ? capabilities.videoModels : canvasEnterpriseVideoModelOptions);
})
.catch(() => {
if (cancelled) return;
setCanvasImageModelOptions(fallbackCanvasImageModelOptions);
setCanvasVideoModelOptions(canvasEnterpriseVideoModelOptions);
});
return () => {
cancelled = true;
};
}, [isAuthenticated]);
const visibleImageModelOptions = useMemo( const visibleImageModelOptions = useMemo(
() => filterImageModelOptionsForSession(canvasImageModelOptions, session), () => filterImageModelOptionsForSession(imageModelOptions, session),
[canvasImageModelOptions, session], [session],
); );
const fallbackVisibleImageModel = visibleImageModelOptions[0]?.value || defaultImageModel; const fallbackVisibleImageModel = visibleImageModelOptions[0]?.value || defaultImageModel;
const resolveVisibleImageModel = useCallback( const resolveVisibleImageModel = useCallback(
@@ -525,11 +486,7 @@ function CanvasPage({
else if (kind === "video") updateVideoNodePrompt(nodeId, nextValue); else if (kind === "video") updateVideoNodePrompt(nodeId, nextValue);
else updateTextNodePrompt(nodeId, nextValue); else updateTextNodePrompt(nodeId, nextValue);
closeTextNodeMention(nodeId); closeTextNodeMention(nodeId);
if (textNodeMentionFocusTimerRef.current !== null) { setTimeout(() => {
window.clearTimeout(textNodeMentionFocusTimerRef.current);
}
textNodeMentionFocusTimerRef.current = window.setTimeout(() => {
textNodeMentionFocusTimerRef.current = null;
if (textarea) { if (textarea) {
textarea.focus(); textarea.focus();
textarea.setSelectionRange(nextCaret, nextCaret); textarea.setSelectionRange(nextCaret, nextCaret);
@@ -565,22 +522,10 @@ function CanvasPage({
const [autoSaveStatus, setAutoSaveStatus] = useState<"saved" | "saving" | "error" | "idle">("idle"); const [autoSaveStatus, setAutoSaveStatus] = useState<"saved" | "saving" | "error" | "idle">("idle");
const autoSaveStatusTimerRef = useRef<number | null>(null); const autoSaveStatusTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current);
if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
if (autoSaveStatusTimerRef.current !== null) window.clearTimeout(autoSaveStatusTimerRef.current);
if (textNodeMentionFocusTimerRef.current !== null) window.clearTimeout(textNodeMentionFocusTimerRef.current);
if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
}
};
}, []);
// Save immediately when user leaves page or switches tab (placed after runCanvasAutoSave definition) // Save immediately when user leaves page or switches tab (placed after runCanvasAutoSave definition)
// — see useEffect below near runCanvasAutoSave // — see useEffect below near runCanvasAutoSave
const { canvasAssets, assetCountsByCategory } = useCanvasAssetSummary(serverAssets); const canvasAssets = serverAssets.filter((asset) => asset.imageUrl);
const shouldShowEmptyProjectState = const shouldShowEmptyProjectState =
projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0; projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0;
const isWaitingForProjects = isAuthenticated && !projectsLoaded; const isWaitingForProjects = isAuthenticated && !projectsLoaded;
@@ -2642,17 +2587,13 @@ function CanvasPage({
setConnectorDrag(null); setConnectorDrag(null);
}; };
const { const collapsedPackageNodeKeys = new Set(
isNodeCollapsedInPackage, nodePackages.flatMap((nodePackage) =>
visibleTextNodes, nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : []
visibleImageNodes, )
visibleVideoNodes, );
} = useCanvasVisibleNodes({ const isNodeCollapsedInPackage = (kind: CanvasNodeKind, id: string) =>
textNodes, collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind, id }));
imageNodes,
videoNodes,
nodePackages,
});
const isLinkCollapsedInPackage = (link: { sourceKind: CanvasNodeKind; sourceNodeId: string; targetKind: CanvasNodeKind; targetNodeId: string }) => const isLinkCollapsedInPackage = (link: { sourceKind: CanvasNodeKind; sourceNodeId: string; targetKind: CanvasNodeKind; targetNodeId: string }) =>
isNodeCollapsedInPackage(link.sourceKind, link.sourceNodeId) || isNodeCollapsedInPackage(link.sourceKind, link.sourceNodeId) ||
isNodeCollapsedInPackage(link.targetKind, link.targetNodeId); isNodeCollapsedInPackage(link.targetKind, link.targetNodeId);
@@ -2933,6 +2874,10 @@ function CanvasPage({
return; return;
} }
closeNodeContextMenus();
setContextMenu(null);
setSelectionContextMenu(null);
const point = getCanvasPointFromClient(event.clientX, event.clientY); const point = getCanvasPointFromClient(event.clientX, event.clientY);
const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92; const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92;
setCanvasViewport((viewport) => { setCanvasViewport((viewport) => {
@@ -3185,13 +3130,7 @@ function CanvasPage({
canvasAutoSaveInFlightRef.current = false; canvasAutoSaveInFlightRef.current = false;
if (canvasAutoSavePendingRef.current) { if (canvasAutoSavePendingRef.current) {
canvasAutoSavePendingRef.current = false; canvasAutoSavePendingRef.current = false;
if (canvasAutoSaveRetryTimerRef.current !== null) { window.setTimeout(() => void runCanvasAutoSave(), canvasAutoSaveIdleTimeoutMs);
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
}
canvasAutoSaveRetryTimerRef.current = window.setTimeout(() => {
canvasAutoSaveRetryTimerRef.current = null;
void runCanvasAutoSave();
}, canvasAutoSaveIdleTimeoutMs);
} }
} }
}, [ }, [
@@ -3260,13 +3199,7 @@ function CanvasPage({
); );
return; return;
} }
if (canvasAutoSaveRetryTimerRef.current !== null) { window.setTimeout(() => void runCanvasAutoSave(), canvasAutoSaveIdleTimeoutMs);
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
}
canvasAutoSaveRetryTimerRef.current = window.setTimeout(() => {
canvasAutoSaveRetryTimerRef.current = null;
void runCanvasAutoSave();
}, canvasAutoSaveIdleTimeoutMs);
}, canvasAutoSaveDebounceMs); }, canvasAutoSaveDebounceMs);
return () => { return () => {
@@ -3278,10 +3211,6 @@ function CanvasPage({
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current); window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
canvasAutoSaveIdleHandleRef.current = null; canvasAutoSaveIdleHandleRef.current = null;
} }
if (canvasAutoSaveRetryTimerRef.current !== null) {
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
canvasAutoSaveRetryTimerRef.current = null;
}
}; };
}, [ }, [
isAuthenticated, isAuthenticated,
@@ -3529,14 +3458,9 @@ function CanvasPage({
return; return;
} }
const toDelete = selectedNode ? [selectedNode] : selectedNodes; const toDelete = selectedNode ? [selectedNode] : selectedNodes;
const textIds = new Set<string>(); const textIds = new Set(toDelete.filter((n) => n.kind === "text").map((n) => n.id));
const imageIds = new Set<string>(); const imageIds = new Set(toDelete.filter((n) => n.kind === "image").map((n) => n.id));
const videoIds = new Set<string>(); const videoIds = new Set(toDelete.filter((n) => n.kind === "video").map((n) => n.id));
for (const node of toDelete) {
if (node.kind === "text") textIds.add(node.id);
else if (node.kind === "image") imageIds.add(node.id);
else if (node.kind === "video") videoIds.add(node.id);
}
if (textIds.size) setTextNodes((nodes) => nodes.filter((n) => !textIds.has(n.id))); if (textIds.size) setTextNodes((nodes) => nodes.filter((n) => !textIds.has(n.id)));
if (imageIds.size) setImageNodes((nodes) => nodes.filter((n) => !imageIds.has(n.id))); if (imageIds.size) setImageNodes((nodes) => nodes.filter((n) => !imageIds.has(n.id)));
if (videoIds.size) setVideoNodes((nodes) => nodes.filter((n) => !videoIds.has(n.id))); if (videoIds.size) setVideoNodes((nodes) => nodes.filter((n) => !videoIds.has(n.id)));
@@ -4013,7 +3937,7 @@ function CanvasPage({
) : null} ) : null}
</svg> </svg>
) : null} ) : null}
{visibleTextNodes.map((textNode) => { {textNodes.filter((textNode) => !isNodeCollapsedInPackage("text", textNode.id)).map((textNode) => {
const textNodeSelected = isSelectedNode("text", textNode.id); const textNodeSelected = isSelectedNode("text", textNode.id);
const textNodeActive = isActiveSelectedNode("text", textNode.id); const textNodeActive = isActiveSelectedNode("text", textNode.id);
const textNodeResizing = nodeResizeDrag?.kind === "text" && nodeResizeDrag.nodeId === textNode.id; const textNodeResizing = nodeResizeDrag?.kind === "text" && nodeResizeDrag.nodeId === textNode.id;
@@ -4162,26 +4086,126 @@ function CanvasPage({
onMouseDown={(event) => handleNodeResizeStart(event, "text", textNode.id, textNode.size)} onMouseDown={(event) => handleNodeResizeStart(event, "text", textNode.id, textNode.size)}
/> />
</div> </div>
{textNodeActive && !isCanvasNodeMoving ? ( {textNodeActive && !isCanvasNodeMoving ? (() => {
<CanvasTextPromptComposer const mentionOptions = buildNodeMentionOptions("text", textNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
nodeId={textNode.id} const mentionState = textNodeMentionStates[textNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
prompt={textNode.prompt} const filteredMentions = mentionState.open
canGenerate={textNodeCanGenerate} ? mentionOptions.filter((o) => !mentionState.query || o.searchText.includes(mentionState.query.toLowerCase()))
isGenerating={textNodeGenerating} : [];
mentionOptions={buildNodeMentionOptions("text", textNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
mentionState={textNodeMentionStates[textNode.id]} const handlePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onPromptChange={updateTextNodePrompt} const value = e.target.value;
onMentionStateChange={setTextNodeMentionStates} const caret = e.target.selectionStart || 0;
onCloseMention={closeTextNodeMention} updateTextNodePrompt(textNode.id, value);
onInsertMention={insertTextNodeMention}
onGenerate={handleGenerateTextNode} // Detect @-mention trigger
const beforeCaret = value.slice(0, caret);
const atIdx = beforeCaret.lastIndexOf("@");
if (atIdx >= 0) {
const query = beforeCaret.slice(atIdx + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
return;
}
}
closeTextNodeMention(textNode.id);
};
const handlePromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!mentionState.open || filteredMentions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { ...mentionState, activeIndex: (mentionState.activeIndex + 1) % filteredMentions.length } }));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { ...mentionState, activeIndex: (mentionState.activeIndex - 1 + filteredMentions.length) % filteredMentions.length } }));
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const opt = filteredMentions[mentionState.activeIndex];
if (opt) {
const ta = e.currentTarget;
insertTextNodeMention(textNode.id, opt, ta);
}
} else if (e.key === "Escape") {
e.preventDefault();
closeTextNodeMention(textNode.id);
}
};
const handlePromptSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const ta = e.currentTarget;
const caret = ta.selectionStart || 0;
setTextNodeMentionStates((prev) => {
const cur = prev[textNode.id];
if (!cur?.open) return prev;
return { ...prev, [textNode.id]: { ...cur, caret } };
});
};
return (
<div className="studio-canvas-text-composer">
<div className="studio-canvas-text-composer__input-wrap">
<textarea
value={textNode.prompt}
onMouseDown={(event) => event.stopPropagation()}
onChange={handlePromptChange}
onKeyDown={handlePromptKeyDown}
onSelect={handlePromptSelect}
placeholder="写下你想讲的故事、场景或角色设定。@引用连接的节点"
/> />
{mentionState.open ? (
<div className="studio-canvas-mention-panel">
{filteredMentions.length > 0 ? filteredMentions.map((opt, idx) => (
<button
key={opt.token}
type="button"
className={`studio-canvas-mention-item${idx === mentionState.activeIndex ? " is-active" : ""}`}
onMouseDown={(e) => { e.preventDefault(); const ta = e.currentTarget.closest(".studio-canvas-text-composer")?.querySelector("textarea") || null; insertTextNodeMention(textNode.id, opt, ta as HTMLTextAreaElement | null); }}
>
<span className="studio-canvas-mention-thumb">
{opt.kind === "image" && opt.previewUrl ? <img src={opt.previewUrl} alt="" /> : opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}
</span>
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
<span className="studio-canvas-mention-token">{opt.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}>
<span className="studio-canvas-mention-label"></span>
</div>
)}
</div>
) : null} ) : null}
</div> </div>
<div className="studio-canvas-text-composer__footer">
<button
type="button"
className={`studio-canvas-text-composer__send studio-canvas-generate-button${textNodeCanGenerate && !textNodeGenerating ? " is-ready" : ""}`}
title={textNodeGenerating ? "生成中" : "生成"}
disabled={textNodeGenerating || !textNodeCanGenerate}
aria-busy={textNodeGenerating}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (!textNodeGenerating && textNodeCanGenerate) {
void handleGenerateTextNode(textNode.id);
}
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<SendOutlined />
</button>
</div>
</div>
);
})() : null}
</div>
</div> </div>
); );
})} })}
{visibleImageNodes.map((imageNode) => { {imageNodes.filter((imageNode) => !isNodeCollapsedInPackage("image", imageNode.id)).map((imageNode) => {
const imageNodeSelected = isSelectedNode("image", imageNode.id); const imageNodeSelected = isSelectedNode("image", imageNode.id);
const imageNodeActive = isActiveSelectedNode("image", imageNode.id); const imageNodeActive = isActiveSelectedNode("image", imageNode.id);
const imageNodeResizing = nodeResizeDrag?.kind === "image" && nodeResizeDrag.nodeId === imageNode.id; const imageNodeResizing = nodeResizeDrag?.kind === "image" && nodeResizeDrag.nodeId === imageNode.id;
@@ -4439,7 +4463,38 @@ function CanvasPage({
</button> </button>
</div> </div>
) : null} ) : null}
{imageNodeActive && !isCanvasNodeMoving && !imageNodeFocusActive ? ( {imageNodeActive && !isCanvasNodeMoving && !imageNodeFocusActive ? (() => {
const imgMentionOptions = buildNodeMentionOptions("image", imageNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
const imgMentionState = textNodeMentionStates[imageNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
const imgFilteredMentions = imgMentionState.open
? imgMentionOptions.filter((o) => !imgMentionState.query || o.searchText.includes(imgMentionState.query.toLowerCase()))
: [];
const handleImagePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const caret = e.target.selectionStart || 0;
updateImageNodePrompt(imageNode.id, value);
const beforeCaret = value.slice(0, caret);
const atIdx = beforeCaret.lastIndexOf("@");
if (atIdx >= 0) {
const query = beforeCaret.slice(atIdx + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
return;
}
}
closeTextNodeMention(imageNode.id);
};
const handleImagePromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!imgMentionState.open || imgFilteredMentions.length === 0) return;
if (e.key === "ArrowDown") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { ...imgMentionState, activeIndex: (imgMentionState.activeIndex + 1) % imgFilteredMentions.length } })); }
else if (e.key === "ArrowUp") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { ...imgMentionState, activeIndex: (imgMentionState.activeIndex - 1 + imgFilteredMentions.length) % imgFilteredMentions.length } })); }
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const opt = imgFilteredMentions[imgMentionState.activeIndex]; if (opt) insertTextNodeMention(imageNode.id, opt, e.currentTarget, "image"); }
else if (e.key === "Escape") { e.preventDefault(); closeTextNodeMention(imageNode.id); }
};
return (
<div className="studio-canvas-image-composer"> <div className="studio-canvas-image-composer">
<div className="studio-canvas-image-composer__tools"> <div className="studio-canvas-image-composer__tools">
<button <button
@@ -4478,23 +4533,47 @@ function CanvasPage({
> >
<FileImageOutlined /><span></span> <FileImageOutlined /><span></span>
</button> </button>
{markingPopoverNodeId === imageNode.id ? ( {markingPopoverNodeId === imageNode.id && (
<CanvasMarkingPopover <div
value={imageNode.marking} className="studio-canvas-marking-popover"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<textarea
className="studio-canvas-marking-input"
placeholder="描述标记内容,如:主角站在桥上,远处是城市天际线" placeholder="描述标记内容,如:主角站在桥上,远处是城市天际线"
onChange={(value) => { value={imageNode.marking || ""}
onChange={(e) => {
const val = e.target.value;
setImageNodes((nodes) => setImageNodes((nodes) =>
nodes.map((node) => (node.id === imageNode.id ? { ...node, marking: value } : node)), nodes.map((n) => (n.id === imageNode.id ? { ...n, marking: val } : n)),
); );
}} }}
onClear={() => {
setImageNodes((nodes) =>
nodes.map((node) => (node.id === imageNode.id ? { ...node, marking: "" } : node)),
);
}}
onDone={() => setMarkingPopoverNodeId(null)}
/> />
) : null} <div className="studio-canvas-marking-actions">
{imageNode.marking && (
<button
type="button"
className="studio-canvas-marking-clear"
onClick={() => {
setImageNodes((nodes) =>
nodes.map((n) => (n.id === imageNode.id ? { ...n, marking: "" } : n)),
);
}}
>
</button>
)}
<button
type="button"
className="studio-canvas-marking-done"
onClick={() => setMarkingPopoverNodeId(null)}
>
</button>
</div>
</div>
)}
<button <button
type="button" type="button"
title="多宫格生成" title="多宫格生成"
@@ -4536,18 +4615,28 @@ function CanvasPage({
</button> </button>
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开"></button> <button type="button" className="studio-canvas-image-composer__expand" aria-label="展开"></button>
</div> </div>
<CanvasPromptMentionTextarea <div className="studio-canvas-text-composer__input-wrap">
nodeId={imageNode.id} <textarea
value={imageNode.prompt} value={imageNode.prompt}
onMouseDown={(event) => event.stopPropagation()}
onChange={handleImagePromptChange}
onKeyDown={handleImagePromptKeyDown}
placeholder="描述你想要生成的画面内容,按/呼出指令,@引用素材" placeholder="描述你想要生成的画面内容,按/呼出指令,@引用素材"
mentionOptions={buildNodeMentionOptions("image", imageNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
mentionState={textNodeMentionStates[imageNode.id]}
mentionKind="image"
onPromptChange={updateImageNodePrompt}
onMentionStateChange={setTextNodeMentionStates}
onCloseMention={closeTextNodeMention}
onInsertMention={insertTextNodeMention}
/> />
{imgMentionState.open && (
<div className="studio-canvas-mention-panel">
{imgFilteredMentions.length > 0 ? imgFilteredMentions.map((opt, idx) => (
<button key={opt.token} type="button" className={`studio-canvas-mention-item${idx === imgMentionState.activeIndex ? " is-active" : ""}`} onMouseDown={(e) => { e.preventDefault(); insertTextNodeMention(imageNode.id, opt, e.currentTarget.closest(".studio-canvas-image-composer")?.querySelector("textarea") || null, "image"); }}>
<span className="studio-canvas-mention-thumb">{opt.kind === "image" && opt.previewUrl ? <img src={opt.previewUrl} alt="" /> : opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}</span>
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
<span className="studio-canvas-mention-token">{opt.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}><span className="studio-canvas-mention-label"></span></div>
)}
</div>
)}
</div>
<div className="studio-canvas-image-composer__footer"> <div className="studio-canvas-image-composer__footer">
<CanvasSelectChip <CanvasSelectChip
ariaLabel="选择生图模型" ariaLabel="选择生图模型"
@@ -4615,12 +4704,12 @@ function CanvasPage({
</button> </button>
</div> </div>
</div> </div>
) : null} ); })() : null}
</div> </div>
</div> </div>
); );
})} })}
{visibleVideoNodes.map((videoNode) => { {videoNodes.filter((videoNode) => !isNodeCollapsedInPackage("video", videoNode.id)).map((videoNode) => {
const videoNodeSelected = isSelectedNode("video", videoNode.id); const videoNodeSelected = isSelectedNode("video", videoNode.id);
const videoNodeActive = isActiveSelectedNode("video", videoNode.id); const videoNodeActive = isActiveSelectedNode("video", videoNode.id);
const videoNodeResizing = nodeResizeDrag?.kind === "video" && nodeResizeDrag.nodeId === videoNode.id; const videoNodeResizing = nodeResizeDrag?.kind === "video" && nodeResizeDrag.nodeId === videoNode.id;
@@ -4770,7 +4859,38 @@ function CanvasPage({
onMouseDown={(event) => handleNodeResizeStart(event, "video", videoNode.id, videoNode.size)} onMouseDown={(event) => handleNodeResizeStart(event, "video", videoNode.id, videoNode.size)}
/> />
</div> </div>
{videoNodeActive && !isCanvasNodeMoving ? ( {videoNodeActive && !isCanvasNodeMoving ? (() => {
const vidMentionOptions = buildNodeMentionOptions("video", videoNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
const vidMentionState = textNodeMentionStates[videoNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
const vidFilteredMentions = vidMentionState.open
? vidMentionOptions.filter((o) => !vidMentionState.query || o.searchText.includes(vidMentionState.query.toLowerCase()))
: [];
const handleVideoPromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const caret = e.target.selectionStart || 0;
updateVideoNodePrompt(videoNode.id, value);
const beforeCaret = value.slice(0, caret);
const atIdx = beforeCaret.lastIndexOf("@");
if (atIdx >= 0) {
const query = beforeCaret.slice(atIdx + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
return;
}
}
closeTextNodeMention(videoNode.id);
};
const handleVideoPromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!vidMentionState.open || vidFilteredMentions.length === 0) return;
if (e.key === "ArrowDown") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { ...vidMentionState, activeIndex: (vidMentionState.activeIndex + 1) % vidFilteredMentions.length } })); }
else if (e.key === "ArrowUp") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { ...vidMentionState, activeIndex: (vidMentionState.activeIndex - 1 + vidFilteredMentions.length) % vidFilteredMentions.length } })); }
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const opt = vidFilteredMentions[vidMentionState.activeIndex]; if (opt) insertTextNodeMention(videoNode.id, opt, e.currentTarget, "video"); }
else if (e.key === "Escape") { e.preventDefault(); closeTextNodeMention(videoNode.id); }
};
return (
<div className="studio-canvas-video-composer"> <div className="studio-canvas-video-composer">
<div className="studio-canvas-video-composer__tabs studio-canvas-video-composer__mode-tabs"> <div className="studio-canvas-video-composer__tabs studio-canvas-video-composer__mode-tabs">
<button <button
@@ -4825,23 +4945,47 @@ function CanvasPage({
> >
{videoNode.cameraMotion ? ` ${CAMERA_MOTION_PRESETS.find((p) => p.value === videoNode.cameraMotion)?.label || ""}` : ""} {videoNode.cameraMotion ? ` ${CAMERA_MOTION_PRESETS.find((p) => p.value === videoNode.cameraMotion)?.label || ""}` : ""}
</button> </button>
{markingPopoverNodeId === videoNode.id ? ( {markingPopoverNodeId === videoNode.id && (
<CanvasMarkingPopover <div
value={videoNode.marking} className="studio-canvas-marking-popover"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<textarea
className="studio-canvas-marking-input"
placeholder="描述标记内容,如:主角在城市街头行走" placeholder="描述标记内容,如:主角在城市街头行走"
onChange={(value) => { value={videoNode.marking || ""}
onChange={(e) => {
const val = e.target.value;
setVideoNodes((nodes) => setVideoNodes((nodes) =>
nodes.map((node) => (node.id === videoNode.id ? { ...node, marking: value } : node)), nodes.map((n) => (n.id === videoNode.id ? { ...n, marking: val } : n)),
); );
}} }}
onClear={() => {
setVideoNodes((nodes) =>
nodes.map((node) => (node.id === videoNode.id ? { ...node, marking: "" } : node)),
);
}}
onDone={() => setMarkingPopoverNodeId(null)}
/> />
) : null} <div className="studio-canvas-marking-actions">
{videoNode.marking && (
<button
type="button"
className="studio-canvas-marking-clear"
onClick={() => {
setVideoNodes((nodes) =>
nodes.map((n) => (n.id === videoNode.id ? { ...n, marking: "" } : n)),
);
}}
>
</button>
)}
<button
type="button"
className="studio-canvas-marking-done"
onClick={() => setMarkingPopoverNodeId(null)}
>
</button>
</div>
</div>
)}
{cameraMotionDropdownNodeId === videoNode.id && ( {cameraMotionDropdownNodeId === videoNode.id && (
<div <div
className="studio-canvas-camera-dropdown" className="studio-canvas-camera-dropdown"
@@ -4868,24 +5012,43 @@ function CanvasPage({
<button type="button"></button> <button type="button"></button>
<button type="button" className="is-active"></button> <button type="button" className="is-active"></button>
</div> </div>
<CanvasPromptMentionTextarea <div className="studio-canvas-text-composer__input-wrap">
nodeId={videoNode.id} <textarea
value={videoNode.prompt} value={videoNode.prompt}
onMouseDown={(event) => event.stopPropagation()}
onChange={handleVideoPromptChange}
onKeyDown={handleVideoPromptKeyDown}
placeholder="根据文字描述生成视频。" placeholder="根据文字描述生成视频。"
mentionOptions={buildNodeMentionOptions("video", videoNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
mentionState={textNodeMentionStates[videoNode.id]}
mentionKind="video"
onPromptChange={updateVideoNodePrompt}
onMentionStateChange={setTextNodeMentionStates}
onCloseMention={closeTextNodeMention}
onInsertMention={insertTextNodeMention}
/> />
{vidMentionState.open ? (
<div className="studio-canvas-mention-panel">
{vidFilteredMentions.length > 0 ? vidFilteredMentions.map((opt, idx) => (
<button
key={opt.token}
type="button"
className={`studio-canvas-mention-item${idx === vidMentionState.activeIndex ? " is-active" : ""}`}
onMouseDown={(ev) => { ev.preventDefault(); insertTextNodeMention(videoNode.id, opt, ev.currentTarget.closest(".studio-canvas-text-composer__input-wrap")?.querySelector("textarea")!, "video"); }}
>
<span className={`studio-canvas-mention-icon studio-canvas-mention-icon--${opt.kind}`}>
{opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}
</span>
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
<span className="studio-canvas-mention-token">{opt.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}>
<span className="studio-canvas-mention-label"></span>
</div>
)}
</div>
) : null}
</div>
<div className="studio-canvas-video-composer__footer studio-canvas-video-composer__settings"> <div className="studio-canvas-video-composer__footer studio-canvas-video-composer__settings">
<CanvasSelectChip <CanvasSelectChip
ariaLabel="选择视频模型" ariaLabel="选择视频模型"
className="canvas-select-chip--model studio-canvas-composer-chip" className="canvas-select-chip--model studio-canvas-composer-chip"
value={toHappyHorseDisplayModel(videoNode.model || defaultVideoModel)} value={toHappyHorseDisplayModel(videoNode.model || defaultVideoModel)}
options={canvasVideoModelOptions} options={canvasEnterpriseVideoModelOptions}
open={canvasSelectMenu === `${videoNode.id}:video-model`} open={canvasSelectMenu === `${videoNode.id}:video-model`}
onToggle={() => onToggle={() =>
setCanvasSelectMenu((current) => setCanvasSelectMenu((current) =>
@@ -4963,7 +5126,7 @@ function CanvasPage({
</button> </button>
</div> </div>
</div> </div>
) : null} ); })() : null}
</div> </div>
</div> </div>
); );
@@ -5257,7 +5420,7 @@ function CanvasPage({
onClick={() => setSelectedExistingCategory(category.key)} onClick={() => setSelectedExistingCategory(category.key)}
> >
{category.label} {category.label}
<span>{assetCountsByCategory.get(category.key) ?? 0} </span> <span>{serverAssets.filter((asset) => asset.type === category.key).length} </span>
</button> </button>
))} ))}
</div> </div>
@@ -1,219 +0,0 @@
import { SendOutlined } from "@ant-design/icons";
import { useRef, type CSSProperties, type Dispatch, type SetStateAction } from "react";
import type { CanvasNodeKind, CanvasPromptMentionOption, CanvasPromptMentionState } from "./canvasTypes";
const MENTION_BOUNDARY_RE = /\s|[,。、;:!??(){}[\]<>]/;
const EMPTY_MENTION_STYLE: CSSProperties = { opacity: 0.5, pointerEvents: "none" };
const DEFAULT_MENTION_STATE: CanvasPromptMentionState = {
open: false,
query: "",
start: 0,
caret: 0,
activeIndex: 0,
};
interface CanvasPromptMentionTextareaProps {
nodeId: string;
value: string;
placeholder: string;
mentionOptions: CanvasPromptMentionOption[];
mentionState?: CanvasPromptMentionState;
onPromptChange: (nodeId: string, prompt: string) => void;
onMentionStateChange: Dispatch<SetStateAction<Record<string, CanvasPromptMentionState>>>;
onCloseMention: (nodeId: string) => void;
onInsertMention: (
nodeId: string,
option: CanvasPromptMentionOption,
textarea: HTMLTextAreaElement | null,
kind?: CanvasNodeKind,
) => void;
mentionKind?: CanvasNodeKind;
}
export function CanvasPromptMentionTextarea({
nodeId,
value,
placeholder,
mentionOptions,
mentionState = DEFAULT_MENTION_STATE,
onPromptChange,
onMentionStateChange,
onCloseMention,
onInsertMention,
mentionKind,
}: CanvasPromptMentionTextareaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const filteredMentions = mentionState.open
? mentionOptions.filter((option) => !mentionState.query || option.searchText.includes(mentionState.query.toLowerCase()))
: [];
const handlePromptChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value;
const caret = event.target.selectionStart || 0;
onPromptChange(nodeId, value);
const beforeCaret = value.slice(0, caret);
const atIndex = beforeCaret.lastIndexOf("@");
if (atIndex >= 0) {
const query = beforeCaret.slice(atIndex + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
onMentionStateChange((prev) => ({
...prev,
[nodeId]: { open: true, query, start: atIndex, caret, activeIndex: 0 },
}));
return;
}
}
onCloseMention(nodeId);
};
const handlePromptKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!mentionState.open || filteredMentions.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
onMentionStateChange((prev) => ({
...prev,
[nodeId]: { ...mentionState, activeIndex: (mentionState.activeIndex + 1) % filteredMentions.length },
}));
} else if (event.key === "ArrowUp") {
event.preventDefault();
onMentionStateChange((prev) => ({
...prev,
[nodeId]: {
...mentionState,
activeIndex: (mentionState.activeIndex - 1 + filteredMentions.length) % filteredMentions.length,
},
}));
} else if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
const option = filteredMentions[mentionState.activeIndex];
if (option) {
onInsertMention(nodeId, option, event.currentTarget, mentionKind);
}
} else if (event.key === "Escape") {
event.preventDefault();
onCloseMention(nodeId);
}
};
const handlePromptSelect = (event: React.SyntheticEvent<HTMLTextAreaElement>) => {
const caret = event.currentTarget.selectionStart || 0;
onMentionStateChange((prev) => {
const current = prev[nodeId];
if (!current?.open) return prev;
return { ...prev, [nodeId]: { ...current, caret } };
});
};
return (
<div className="studio-canvas-text-composer__input-wrap">
<textarea
ref={textareaRef}
value={value}
onMouseDown={(event) => event.stopPropagation()}
onChange={handlePromptChange}
onKeyDown={handlePromptKeyDown}
onSelect={handlePromptSelect}
placeholder={placeholder}
/>
{mentionState.open ? (
<div className="studio-canvas-mention-panel">
{filteredMentions.length > 0 ? filteredMentions.map((option, index) => (
<button
key={option.token}
type="button"
className={`studio-canvas-mention-item${index === mentionState.activeIndex ? " is-active" : ""}`}
onMouseDown={(event) => {
event.preventDefault();
onInsertMention(nodeId, option, textareaRef.current, mentionKind);
}}
>
<span className="studio-canvas-mention-thumb">
{option.kind === "image" && option.previewUrl ? (
<img src={option.previewUrl} alt="" />
) : option.kind === "image" ? "🖼" : option.kind === "video" ? "🎬" : "📝"}
</span>
<span className="studio-canvas-mention-label">{option.nodeTitle}</span>
<span className="studio-canvas-mention-token">{option.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={EMPTY_MENTION_STYLE}>
<span className="studio-canvas-mention-label"></span>
</div>
)}
</div>
) : null}
</div>
);
}
interface CanvasTextPromptComposerProps {
nodeId: string;
prompt: string;
canGenerate: boolean;
isGenerating: boolean;
mentionOptions: CanvasPromptMentionOption[];
mentionState?: CanvasPromptMentionState;
onPromptChange: (nodeId: string, prompt: string) => void;
onMentionStateChange: Dispatch<SetStateAction<Record<string, CanvasPromptMentionState>>>;
onCloseMention: (nodeId: string) => void;
onInsertMention: (
nodeId: string,
option: CanvasPromptMentionOption,
textarea: HTMLTextAreaElement | null,
kind?: CanvasNodeKind,
) => void;
onGenerate: (nodeId: string) => void | Promise<void>;
}
export function CanvasTextPromptComposer({
nodeId,
prompt,
canGenerate,
isGenerating,
mentionOptions,
mentionState,
onPromptChange,
onMentionStateChange,
onCloseMention,
onInsertMention,
onGenerate,
}: CanvasTextPromptComposerProps) {
return (
<div className="studio-canvas-text-composer">
<CanvasPromptMentionTextarea
nodeId={nodeId}
value={prompt}
placeholder="写下你想讲的故事、场景或角色设定。@引用连接的节点"
mentionOptions={mentionOptions}
mentionState={mentionState}
onPromptChange={onPromptChange}
onMentionStateChange={onMentionStateChange}
onCloseMention={onCloseMention}
onInsertMention={onInsertMention}
/>
<div className="studio-canvas-text-composer__footer">
<button
type="button"
className={`studio-canvas-text-composer__send studio-canvas-generate-button${canGenerate && !isGenerating ? " is-ready" : ""}`}
title={isGenerating ? "生成中" : "生成"}
disabled={isGenerating || !canGenerate}
aria-busy={isGenerating}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isGenerating && canGenerate) {
void onGenerate(nodeId);
}
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<SendOutlined />
</button>
</div>
</div>
);
}
@@ -1,74 +0,0 @@
import { useCallback, useMemo } from "react";
import type { ServerAssetItem } from "../../api/assetClient";
import type {
CanvasImageNode,
CanvasNodeKind,
CanvasNodePackage,
CanvasTextNode,
CanvasVideoNode,
} from "./canvasTypes";
import { getCanvasSelectionKey } from "./canvasUtils";
export function useCanvasAssetSummary(serverAssets: ServerAssetItem[]) {
return useMemo(() => {
const canvasAssets: ServerAssetItem[] = [];
const assetCountsByCategory = new Map<string, number>();
for (const asset of serverAssets) {
if (asset.imageUrl) {
canvasAssets.push(asset);
}
assetCountsByCategory.set(asset.type, (assetCountsByCategory.get(asset.type) ?? 0) + 1);
}
return { canvasAssets, assetCountsByCategory };
}, [serverAssets]);
}
export function useCanvasVisibleNodes({
textNodes,
imageNodes,
videoNodes,
nodePackages,
}: {
textNodes: CanvasTextNode[];
imageNodes: CanvasImageNode[];
videoNodes: CanvasVideoNode[];
nodePackages: CanvasNodePackage[];
}) {
const collapsedPackageNodeKeys = useMemo(
() => new Set(
nodePackages.flatMap((nodePackage) =>
nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : []
)
),
[nodePackages],
);
const isNodeCollapsedInPackage = useCallback(
(kind: CanvasNodeKind, id: string) =>
collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind, id })),
[collapsedPackageNodeKeys],
);
const visibleTextNodes = useMemo(
() => textNodes.filter((textNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "text", id: textNode.id }))),
[collapsedPackageNodeKeys, textNodes],
);
const visibleImageNodes = useMemo(
() => imageNodes.filter((imageNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "image", id: imageNode.id }))),
[collapsedPackageNodeKeys, imageNodes],
);
const visibleVideoNodes = useMemo(
() => videoNodes.filter((videoNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "video", id: videoNode.id }))),
[collapsedPackageNodeKeys, videoNodes],
);
return {
collapsedPackageNodeKeys,
isNodeCollapsedInPackage,
visibleTextNodes,
visibleImageNodes,
visibleVideoNodes,
};
}
+5 -10
View File
@@ -82,16 +82,11 @@ export function useCanvasNodeDrag(params: UseCanvasNodeDragParams) {
const cy = pos.y + size.height / 2; const cy = pos.y + size.height / 2;
const right = pos.x + size.width; const right = pos.x + size.width;
const bottom = pos.y + size.height; const bottom = pos.y + size.height;
const others: Array<{ pos: CanvasPoint; size: CanvasNodeSize }> = []; const others = [
for (const node of textNodesRef.current) { ...textNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size }); ...imageNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
} ...videoNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
for (const node of imageNodesRef.current) { ];
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size });
}
for (const node of videoNodesRef.current) {
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size });
}
for (const other of others) { for (const other of others) {
const ocx = other.pos.x + other.size.width / 2; const ocx = other.pos.x + other.size.width / 2;
const ocy = other.pos.y + other.size.height / 2; const ocy = other.pos.y + other.size.height / 2;
@@ -17,8 +17,6 @@ import {
VideoCameraOutlined, VideoCameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import StudioToolLayout from "../../components/StudioToolLayout"; import StudioToolLayout from "../../components/StudioToolLayout";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types"; import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
-1
View File
@@ -10,7 +10,6 @@ import {
SearchOutlined, SearchOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "../../styles/pages/community.css";
import { useDebounce } from "../../hooks/useDebounce"; import { useDebounce } from "../../hooks/useDebounce";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient"; import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
import WorkspacePageShell from "../../components/WorkspacePageShell"; import WorkspacePageShell from "../../components/WorkspacePageShell";
+1 -5
View File
@@ -91,11 +91,7 @@ export function getCommunityCaseSurface(item: Pick<ServerCommunityCase, "metadat
); );
if (explicitSurface !== "unknown") return explicitSurface; if (explicitSurface !== "unknown") return explicitSurface;
const tags: string[] = []; const tags = item.tags.map((tag) => tag.trim()).filter(Boolean);
for (const rawTag of item.tags) {
const tag = rawTag.trim();
if (tag) tags.push(tag);
}
if (tags.some((tag) => tag.includes("生成页面社区") || tag === "Web生成")) return "generation"; if (tags.some((tag) => tag.includes("生成页面社区") || tag === "Web生成")) return "generation";
if (tags.some((tag) => tag.includes("画布页面社区") || tag.includes("工作流"))) return "canvas"; if (tags.some((tag) => tag.includes("画布页面社区") || tag.includes("工作流"))) return "canvas";
if (getWorkflowFromCase(item)) return "canvas"; if (getWorkflowFromCase(item)) return "canvas";
@@ -1,5 +1,4 @@
import { FileTextOutlined, SafetyOutlined } from "@ant-design/icons"; import { FileTextOutlined, SafetyOutlined } from "@ant-design/icons";
import "../../styles/pages/compliance.css";
type ComplianceKind = "agreement" | "privacy"; type ComplianceKind = "agreement" | "privacy";
@@ -1,5 +1,4 @@
import { useCallback, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react"; import { useCallback, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
import "../../styles/pages/dialog-generator.css";
type DialogStyle = "style1" | "style2" | "style3" | "style4"; type DialogStyle = "style1" | "style2" | "style3" | "style4";
@@ -24,7 +24,6 @@ import {
VideoCameraOutlined, VideoCameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode } from "react"; import { useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode } from "react";
import "../../styles/pages/avatar-console.css";
import type { WebViewKey } from "../../types"; import type { WebViewKey } from "../../types";
import { import {
bringAvatarEditorLayerForward, bringAvatarEditorLayerForward,
@@ -18,8 +18,6 @@ import {
UserOutlined, UserOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { uploadAssetWithProgress } from "../../api/uploadWithProgress"; import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
+21 -102
View File
@@ -12,9 +12,7 @@ import {
SettingOutlined, SettingOutlined,
SkinOutlined, SkinOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react"; import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import "../../styles/pages/ecommerce.css";
import "../../styles/pages/local-theme-parity.css";
import { ossAssets } from "../../data/ossAssets"; import { ossAssets } from "../../data/ossAssets";
import { EcommerceProgressBar } from "./EcommerceProgressBar"; import { EcommerceProgressBar } from "./EcommerceProgressBar";
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu"; import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
@@ -577,7 +575,6 @@ const cloneSetCountOptions: Array<{
{ key: "white", title: "白底图", desc: "白底主图,多角度呈现商品细节" }, { key: "white", title: "白底图", desc: "白底主图,多角度呈现商品细节" },
{ key: "scene", title: "场景图", desc: "展示商品的生活使用场景和人物搭配" }, { key: "scene", title: "场景图", desc: "展示商品的生活使用场景和人物搭配" },
]; ];
const cloneSetCountKeys = cloneSetCountOptions.map((option) => option.key);
const defaultCloneSetCounts: Record<CloneSetCountKey, number> = { const defaultCloneSetCounts: Record<CloneSetCountKey, number> = {
selling: 3, selling: 3,
white: 1, white: 1,
@@ -903,58 +900,25 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [selectedDetailModules, setSelectedDetailModules] = useState<string[]>(defaultDetailModuleIds); const [selectedDetailModules, setSelectedDetailModules] = useState<string[]>(defaultDetailModuleIds);
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle"); const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
const [detailResultUrl, setDetailResultUrl] = useState<string | null>(null); const [detailResultUrl, setDetailResultUrl] = useState<string | null>(null);
const productSetRatioOptions = useMemo( const productSetRatioOptions = getPlatformRatioOptions(productSetPlatform, productSetOutput);
() => getPlatformRatioOptions(productSetPlatform, productSetOutput), const hotUploadedRatioOption = cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null;
[productSetOutput, productSetPlatform], const baseCloneRatioOptions = getPlatformRatioOptions(platform, cloneOutput);
); const cloneRatioOptions = hotUploadedRatioOption
const hotUploadedRatioOption = useMemo(
() => cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null,
[cloneOutput, cloneReferenceImages],
);
const baseCloneRatioOptions = useMemo(
() => getPlatformRatioOptions(platform, cloneOutput),
[cloneOutput, platform],
);
const cloneRatioOptions = useMemo(
() => hotUploadedRatioOption
? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption]) ? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption])
: baseCloneRatioOptions, : baseCloneRatioOptions;
[baseCloneRatioOptions, hotUploadedRatioOption], const productSetLanguageOptions = getPlatformLanguageOptions(productSetPlatform, productSetMarket);
); const cloneLanguageOptions = getPlatformLanguageOptions(platform, market);
const productSetLanguageOptions = useMemo( const detailLanguageOptions = getPlatformLanguageOptions(detailPlatform, detailMarket);
() => getPlatformLanguageOptions(productSetPlatform, productSetMarket),
[productSetMarket, productSetPlatform],
);
const cloneLanguageOptions = useMemo(
() => getPlatformLanguageOptions(platform, market),
[market, platform],
);
const detailLanguageOptions = useMemo(
() => getPlatformLanguageOptions(detailPlatform, detailMarket),
[detailMarket, detailPlatform],
);
const ecommerceMentionImages: MentionImageOption[] = [ const ecommerceMentionImages: MentionImageOption[] = [
...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })), ...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })),
...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })), ...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })),
]; ];
const ecommerceVideoImageDataUrls = useMemo(
() => productImages.map((img) => img.src),
[productImages],
);
const ecommerceVideoImageFiles = useMemo(
() => productImages.map((img) => img.file),
[productImages],
);
const selectedProductSetOutput = const selectedProductSetOutput =
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!; productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!; const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
const productSetPreviewReady = productSetStatus === "done"; const productSetPreviewReady = productSetStatus === "done";
const cloneSetTotal = useMemo( const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0);
() => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0),
[cloneSetCounts],
);
const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating"; const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating";
const canGenerate = (cloneOutput === "video-outfit" const canGenerate = (cloneOutput === "video-outfit"
? Boolean(videoOutfitVideoFile && videoOutfitRefFile) ? Boolean(videoOutfitVideoFile && videoOutfitRefFile)
@@ -963,12 +927,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating"; const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
const cloneVideoDurationProgress = const cloneVideoDurationProgress =
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100; ((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
const cloneVideoDurationStyle: CSSProperties = useMemo( const cloneVideoDurationStyle: CSSProperties = {
() => ({
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`, "--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
}) as CSSProperties, } as CSSProperties;
[cloneVideoDurationProgress],
);
const trackEcommerceTask = (taskId: string) => { const trackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.add(taskId); activeEcommerceTaskIdsRef.current.add(taskId);
@@ -1137,8 +1098,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}; };
const clearCloneSetCountHold = () => { const clearCloneSetCountHold = () => {
window.removeEventListener("pointerup", clearCloneSetCountHold);
window.removeEventListener("pointercancel", clearCloneSetCountHold);
if (countHoldTimeoutRef.current !== null) { if (countHoldTimeoutRef.current !== null) {
window.clearTimeout(countHoldTimeoutRef.current); window.clearTimeout(countHoldTimeoutRef.current);
countHoldTimeoutRef.current = null; countHoldTimeoutRef.current = null;
@@ -1253,34 +1212,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
requirement, requirement,
}); });
const latestCloneSettingSnapshot = useMemo(
() => createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest"),
[
cloneOutput,
platform,
market,
language,
ratio,
cloneSetCounts,
selectedCloneDetailModules,
cloneModelPanelTab,
selectedCloneModelScenes,
cloneModelCustomScene,
cloneModelGender,
cloneModelAge,
cloneModelEthnicity,
cloneModelBody,
cloneModelAppearance,
cloneVideoQuality,
cloneVideoDuration,
cloneVideoSmart,
cloneReferenceMode,
cloneReplicateLevel,
requirement,
cloneSettingName,
],
);
const persistLatestCloneSetting = () => { const persistLatestCloneSetting = () => {
const snapshot = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest"); const snapshot = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
latestCloneSettingRef.current = snapshot; latestCloneSettingRef.current = snapshot;
@@ -1328,8 +1259,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}; };
useEffect(() => { useEffect(() => {
latestCloneSettingRef.current = latestCloneSettingSnapshot; latestCloneSettingRef.current = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
}, [latestCloneSettingSnapshot]); });
useEffect(() => { useEffect(() => {
const latestSetting = readCloneLatestSetting(); const latestSetting = readCloneLatestSetting();
@@ -1636,7 +1567,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const generatedUrls: string[] = []; const generatedUrls: string[] = [];
const stamp = Date.now(); const stamp = Date.now();
for (const countKey of cloneSetCountKeys) { for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
const count = counts[countKey]; const count = counts[countKey];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
if (imageAbortRef.current.current) break; if (imageAbortRef.current.current) break;
@@ -1912,13 +1843,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
platform, ratio, language, market, platform, ratio, language, market,
{ gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene }, { gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene },
(s) => setTryOnStatus(s as TryOnStatus), (s) => setTryOnStatus(s as TryOnStatus),
(res) => { (res) => setTryOnResultImages(res.map((r) => r.src).filter(Boolean)),
const urls: string[] = [];
for (const item of res) {
if (item.src) urls.push(item.src);
}
setTryOnResultImages(urls);
},
); );
lastFailedActionRef.current = () => handleTryOnGenerate(); lastFailedActionRef.current = () => handleTryOnGenerate();
}; };
@@ -2038,7 +1963,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
productImages.length === 0 ? "请先上传商品原图" : status === "generating" ? "生成中..." : `生成${selectedCloneOutput.label}`; productImages.length === 0 ? "请先上传商品原图" : status === "generating" ? "生成中..." : `生成${selectedCloneOutput.label}`;
const setPreviewCards: CloneResult[] = []; const setPreviewCards: CloneResult[] = [];
let setIndex = 0; let setIndex = 0;
for (const countKey of cloneSetCountKeys) { for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
const count = cloneSetCounts[countKey]; const count = cloneSetCounts[countKey];
const info = setCountLabels[countKey]; const info = setCountLabels[countKey];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@@ -2053,7 +1978,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const clonePreviewCards: CloneResult[] = []; const clonePreviewCards: CloneResult[] = [];
let cloneIndex = 0; let cloneIndex = 0;
for (const countKey of cloneSetCountKeys) { for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
const count = cloneSetCounts[countKey]; const count = cloneSetCounts[countKey];
const info = setCountLabels[countKey]; const info = setCountLabels[countKey];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@@ -2065,12 +1990,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
cloneIndex++; cloneIndex++;
} }
} }
const detailSourcePreviewImages = detailProductImages.length
? detailProductImages.reduce<string[]>((urls, item) => {
urls.push(item.src);
return urls;
}, [])
: detailProductSamples;
const cloneBasicSelects: Array<{ const cloneBasicSelects: Array<{
key: CloneBasicSelectKey; key: CloneBasicSelectKey;
label: string; label: string;
@@ -2569,7 +2488,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<section className="product-detail-demo-board"> <section className="product-detail-demo-board">
<div className="product-detail-source-stack"> <div className="product-detail-source-stack">
{detailSourcePreviewImages.map((src, index) => ( {(detailProductImages.length ? detailProductImages.map((item) => item.src) : detailProductSamples).map((src, index) => (
<figure key={`${src}-${index}`}> <figure key={`${src}-${index}`}>
<img src={src} alt={`商品原图 ${index + 1}`} /> <img src={src} alt={`商品原图 ${index + 1}`} />
</figure> </figure>
@@ -2697,8 +2616,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0, overflow: "hidden" }}> <main className="product-clone-preview product-clone-preview--video" style={{ padding: 0, overflow: "hidden" }}>
<EcommerceVideoWorkspace <EcommerceVideoWorkspace
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)} isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
productImageDataUrls={ecommerceVideoImageDataUrls} productImageDataUrls={productImages.map((img) => img.src)}
productImageFiles={ecommerceVideoImageFiles} productImageFiles={productImages.map((img) => img.file)}
requirement={requirement} requirement={requirement}
platform={platform} platform={platform}
aspectRatio={ratio.includes("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"} aspectRatio={ratio.includes("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"}
@@ -8,10 +8,6 @@ import {
TagsOutlined, TagsOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import "../../styles/pages/ecommerce.css";
import "../../styles/pages/local-theme-parity.css";
import type { WebProjectSummary } from "../../types"; import type { WebProjectSummary } from "../../types";
import { useDebounce } from "../../hooks/useDebounce"; import { useDebounce } from "../../hooks/useDebounce";
import { templateCarouselCases, templateCases, templateCategories, type TemplateCase } from "./ecommerceTemplates"; import { templateCarouselCases, templateCases, templateCategories, type TemplateCase } from "./ecommerceTemplates";
@@ -1,5 +1,4 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import "../../styles/pages/ecommerce-video.css";
import { import {
CloseOutlined, CloseOutlined,
CopyOutlined, CopyOutlined,
@@ -122,7 +121,6 @@ export default function EcommerceVideoWorkspace({
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null); const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false }); const renderAbortRef = useRef({ current: false });
const actionNoticeTimerRef = useRef<number | null>(null);
const setView = useAppStore((s) => s.setView); const setView = useAppStore((s) => s.setView);
const keepaliveRestoredFingerprintRef = useRef<string | null>(null); const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
const keepalivePollingStartedRef = useRef(false); const keepalivePollingStartedRef = useRef(false);
@@ -278,23 +276,9 @@ export default function EcommerceVideoWorkspace({
// Note: keep-alive is NOT cleared on completion — results persist across page switches. // Note: keep-alive is NOT cleared on completion — results persist across page switches.
// Only cleared when user explicitly starts a new plan via handlePlan. // Only cleared when user explicitly starts a new plan via handlePlan.
useEffect(() => {
return () => {
if (actionNoticeTimerRef.current !== null) {
window.clearTimeout(actionNoticeTimerRef.current);
}
};
}, []);
const showNotice = (msg: string) => { const showNotice = (msg: string) => {
setActionNotice(msg); setActionNotice(msg);
if (actionNoticeTimerRef.current !== null) { setTimeout(() => setActionNotice(null), 3000);
window.clearTimeout(actionNoticeTimerRef.current);
}
actionNoticeTimerRef.current = window.setTimeout(() => {
actionNoticeTimerRef.current = null;
setActionNotice(null);
}, 3000);
}; };
const handleDownload = async (url: string) => { const handleDownload = async (url: string) => {
+23 -12
View File
@@ -9,7 +9,6 @@ import {
type AdVideoUserConfig, type AdVideoUserConfig,
} from "../../api/adVideoPlanClient"; } from "../../api/adVideoPlanClient";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { serverRequest } from "../../api/serverConnection";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation"; import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
@@ -431,6 +430,15 @@ export interface VideoHistoryListResponse {
offset: 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 buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> { export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
const uploadAssetByUrl = payload.uploadAssetByUrl; const uploadAssetByUrl = payload.uploadAssetByUrl;
const scenes = await Promise.all( const scenes = await Promise.all(
@@ -478,12 +486,13 @@ export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryP
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> { export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload); const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
return serverRequest<{ id: number; createdAt: string }>("ai/ecommerce/video-history", { const res = await fetch(API_BASE, {
method: "POST", method: "POST",
body: historyPayload, headers: { "Content-Type": "application/json", ...getAuthHeaders() },
maxRetries: 0, body: JSON.stringify(historyPayload),
fallbackMessage: "Failed to save video history",
}); });
if (!res.ok) throw new Error("Failed to save video history");
return res.json();
} }
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem { function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
@@ -502,10 +511,12 @@ export async function fetchVideoHistory(
limit = 20, limit = 20,
offset = 0, offset = 0,
): Promise<VideoHistoryListResponse> { ): Promise<VideoHistoryListResponse> {
const search = new URLSearchParams({ limit: String(limit), offset: String(offset) }); const res = await fetch(
const history = await serverRequest<VideoHistoryListResponse>(`ai/ecommerce/video-history?${search}`, { `${API_BASE}?limit=${limit}&offset=${offset}`,
fallbackMessage: "Failed to fetch video history", { headers: getAuthHeaders() },
}); );
if (!res.ok) throw new Error("Failed to fetch video history");
const history = (await res.json()) as VideoHistoryListResponse;
return { return {
...history, ...history,
items: history.items.map(removeTemporaryHistoryUrls), items: history.items.map(removeTemporaryHistoryUrls),
@@ -513,9 +524,9 @@ export async function fetchVideoHistory(
} }
export async function deleteVideoHistory(id: number): Promise<void> { export async function deleteVideoHistory(id: number): Promise<void> {
await serverRequest<void>(`ai/ecommerce/video-history/${id}`, { const res = await fetch(`${API_BASE}/${id}`, {
method: "DELETE", method: "DELETE",
maxRetries: 0, headers: getAuthHeaders(),
fallbackMessage: "Failed to delete video history",
}); });
if (!res.ok) throw new Error("Failed to delete video history");
} }
-1
View File
@@ -11,7 +11,6 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSPr
import type { WebViewKey, WebImageWorkbenchTool } from "../../types"; import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
import { useScrollEntrance } from "../../hooks/useScrollEntrance"; import { useScrollEntrance } from "../../hooks/useScrollEntrance";
import { ossAssets } from "../../data/ossAssets"; import { ossAssets } from "../../data/ossAssets";
import "../../styles/pages/home.css";
import WelcomeSplash from "./WelcomeSplash"; import WelcomeSplash from "./WelcomeSplash";
import ToolboxSection from "./ToolboxSection"; import ToolboxSection from "./ToolboxSection";
import ScriptReviewShowcase from "./ScriptReviewShowcase"; import ScriptReviewShowcase from "./ScriptReviewShowcase";
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import "../../styles/pages/model-generation-showcase.css";
type ShowMode = "agent" | "image" | "video"; type ShowMode = "agent" | "image" | "video";
+4 -16
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import "../../styles/pages/script-review-showcase.css";
const DIMS = [ const DIMS = [
{ name: "钩子设计", score: 16, max: 20, hue: 145, desc: "吸引力·悬念·黄金三秒", isPerfect: false, isLow: false }, { name: "钩子设计", score: 16, max: 20, hue: 145, desc: "吸引力·悬念·黄金三秒", isPerfect: false, isLow: false },
@@ -51,12 +50,6 @@ function ScriptReviewShowcase() {
const scoreRef = useRef<HTMLSpanElement>(null); const scoreRef = useRef<HTMLSpanElement>(null);
const barRefs = useRef<(HTMLDivElement | null)[]>([]); const barRefs = useRef<(HTMLDivElement | null)[]>([]);
const scoreValRefs = useRef<(HTMLSpanElement | null)[]>([]); const scoreValRefs = useRef<(HTMLSpanElement | null)[]>([]);
const animationTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const clearAnimationTimers = () => {
animationTimersRef.current.forEach((timer) => clearTimeout(timer));
animationTimersRef.current = [];
};
useEffect(() => { useEffect(() => {
const el = document.getElementById("script-review-showcase"); const el = document.getElementById("script-review-showcase");
@@ -76,23 +69,18 @@ function ScriptReviewShowcase() {
useEffect(() => { useEffect(() => {
if (!animated) return; if (!animated) return;
clearAnimationTimers(); const timer = setTimeout(() => {
const scheduleAnimation = (callback: () => void, delay: number) => {
const timer = setTimeout(callback, delay);
animationTimersRef.current.push(timer);
};
scheduleAnimation(() => {
animateNumber(scoreRef.current, 77, 1400); animateNumber(scoreRef.current, 77, 1400);
barRefs.current.forEach((bar, i) => { barRefs.current.forEach((bar, i) => {
if (!bar) return; if (!bar) return;
const pct = parseFloat(bar.dataset.pct ?? "0"); const pct = parseFloat(bar.dataset.pct ?? "0");
scheduleAnimation(() => { bar.style.height = `${pct}%`; }, i * 100 + 400); setTimeout(() => { bar.style.height = `${pct}%`; }, i * 100 + 400);
}); });
scoreValRefs.current.forEach((el, i) => { scoreValRefs.current.forEach((el, i) => {
scheduleAnimation(() => animateNumber(el, parseInt(el?.dataset.target ?? "0"), 800), i * 100 + 400); setTimeout(() => animateNumber(el, parseInt(el?.dataset.target ?? "0"), 800), i * 100 + 400);
}); });
}, 500); }, 500);
return clearAnimationTimers; return () => clearTimeout(timer);
}, [animated]); }, [animated]);
return ( return (
-1
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import "../../styles/pages/script-review-visual.css";
const DIMS = [ const DIMS = [
{ name: "钩子设计", score: 19, max: 20, hue: 145 }, { name: "钩子设计", score: 19, max: 20, hue: 145 },
-1
View File
@@ -1,7 +1,6 @@
import { ToolOutlined } from "@ant-design/icons"; import { ToolOutlined } from "@ant-design/icons";
import type { WebViewKey, WebImageWorkbenchTool } from "../../types"; import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
import { ossAssets } from "../../data/ossAssets"; import { ossAssets } from "../../data/ossAssets";
import "../../styles/pages/toolbox.css";
const { const {
imageBefore: toolImageBefore, imageBefore: toolImageBefore,
-1
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/welcome-splash.css";
interface WelcomeSplashProps { interface WelcomeSplashProps {
onEnter: () => void; onEnter: () => void;
@@ -24,8 +24,6 @@ import {
ThunderboltOutlined, ThunderboltOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types"; import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
@@ -39,9 +37,6 @@ type WorkMode = "single" | "blend";
type OutputSize = "9:16" | "16:9" | "4:3" | "3:4" | "1:1"; type OutputSize = "9:16" | "16:9" | "4:3" | "3:4" | "1:1";
type OutputCount = 1 | 2 | 3 | 4; type OutputCount = 1 | 2 | 3 | 4;
const OUTPUT_SIZE_OPTIONS: OutputSize[] = ["9:16", "16:9", "4:3", "3:4", "1:1"];
const OUTPUT_COUNT_OPTIONS: OutputCount[] = [1, 2, 3, 4];
const SIZE_TO_RATIO: Record<OutputSize, string> = { const SIZE_TO_RATIO: Record<OutputSize, string> = {
"9:16": "9:16", "9:16": "9:16",
"16:9": "16:9", "16:9": "16:9",
@@ -85,20 +80,6 @@ const CAMERA_EFFECT_PRESETS = [
{ key: "hdr", label: "HDR", prompt: "HDR高动态范围,明暗细节丰富,色彩饱和" }, { key: "hdr", label: "HDR", prompt: "HDR高动态范围,明暗细节丰富,色彩饱和" },
] as const; ] as const;
const CAMERA_EFFECT_PROMPT_BY_KEY = new Map<string, string>(
CAMERA_EFFECT_PRESETS.map((effect) => [effect.key, effect.prompt]),
);
function getCameraEffectsPrompt(effectKeys: Set<string>): string {
if (effectKeys.size === 0) return "";
const prompts: string[] = [];
for (const key of effectKeys) {
const prompt = CAMERA_EFFECT_PROMPT_BY_KEY.get(key);
if (prompt) prompts.push(prompt);
}
return prompts.join("");
}
function shotScaleToZoom(shotScale: number): number { function shotScaleToZoom(shotScale: number): number {
const map: Record<number, number> = { 1: 24, 2: 28, 3: 32, 4: 35, 5: 40, 6: 50, 7: 60, 8: 75, 9: 85, 10: 100 }; const map: Record<number, number> = { 1: 24, 2: 28, 3: 32, 4: 35, 5: 40, 6: 50, 7: 60, 8: 75, 9: 85, 10: 100 };
return map[Math.round(Math.max(1, Math.min(10, shotScale)))] || 40; return map[Math.round(Math.max(1, Math.min(10, shotScale)))] || 40;
@@ -171,7 +152,6 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
abortRef.current = false; abortRef.current = false;
taskIdRef.current = saved.taskId; taskIdRef.current = saved.taskId;
void waitForTask(saved.taskId, { void waitForTask(saved.taskId, {
kind: "image",
onProgress: (e) => { onProgress: (e) => {
setStatus(`${e.status} / ${e.progress}%`); setStatus(`${e.status} / ${e.progress}%`);
if (e.status === "completed" && e.resultUrl) { if (e.status === "completed" && e.resultUrl) {
@@ -418,7 +398,9 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
const refUrls = await uploadReferenceImages([cameraImage]); const refUrls = await uploadReferenceImages([cameraImage]);
const model = "wan2.7-image-pro"; const model = "wan2.7-image-pro";
const cameraDesc = `镜头预设: ${cameraPreset}, 方向: ${cameraDirection}, 水平: ${cameraHorizontal}°, 垂直: ${cameraVertical}°, 倾斜: ${cameraRoll}°, 焦距: ${cameraZoom}mm`; const cameraDesc = `镜头预设: ${cameraPreset}, 方向: ${cameraDirection}, 水平: ${cameraHorizontal}°, 垂直: ${cameraVertical}°, 倾斜: ${cameraRoll}°, 焦距: ${cameraZoom}mm`;
const effectsDesc = getCameraEffectsPrompt(cameraEffects); const effectsDesc = cameraEffects.size > 0
? Array.from(cameraEffects).map((key) => CAMERA_EFFECT_PRESETS.find((e) => e.key === key)?.prompt).filter(Boolean).join("")
: "";
const fullPrompt = cameraPromptEnabled && cameraPrompt.trim() const fullPrompt = cameraPromptEnabled && cameraPrompt.trim()
? `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}${cameraPrompt}` ? `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}${cameraPrompt}`
: `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}。保持人物和场景一致,按照镜头参数重新构图。`; : `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}。保持人物和场景一致,按照镜头参数重新构图。`;
@@ -464,7 +446,6 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => { const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => {
return waitForTask(taskId, { return waitForTask(taskId, {
kind: "image",
abortRef, abortRef,
onProgress: (e) => setGenerationProgress(e.progress || 0), onProgress: (e) => setGenerationProgress(e.progress || 0),
}); });
@@ -765,7 +746,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
<h3></h3> <h3></h3>
<span className="image-workbench-field-label"></span> <span className="image-workbench-field-label"></span>
<div className="image-workbench-segmented"> <div className="image-workbench-segmented">
{OUTPUT_SIZE_OPTIONS.map((s) => ( {(["9:16", "16:9", "4:3", "3:4", "1:1"] as OutputSize[]).map((s) => (
<button key={s} type="button" className={outputSize === s ? "is-active" : ""} onClick={() => setOutputSize(s)}> <button key={s} type="button" className={outputSize === s ? "is-active" : ""} onClick={() => setOutputSize(s)}>
{s} {s}
</button> </button>
@@ -1321,7 +1302,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
<h3></h3> <h3></h3>
<span className="image-workbench-field-label"></span> <span className="image-workbench-field-label"></span>
<div className="image-workbench-segmented"> <div className="image-workbench-segmented">
{OUTPUT_SIZE_OPTIONS.map((s) => ( {(["9:16", "16:9", "4:3", "3:4", "1:1"] as OutputSize[]).map((s) => (
<button key={s} type="button" className={outputSize === s ? "is-active" : ""} onClick={() => setOutputSize(s)}> <button key={s} type="button" className={outputSize === s ? "is-active" : ""} onClick={() => setOutputSize(s)}>
{s} {s}
</button> </button>
@@ -1330,7 +1311,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
<div className="image-workbench-count"> <div className="image-workbench-count">
<span></span> <span></span>
<div> <div>
{OUTPUT_COUNT_OPTIONS.map((count) => ( {([1, 2, 3, 4] as OutputCount[]).map((count) => (
<button <button
key={count} key={count}
type="button" type="button"
+4 -8
View File
@@ -13,8 +13,7 @@ import {
VideoCameraOutlined, VideoCameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import "../../styles/pages/more.css";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types"; import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
interface MorePageProps { interface MorePageProps {
@@ -144,12 +143,9 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
return t.category === filter; return t.category === filter;
}); });
const toolById = useMemo(() => new Map(tools.map((tool) => [tool.id, tool])), []); const recentTools = recentIds
const recentTools = recentIds.reduce<MoreTool[]>((acc, id) => { .map((id) => tools.find((t) => t.id === id))
const tool = toolById.get(id); .filter((t): t is MoreTool => Boolean(t) && (t?.ready ?? false));
if (tool?.ready) acc.push(tool);
return acc;
}, []);
const groupedTools = filteredTools.reduce<Record<ToolCategory, MoreTool[]>>((acc, t) => { const groupedTools = filteredTools.reduce<Record<ToolCategory, MoreTool[]>>((acc, t) => {
if (!acc[t.category]) acc[t.category] = []; if (!acc[t.category]) acc[t.category] = [];
+20 -53
View File
@@ -17,8 +17,7 @@ import {
ShareAltOutlined, ShareAltOutlined,
UserOutlined, UserOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent } from "react"; import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react";
import "../../styles/pages/profile.css";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { assetClient } from "../../api/assetClient"; import { assetClient } from "../../api/assetClient";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient"; import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
@@ -255,42 +254,12 @@ function ProfilePage({
const [bioStatusNotice, setBioStatusNotice] = useState<string | null>(null); const [bioStatusNotice, setBioStatusNotice] = useState<string | null>(null);
const [bannerUrl, setBannerUrl] = useState(() => session?.user.backgroundUrl || readLocalProfileValue(userId, "background")); const [bannerUrl, setBannerUrl] = useState(() => session?.user.backgroundUrl || readLocalProfileValue(userId, "background"));
const completedTasks = useMemo( const completedTasks = tasks.filter((task) => task.status === "completed");
() => tasks.filter((task) => task.status === "completed"), const visibleWorks = completedTasks.length ? completedTasks : tasks.slice(0, 6);
[tasks],
);
const visibleWorks = useMemo(
() => (completedTasks.length ? completedTasks : tasks.slice(0, 6)),
[completedTasks, tasks],
);
const totalBalance = usage.balanceCents + (session?.user.enterpriseBalanceCents || 0); const totalBalance = usage.balanceCents + (session?.user.enterpriseBalanceCents || 0);
const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分"; const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分";
const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null; const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null;
const displayedBio = profileBio.trim() || "这个人还没有填写个性签名"; const displayedBio = profileBio.trim() || "这个人还没有填写个性签名";
const activePanelTitle =
activePanel === "works"
? "代表作"
: activePanel === "projects"
? "服务器项目"
: activePanel === "assets"
? "我的资产"
: "社区审核";
const activePanelDescription =
activePanel === "works"
? "最近完成的高质量生成内容"
: activePanel === "projects"
? "云端同步的创作项目"
: activePanel === "assets"
? "可复用的图片、视频与素材"
: "已提交社区的案例状态";
const activePanelCount =
activePanel === "works"
? visibleWorks.length
: activePanel === "projects"
? projects.length
: activePanel === "assets"
? savedAssets.length
: communityCases.length;
const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()); const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
const phoneLooksValid = /^1[3-9]\d{9}$/.test(phone.trim()); const phoneLooksValid = /^1[3-9]\d{9}$/.test(phone.trim());
const passwordLooksReady = password.length >= (mode === "register" ? 6 : 1); const passwordLooksReady = password.length >= (mode === "register" ? 6 : 1);
@@ -796,9 +765,9 @@ function ProfilePage({
className={`profile-page__banner${bannerUrl ? " has-image" : ""}`} className={`profile-page__banner${bannerUrl ? " has-image" : ""}`}
style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined} style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined}
> >
<button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()} aria-label="更换背景"> <button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()}>
<CameraOutlined /> <CameraOutlined />
<span className="profile-page__banner-btn-label"></span>
</button> </button>
<div className="profile-page__banner-overlay" /> <div className="profile-page__banner-overlay" />
</header> </header>
@@ -878,16 +847,14 @@ function ProfilePage({
className={accountPanel === "credits" ? "is-active" : ""} className={accountPanel === "credits" ? "is-active" : ""}
onClick={() => setAccountPanel("credits")} onClick={() => setAccountPanel("credits")}
> >
<span></span> {(totalBalance / 100).toFixed(2)}
<strong>{(totalBalance / 100).toFixed(2)}</strong>
</button> </button>
<button <button
type="button" type="button"
className={accountPanel === "tasks" ? "is-active" : ""} className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")} onClick={() => setAccountPanel("tasks")}
> >
<span></span> {tasks.length}
<strong>{tasks.length}</strong>
</button> </button>
</div> </div>
<div className="profile-page__account-summary"> <div className="profile-page__account-summary">
@@ -896,7 +863,6 @@ function ProfilePage({
<span className="profile-page__account-summary-main"> <span className="profile-page__account-summary-main">
<small></small> <small></small>
<strong>{displayName}</strong> <strong>{displayName}</strong>
<em>{packageLabel}</em>
</span> </span>
<span className="profile-page__account-summary-metric"> <span className="profile-page__account-summary-metric">
<small></small> <small></small>
@@ -908,7 +874,6 @@ function ProfilePage({
<span className="profile-page__account-summary-main"> <span className="profile-page__account-summary-main">
<small></small> <small></small>
<strong>{tasks.length} </strong> <strong>{tasks.length} </strong>
<em>{completedTasks.length} </em>
</span> </span>
<span className="profile-page__account-summary-metric"> <span className="profile-page__account-summary-metric">
<small></small> <small></small>
@@ -919,7 +884,6 @@ function ProfilePage({
</div> </div>
</div> </div>
<div className="profile-page__actions">
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan"> <button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
<ShareAltOutlined /> <ShareAltOutlined />
{packageLabel} {packageLabel}
@@ -937,31 +901,34 @@ function ProfilePage({
<LockOutlined /> <LockOutlined />
退 退
</button> </button>
</div>
</aside> </aside>
<main className="profile-page__main"> <main className="profile-page__main">
<div className="profile-page__main-tabs"> <div className="profile-page__main-tabs">
<button type="button" className={activePanel === "works" ? "is-active" : ""} onClick={() => setActivePanel("works")}> <button type="button" className={activePanel === "works" ? "is-active" : ""} onClick={() => setActivePanel("works")}>
<span></span>
</button> </button>
<button type="button" className={activePanel === "projects" ? "is-active" : ""} onClick={() => setActivePanel("projects")}> <button type="button" className={activePanel === "projects" ? "is-active" : ""} onClick={() => setActivePanel("projects")}>
<span></span>
</button> </button>
<button type="button" className={activePanel === "assets" ? "is-active" : ""} onClick={() => setActivePanel("assets")}> <button type="button" className={activePanel === "assets" ? "is-active" : ""} onClick={() => setActivePanel("assets")}>
<span></span>
</button> </button>
<button type="button" className={activePanel === "community" ? "is-active" : ""} onClick={() => setActivePanel("community")}> <button type="button" className={activePanel === "community" ? "is-active" : ""} onClick={() => setActivePanel("community")}>
<span></span>
</button> </button>
</div> </div>
<div className="profile-page__section"> <div className="profile-page__section">
<div className="profile-page__section-head"> <span className="profile-page__section-label">
<span className="profile-page__section-label">{activePanelTitle}</span> {activePanel === "works"
<span className="profile-page__section-desc">{activePanelDescription}</span> ? "代表作"
<span className="profile-page__section-meta">{activePanelCount} </span> : activePanel === "projects"
</div> ? "服务器项目"
: activePanel === "assets"
? "我的资产"
: "社区审核"}
</span>
{renderActivePanel()} {renderActivePanel()}
</div> </div>
</main> </main>
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import "../../styles/pages/provider-health.css";
import { import {
CheckCircleOutlined, CheckCircleOutlined,
CloseCircleOutlined, CloseCircleOutlined,
@@ -16,8 +16,6 @@ import {
VideoCameraOutlined, VideoCameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive"; import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
+20 -25
View File
@@ -1,9 +1,16 @@
import {
BarChartOutlined,
CheckCircleFilled,
CopyOutlined,
DownloadOutlined,
FileTextOutlined,
LoadingOutlined,
ThunderboltOutlined,
UploadOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react"; import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
import "../../styles/pages/script-tokens-v5.css";
import "../../styles/pages/script-tokens.css";
import { evaluateScript } from "../../api/scriptEvalClient"; import { evaluateScript } from "../../api/scriptEvalClient";
import { buildApiUrl, getStoredToken } from "../../api/serverConnection"; import { buildApiUrl, getStoredToken } from "../../api/serverConnection";
import { ShellIcon } from "../../components/ShellIcon";
import { useSessionStore } from "../../stores"; import { useSessionStore } from "../../stores";
interface ScoreDimension { interface ScoreDimension {
@@ -236,21 +243,9 @@ function getDimensionSubScores(result: EvalResult, dim: ScoreDimension): Array<[
.slice(0, 5); .slice(0, 5);
} }
function normalizeEvidenceItems(evidence: unknown[] | undefined, limit: number): string[] {
if (!Array.isArray(evidence)) return [];
const items: string[] = [];
for (const item of evidence) {
const value = String(item).trim();
if (!value) continue;
items.push(value);
if (items.length >= limit) break;
}
return items;
}
function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[] { function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[] {
const evidence = result.evidence?.[dim.key] ?? (dim.key === "logic" ? result.evidence?.dialogue : undefined); const evidence = result.evidence?.[dim.key] ?? (dim.key === "logic" ? result.evidence?.dialogue : undefined);
return normalizeEvidenceItems(evidence, 3); return Array.isArray(evidence) ? evidence.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3) : [];
} }
function formatReportMarkdown(result: EvalResult, script: string): string { function formatReportMarkdown(result: EvalResult, script: string): string {
@@ -485,7 +480,7 @@ function ScriptTokensPage() {
> >
{uploadedFile ? ( {uploadedFile ? (
<div className="script-eval-v5-upload-done is-show"> <div className="script-eval-v5-upload-done is-show">
<ShellIcon name="check-circle" /> <CheckCircleFilled />
<span className="script-eval-v5-uf-meta"> <span className="script-eval-v5-uf-meta">
<span className="script-eval-v5-uf-name">{uploadedFile.name}</span> <span className="script-eval-v5-uf-name">{uploadedFile.name}</span>
<span className="script-eval-v5-uf-size">{formatFileSize(uploadedFile.size)}</span> <span className="script-eval-v5-uf-size">{formatFileSize(uploadedFile.size)}</span>
@@ -496,10 +491,10 @@ function ScriptTokensPage() {
</div> </div>
) : ( ) : (
<> <>
<div className="script-eval-v5-upload-icon"><ShellIcon name="upload" /></div> <div className="script-eval-v5-upload-icon"><UploadOutlined /></div>
<div className="script-eval-v5-upload-text"></div> <div className="script-eval-v5-upload-text"></div>
<button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}> <button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}>
<ShellIcon name="upload" /> <UploadOutlined />
</button> </button>
<div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div> <div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div>
</> </>
@@ -572,11 +567,11 @@ function ScriptTokensPage() {
disabled={loading || !hasContent} disabled={loading || !hasContent}
onClick={() => void handleEvaluate()} onClick={() => void handleEvaluate()}
> >
{loading ? <ShellIcon name="loading" /> : <ShellIcon name="thunderbolt" />} {loading ? <LoadingOutlined /> : <ThunderboltOutlined />}
<span>{loading ? "评测中..." : "开始评测"}</span> <span>{loading ? "评测中..." : "开始评测"}</span>
</button> </button>
<button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}> <button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}>
<ShellIcon name="download" /> <DownloadOutlined />
<span></span> <span></span>
</button> </button>
</div> </div>
@@ -594,10 +589,10 @@ function ScriptTokensPage() {
{result && ( {result && (
<> <>
<button type="button" className="script-eval-v5-action-btn" onClick={() => void handleCopyReport()}> <button type="button" className="script-eval-v5-action-btn" onClick={() => void handleCopyReport()}>
<ShellIcon name="copy" />{copied ? "已复制" : "复制"} <CopyOutlined />{copied ? "已复制" : "复制"}
</button> </button>
<button type="button" className="script-eval-v5-action-btn" onClick={handleExportMarkdown}> <button type="button" className="script-eval-v5-action-btn" onClick={handleExportMarkdown}>
<ShellIcon name="download" /> <DownloadOutlined />
</button> </button>
</> </>
)} )}
@@ -631,7 +626,7 @@ function ScriptTokensPage() {
onKeyDown={uploadKeyDown} onKeyDown={uploadKeyDown}
> >
<div className="script-eval-v5-upload-card-icon"> <div className="script-eval-v5-upload-card-icon">
<ShellIcon name="file-text" /> <FileTextOutlined />
</div> </div>
<div className="script-eval-v5-upload-card-title"> <div className="script-eval-v5-upload-card-title">
{uploadedFile ? "剧本已导入" : "上传剧本文件"} {uploadedFile ? "剧本已导入" : "上传剧本文件"}
@@ -735,7 +730,7 @@ function ScriptTokensPage() {
</div> </div>
</div> </div>
<div className="script-eval-report__chart-note"> <div className="script-eval-report__chart-note">
<ShellIcon name="bar-chart" /> <BarChartOutlined />
<span> <span>
{activeDim === null {activeDim === null
? "悬停维度可查看当前分项表现,优先从低分项制定改稿计划。" ? "悬停维度可查看当前分项表现,优先从低分项制定改稿计划。"
+24 -16
View File
@@ -1,8 +1,16 @@
import {
ArrowLeftOutlined,
BarChartOutlined,
CheckCircleOutlined,
LeftOutlined,
LineChartOutlined,
ReloadOutlined,
RightOutlined,
TeamOutlined,
UserOutlined,
WarningOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { ShellIcon } from "../../components/ShellIcon";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/script-tokens-v5.css";
import "../../styles/pages/script-tokens.css";
import type { import type {
WebEnterpriseUsageMember, WebEnterpriseUsageMember,
WebEnterpriseUsageRecord, WebEnterpriseUsageRecord,
@@ -235,7 +243,7 @@ function TokenUsagePage({
<header className="management-center-toolbar" aria-label="管理中心操作"> <header className="management-center-toolbar" aria-label="管理中心操作">
<div className="management-center-toolbar__title"> <div className="management-center-toolbar__title">
<button type="button" className="management-center-toolbar__back" aria-label="返回工具盒" onClick={onOpenMore}> <button type="button" className="management-center-toolbar__back" aria-label="返回工具盒" onClick={onOpenMore}>
<ShellIcon name="arrow-left" /> <ArrowLeftOutlined />
</button> </button>
<span> <span>
<strong></strong> <strong></strong>
@@ -246,18 +254,18 @@ function TokenUsagePage({
{enterpriseUsageLoading ? "正在同步企业用量" : enterpriseUsageError || "服务器已连接"} {enterpriseUsageLoading ? "正在同步企业用量" : enterpriseUsageError || "服务器已连接"}
</span> </span>
<button type="button" onClick={refreshEnterpriseUsage} disabled={enterpriseUsageLoading}> <button type="button" onClick={refreshEnterpriseUsage} disabled={enterpriseUsageLoading}>
<ShellIcon name="reload" /> <ReloadOutlined />
</button> </button>
<button type="button" className="is-muted-action"> <button type="button" className="is-muted-action">
<ShellIcon name="user" /> <UserOutlined />
</button> </button>
</header> </header>
{isLowBalance ? ( {isLowBalance ? (
<div className="management-balance-alert" role="alert"> <div className="management-balance-alert" role="alert">
<ShellIcon name="warning" /> <WarningOutlined />
<span> {formatCredits(availableBalanceCents)}</span> <span> {formatCredits(availableBalanceCents)}</span>
</div> </div>
) : null} ) : null}
@@ -276,7 +284,7 @@ function TokenUsagePage({
<article className="management-card management-card--chart"> <article className="management-card management-card--chart">
<div className="management-card__head"> <div className="management-card__head">
<h2> <h2>
<ShellIcon name="bar-chart" /> <BarChartOutlined />
</h2> </h2>
<span>{enterpriseUsageLoading ? "SYNC" : modelBreakdown.length ? `${modelBreakdown.length} 个模型` : "LIVE"}</span> <span>{enterpriseUsageLoading ? "SYNC" : modelBreakdown.length ? `${modelBreakdown.length} 个模型` : "LIVE"}</span>
@@ -298,7 +306,7 @@ function TokenUsagePage({
</div> </div>
) : ( ) : (
<div className="management-empty-chart"> <div className="management-empty-chart">
<ShellIcon name="bar-chart" /> <BarChartOutlined />
<span></span> <span></span>
</div> </div>
)} )}
@@ -307,7 +315,7 @@ function TokenUsagePage({
<article className="management-card management-status-card"> <article className="management-card management-status-card">
<div className="management-card__head"> <div className="management-card__head">
<h2> <h2>
<ShellIcon name="line-chart" /> <LineChartOutlined />
</h2> </h2>
</div> </div>
@@ -336,7 +344,7 @@ function TokenUsagePage({
<section className="management-card management-members"> <section className="management-card management-members">
<div className="management-card__head"> <div className="management-card__head">
<h2> <h2>
<ShellIcon name="team" /> <TeamOutlined />
({members.length}) ({members.length})
</h2> </h2>
<button type="button">{isEnterpriseAdmin ? "企业管理员" : "当前账号"}</button> <button type="button">{isEnterpriseAdmin ? "企业管理员" : "当前账号"}</button>
@@ -355,7 +363,7 @@ function TokenUsagePage({
<b>{member.taskCount} </b> <b>{member.taskCount} </b>
<b>{formatDateTime(member.lastUsedAt)}</b> <b>{formatDateTime(member.lastUsedAt)}</b>
</span> </span>
<ShellIcon name="check-circle" /> <CheckCircleOutlined />
</article> </article>
))} ))}
</div> </div>
@@ -364,7 +372,7 @@ function TokenUsagePage({
<section className="management-card management-records"> <section className="management-card management-records">
<div className="management-card__head"> <div className="management-card__head">
<h2> <h2>
<ShellIcon name="bar-chart" /> <BarChartOutlined />
</h2> </h2>
<span>{records.length} </span> <span>{records.length} </span>
@@ -400,11 +408,11 @@ function TokenUsagePage({
{records.length > pageSize && ( {records.length > pageSize && (
<div className="management-record-pagination"> <div className="management-record-pagination">
<button type="button" disabled={recordPage === 0} onClick={() => setRecordPage((p) => p - 1)}> <button type="button" disabled={recordPage === 0} onClick={() => setRecordPage((p) => p - 1)}>
<ShellIcon name="chevron-left" /> <LeftOutlined />
</button> </button>
<span>{recordPage + 1} / {totalPages}</span> <span>{recordPage + 1} / {totalPages}</span>
<button type="button" disabled={recordPage >= totalPages - 1} onClick={() => setRecordPage((p) => p + 1)}> <button type="button" disabled={recordPage >= totalPages - 1} onClick={() => setRecordPage((p) => p + 1)}>
<ShellIcon name="chevron-right" /> <RightOutlined />
</button> </button>
</div> </div>
)} )}
@@ -9,9 +9,6 @@ import {
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react"; import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import type { WebViewKey } from "../../types"; import type { WebViewKey } from "../../types";
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "../ecommerce/ImageMentionMenu"; import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "../ecommerce/ImageMentionMenu";
import "../../styles/pages/ecommerce.css";
import "../../styles/pages/size-template.css";
import "../../styles/pages/local-theme-parity.css";
interface SizeTemplatePageProps { interface SizeTemplatePageProps {
isAuthenticated?: boolean; isAuthenticated?: boolean;
@@ -13,9 +13,6 @@ import {
VideoCameraOutlined, VideoCameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import "../../styles/pages/subtitle-removal.css";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive"; import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
@@ -13,8 +13,6 @@ import {
SwapOutlined, SwapOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive"; import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
+2 -26
View File
@@ -34,14 +34,13 @@ import {
type ReactNode, type ReactNode,
type SyntheticEvent, type SyntheticEvent,
} from "react"; } from "react";
import "../../styles/pages/workbench.css";
import type { WebGenerationPreviewTask, WebUserSession } from "../../types"; import type { WebGenerationPreviewTask, WebUserSession } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { claimGenerationSlot, getActiveGenerationTaskCount, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency"; import { claimGenerationSlot, getActiveGenerationTaskCount, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUploadService"; import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUploadService";
import { assetClient } from "../../api/assetClient"; import { assetClient } from "../../api/assetClient";
import { communityClient } from "../../api/communityClient"; import { communityClient } from "../../api/communityClient";
import { loadRechargeModal, type RechargeModalComponent } from "../../components/RechargeModal/loadRechargeModal"; import { RechargeModal } from "../../components/RechargeModal/RechargeModal";
import { useGenerationTasks } from "../../hooks/useGenerationTasks"; import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import { conversationClient, type ConversationSummary } from "../../api/conversationClient"; import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
@@ -268,7 +267,6 @@ function WorkbenchPage({
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [generationStatus, setGenerationStatus] = useState("准备就绪"); const [generationStatus, setGenerationStatus] = useState("准备就绪");
const [showRechargeModal, setShowRechargeModal] = useState(false); const [showRechargeModal, setShowRechargeModal] = useState(false);
const [RechargeModal, setRechargeModal] = useState<RechargeModalComponent | null>(null);
const [savedAssetMentionItems, setSavedAssetMentionItems] = useState< const [savedAssetMentionItems, setSavedAssetMentionItems] = useState<
Pick<ReferenceItem, "id" | "kind" | "name" | "previewUrl" | "remoteUrl" | "token">[] Pick<ReferenceItem, "id" | "kind" | "name" | "previewUrl" | "remoteUrl" | "token">[]
>([]); >([]);
@@ -292,21 +290,6 @@ function WorkbenchPage({
activeConversationIdRef.current = activeConversationId; activeConversationIdRef.current = activeConversationId;
}, []); }, []);
useEffect(() => {
if (!showRechargeModal || RechargeModal) return;
let cancelled = false;
void loadRechargeModal().then((component) => {
if (!cancelled) {
setRechargeModal(() => component);
}
});
return () => {
cancelled = true;
};
}, [RechargeModal, showRechargeModal]);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return; if (!isAuthenticated) return;
let cancelled = false; let cancelled = false;
@@ -386,7 +369,7 @@ function WorkbenchPage({
.get() .get()
.then((capabilities) => { .then((capabilities) => {
if (cancelled) return; if (cancelled) return;
const nextVideoModels = capabilities.videoModels.length ? capabilities.videoModels : VIDEO_MODEL_OPTIONS; const nextVideoModels = VIDEO_MODEL_OPTIONS;
applyImageModels(capabilities.imageModels); applyImageModels(capabilities.imageModels);
setVideoModelOptions(nextVideoModels); setVideoModelOptions(nextVideoModels);
@@ -3119,11 +3102,6 @@ function WorkbenchPage({
<span>{message.taskStatusLabel || generationStatus}</span> <span>{message.taskStatusLabel || generationStatus}</span>
</div> </div>
)} )}
{message.role === "assistant" && message.mode === "chat" && message.status === "completed" && (
<div className="ai-chat-task-billing-note">
{formatTextTokenUsage(message.taskUsage)}
</div>
)}
{(message.resultUrl || (message.result && message.status !== "thinking")) && ( {(message.resultUrl || (message.result && message.status !== "thinking")) && (
<ResultCard <ResultCard
message={message} message={message}
@@ -3207,9 +3185,7 @@ function WorkbenchPage({
{renderMessagePreviewOverlay()} {renderMessagePreviewOverlay()}
{renderDeleteDialog()} {renderDeleteDialog()}
{showRechargeModal && RechargeModal ? (
<RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} /> <RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} />
) : null}
</section> </section>
); );
} }
+26 -9
View File
@@ -3,8 +3,6 @@
* Persists task state to localStorage so in-progress tasks survive page switches. * Persists task state to localStorage so in-progress tasks survive page switches.
*/ */
import { waitForTask } from "../../api/taskSubscription";
const KEEPALIVE_PREFIX = "omniai:tool-task:"; const KEEPALIVE_PREFIX = "omniai:tool-task:";
interface ToolTaskKeepalive { interface ToolTaskKeepalive {
@@ -61,19 +59,38 @@ export function clearToolTaskState(key: string): void {
try { window.localStorage.removeItem(KEEPALIVE_PREFIX + key); } catch { /* ignore */ } try { window.localStorage.removeItem(KEEPALIVE_PREFIX + key); } catch { /* ignore */ }
} }
const TASK_POLL_INTERVAL = 3000;
const TASK_POLL_TIMEOUT = 30 * 60 * 1000;
export async function pollTaskUntilDone( export async function pollTaskUntilDone(
taskId: string, taskId: string,
onProgress?: (progress: number) => void, onProgress?: (progress: number) => void,
abortRef?: { current: boolean }, abortRef?: { current: boolean },
kind: "image" | "video" = "video",
): Promise<string | null> { ): Promise<string | null> {
const startTime = Date.now();
const { aiGenerationClient } = await import("../../api/aiGenerationClient");
while (true) {
if (abortRef?.current) return null;
if (Date.now() - startTime > TASK_POLL_TIMEOUT) return null;
try { try {
return await waitForTask(taskId, { const task = await aiGenerationClient.getTaskStatus(taskId);
kind, if (!task) return null;
abortRef,
onProgress: (event) => onProgress?.(Math.min(99, Number(event.progress || 0))), const progress = Math.min(99, task.progress || 0);
}); onProgress?.(progress);
} catch {
if (task.status === "completed") {
return task.resultUrl || null;
}
if (task.status === "failed" || task.status === "cancelled") {
return null; return null;
} }
} catch {
// retry on next poll
}
await new Promise((r) => setTimeout(r, TASK_POLL_INTERVAL));
}
} }
+18 -29
View File
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useRef, useCallback } from "react"; import { useEffect, useMemo, useRef, useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import type { GenerationQueueItem } from "../stores/useGenerationStore"; import type { GenerationQueueItem } from "../stores/useGenerationStore";
import { useGenerationStore } from "../stores/useGenerationStore"; import { useGenerationStore } from "../stores/useGenerationStore";
import { import {
@@ -14,17 +13,7 @@ interface UseGenerationTasksOptions {
export function useGenerationTasks(options: UseGenerationTasksOptions) { export function useGenerationTasks(options: UseGenerationTasksOptions) {
const { sourceView, autoResume = true } = options; const { sourceView, autoResume = true } = options;
const { const store = useGenerationStore();
queue,
addTask,
updateTask: updateStoredTask,
getRunningTasks,
} = useGenerationStore(useShallow((s) => ({
queue: s.queue,
addTask: s.addTask,
updateTask: s.updateTask,
getRunningTasks: s.getRunningTasks,
})));
const pollingStartedRef = useRef(false); const pollingStartedRef = useRef(false);
// ── Auto-resume: re-subscribe to running tasks on mount ──── // ── Auto-resume: re-subscribe to running tasks on mount ────
@@ -32,7 +21,7 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) {
if (!autoResume || pollingStartedRef.current) return; if (!autoResume || pollingStartedRef.current) return;
pollingStartedRef.current = true; pollingStartedRef.current = true;
const active = getRunningTasks().filter((t) => t.sourceView === sourceView); const active = store.getRunningTasks().filter((t) => t.sourceView === sourceView);
if (active.length > 0) { if (active.length > 0) {
startBackgroundPolling(); startBackgroundPolling();
} }
@@ -40,19 +29,19 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) {
return () => { return () => {
pollingStartedRef.current = false; pollingStartedRef.current = false;
}; };
}, [autoResume, sourceView, getRunningTasks]); }, [autoResume, sourceView, store]);
// ── Subscribe to live updates ─────────────────────────── // ── Subscribe to live updates ───────────────────────────
useEffect(() => { useEffect(() => {
return subscribeToTaskUpdates((updated) => { return subscribeToTaskUpdates((updated) => {
updateStoredTask(updated.id, updated); store.updateTask(updated.id, updated);
}); });
}, [updateStoredTask]); }, [store]);
// ── View-scoped computed lists ────────────────────────── // ── View-scoped computed lists ──────────────────────────
const myTasks = useMemo( const myTasks = useMemo(
() => queue.filter((t) => t.sourceView === sourceView), () => store.queue.filter((t) => t.sourceView === sourceView),
[queue, sourceView], [store.queue, sourceView],
); );
const activeTasks = useMemo( const activeTasks = useMemo(
@@ -74,41 +63,41 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) {
const submitTask = useCallback( const submitTask = useCallback(
(task: Omit<GenerationQueueItem, "id" | "createdAt">) => { (task: Omit<GenerationQueueItem, "id" | "createdAt">) => {
const id = `gen-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const id = `gen-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
addTask({ ...task, id, createdAt: Date.now() }); store.addTask({ ...task, id, createdAt: Date.now() });
return id; return id;
}, },
[addTask], [store],
); );
const updateTask = useCallback( const updateTask = useCallback(
(id: string, patch: Partial<GenerationQueueItem>) => { (id: string, patch: Partial<GenerationQueueItem>) => {
updateStoredTask(id, patch); store.updateTask(id, patch);
}, },
[updateStoredTask], [store],
); );
const markCompleted = useCallback( const markCompleted = useCallback(
(id: string, resultUrl: string) => { (id: string, resultUrl: string) => {
updateStoredTask(id, { status: "completed", progress: 100, resultUrl }); store.updateTask(id, { status: "completed", progress: 100, resultUrl });
}, },
[updateStoredTask], [store],
); );
const markFailed = useCallback( const markFailed = useCallback(
(id: string, error: string) => { (id: string, error: string) => {
updateStoredTask(id, { status: "failed", error }); store.updateTask(id, { status: "failed", error });
}, },
[updateStoredTask], [store],
); );
const retryTask = useCallback( const retryTask = useCallback(
(id: string) => { (id: string) => {
const task = queue.find((t) => t.id === id); const task = store.queue.find((t) => t.id === id);
if (task) { if (task) {
updateStoredTask(id, { status: "pending", progress: 0, error: null }); store.updateTask(id, { status: "pending", progress: 0, error: null });
} }
}, },
[queue, updateStoredTask], [store],
); );
return { return {
+1
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import "@xyflow/react/dist/style.css";
import "./styles/index.css"; import "./styles/index.css";
import App from "./App"; import App from "./App";
import { reportError } from "./utils/errorReporting"; import { reportError } from "./utils/errorReporting";
+79 -71
View File
@@ -1,12 +1,20 @@
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore"; import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
import { waitForTask, type TaskProgressEvent } from "../api/taskSubscription"; import { aiGenerationClient } from "../api/aiGenerationClient";
import { buildTaskFailureInfo } from "../utils/taskLifecycle"; import {
buildLocalTimeoutMessage,
buildTaskFailureInfo,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../utils/taskLifecycle";
type PollCallback = (item: GenerationQueueItem) => void; type PollCallback = (item: GenerationQueueItem) => void;
const activePollers = new Map<string, { current: boolean }>(); const activePollers = new Map<string, ReturnType<typeof setInterval>>();
const pollCallbacks = new Set<PollCallback>(); const pollCallbacks = new Set<PollCallback>();
const POLL_INTERVAL = 3000;
const MAX_POLL_ATTEMPTS = 200; // Keep the previous 10-minute guard as a fallback.
export function subscribeToTaskUpdates(callback: PollCallback): () => void { export function subscribeToTaskUpdates(callback: PollCallback): () => void {
pollCallbacks.add(callback); pollCallbacks.add(callback);
return () => { pollCallbacks.delete(callback); }; return () => { pollCallbacks.delete(callback); };
@@ -26,109 +34,109 @@ function getQueueItemModel(item: GenerationQueueItem): string | undefined {
return typeof item.params?.model === "string" ? item.params.model : undefined; return typeof item.params?.model === "string" ? item.params.model : undefined;
} }
function updateTaskAndNotify(id: string, patch: Partial<GenerationQueueItem>): GenerationQueueItem | null { function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void {
const current = useGenerationStore.getState().queue.find((i) => i.id === id);
if (!current) return null;
const next = { ...current, ...patch };
useGenerationStore.getState().updateTask(id, patch);
notifyCallbacks(next);
return next;
}
function isTerminalStatus(status: GenerationQueueItem["status"]): boolean {
return status === "completed" || status === "failed" || status === "cancelled";
}
function pollTask(item: GenerationQueueItem): void {
const key = `poll-${item.id}`; const key = `poll-${item.id}`;
if (activePollers.has(key) || !item.taskId) return; if (activePollers.has(key)) return;
const kind = getQueueItemKind(item); const kind = getQueueItemKind(item);
const abortRef = { current: false }; const timeoutPolicy = getTaskTimeoutPolicy({ kind, model: getQueueItemModel(item) });
activePollers.set(key, abortRef); let lastProgress = Math.max(0, Number(item.progress || 0));
let lastProgressAt = Date.now();
const applyProgress = (event: TaskProgressEvent) => { const interval = setInterval(async () => {
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id); const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
if (!current || isTerminalStatus(current.status)) { if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") {
abortRef.current = true; cleanupPoll(key);
return; return;
} }
attemptsRef.current++;
const timeoutReason = isTaskLocallyTimedOut({
startedAt: current.createdAt || item.createdAt || Date.now(),
lastProgressAt,
progress: lastProgress,
policy: timeoutPolicy,
});
if (timeoutReason || attemptsRef.current > MAX_POLL_ATTEMPTS) {
const error = buildLocalTimeoutMessage(kind);
useGenerationStore.getState().updateTask(item.id, {
status: "failed",
error,
});
notifyCallbacks({ ...item, status: "failed", error });
cleanupPoll(key);
return;
}
try {
const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || "");
const nextProgress = Number(status.progress || 0);
if (nextProgress > lastProgress || status.status === "completed") {
lastProgress = Math.max(lastProgress, nextProgress);
lastProgressAt = Date.now();
}
const patch: Partial<GenerationQueueItem> = { const patch: Partial<GenerationQueueItem> = {
progress: Number(event.progress || 0), progress: status.progress,
resultUrl: event.resultUrl || current.resultUrl, resultUrl: status.resultUrl || current.resultUrl,
error: event.error || current.error, error: status.error || current.error,
}; };
if (event.status === "completed") { if (status.status === "completed") {
patch.status = "completed"; patch.status = "completed";
patch.progress = 100; useGenerationStore.getState().updateTask(item.id, patch);
} else if (event.status === "failed" || event.status === "cancelled") { notifyCallbacks({ ...item, ...patch, status: "completed" });
cleanupPoll(key);
} else if (status.status === "failed" || status.status === "cancelled") {
patch.status = "failed"; patch.status = "failed";
patch.error = buildTaskFailureInfo(event.error).message; patch.error = buildTaskFailureInfo(status.error).message;
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "failed" });
cleanupPoll(key);
} else { } else {
patch.status = "running"; patch.status = "running";
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "running" });
}
} catch {
// Network errors during polling are retried until the lifecycle guard trips.
}
}, POLL_INTERVAL);
activePollers.set(key, interval);
} }
updateTaskAndNotify(item.id, patch); function cleanupPoll(key: string): void {
}; const interval = activePollers.get(key);
if (interval) {
void waitForTask(item.taskId, { clearInterval(interval);
kind,
model: getQueueItemModel(item),
startedAt: item.createdAt || Date.now(),
abortRef,
onProgress: applyProgress,
})
.then((resultUrl) => {
if (abortRef.current) return;
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
if (!current || isTerminalStatus(current.status)) return;
updateTaskAndNotify(item.id, {
status: "completed",
progress: 100,
resultUrl: resultUrl || current.resultUrl,
});
})
.catch((error) => {
if (abortRef.current) return;
const failure = buildTaskFailureInfo(error instanceof Error ? error.message : String(error));
updateTaskAndNotify(item.id, {
status: "failed",
error: failure.message,
});
})
.finally(() => {
cleanupPoll(key, abortRef);
});
}
function cleanupPoll(key: string, abortRef: { current: boolean }): void {
if (activePollers.get(key) !== abortRef) return;
activePollers.delete(key); activePollers.delete(key);
} }
}
export function startBackgroundPolling(): void { export function startBackgroundPolling(): void {
const tasks = useGenerationStore.getState().getRunningTasks(); const tasks = useGenerationStore.getState().getRunningTasks();
const attemptsMap = new Map<string, { current: number }>();
tasks.forEach((task) => { tasks.forEach((task) => {
if (task.taskId) { if (task.taskId) {
pollTask(task); if (!attemptsMap.has(task.id)) {
attemptsMap.set(task.id, { current: 0 });
}
pollTask(task, attemptsMap.get(task.id)!);
} }
}); });
} }
export function resumeTaskPolling(taskId: string, storeId: string): void { export function resumeTaskPolling(taskId: string, storeId: string): void {
const task = useGenerationStore.getState().queue.find((i) => i.id === storeId); const task = useGenerationStore.getState().queue.find((i) => i.id === storeId);
if (task && !isTerminalStatus(task.status)) { if (task && task.status !== "completed" && task.status !== "failed") {
pollTask({ ...task, taskId }); pollTask(task, { current: 0 });
} }
} }
export function stopAllPolling(): void { export function stopAllPolling(): void {
activePollers.forEach((abortRef) => { activePollers.forEach((interval) => clearInterval(interval));
abortRef.current = true;
});
activePollers.clear(); activePollers.clear();
} }
@@ -0,0 +1,252 @@
.bug-feedback-overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
}
.bug-feedback-modal {
width: min(560px, 92vw);
max-height: 80vh;
overflow-y: auto;
border-radius: 16px;
background: var(--bg-panel);
border: 1px solid var(--border-subtle);
box-shadow: var(--shadow-heavy, 0 12px 40px rgba(0,0,0,0.4));
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.bug-feedback-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.bug-feedback-modal__tabs {
display: flex;
gap: 4px;
}
.bug-feedback-modal__tabs button {
padding: 6px 14px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--fg-muted);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.bug-feedback-modal__tabs button.is-active {
background: var(--accent);
color: #000;
border-color: var(--accent);
}
.bug-feedback-modal__close {
width: 30px;
height: 30px;
border-radius: 8px;
border: none;
background: var(--bg-subtle);
color: var(--fg-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.bug-feedback-modal__close:hover {
background: var(--bg-hover);
}
.bug-feedback-modal__form {
display: flex;
flex-direction: column;
gap: 12px;
}
.bug-feedback-modal__tip {
font-size: 13px;
color: var(--accent);
margin: 0;
padding: 8px 12px;
border-radius: 8px;
background: rgba(var(--accent-rgb, 0 255 136), 0.08);
}
.bug-feedback-modal__label {
font-size: 13px;
font-weight: 500;
color: var(--fg-muted);
}
.bug-feedback-modal__input,
.bug-feedback-modal__textarea {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-subtle);
color: var(--fg-default);
font-size: 14px;
resize: vertical;
}
.bug-feedback-modal__textarea {
min-height: 100px;
}
.bug-feedback-modal__screenshot {
display: flex;
align-items: center;
}
.bug-feedback-modal__upload-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px dashed var(--border-subtle);
background: transparent;
color: var(--fg-muted);
font-size: 13px;
cursor: pointer;
transition: border-color 0.15s;
}
.bug-feedback-modal__upload-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.bug-feedback-modal__preview {
display: flex;
align-items: center;
gap: 10px;
}
.bug-feedback-modal__preview img {
width: 80px;
height: 60px;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--border-subtle);
}
.bug-feedback-modal__preview button {
padding: 4px 10px;
border-radius: 6px;
border: 1px solid var(--border-subtle);
background: transparent;
color: var(--fg-muted);
font-size: 12px;
cursor: pointer;
}
.bug-feedback-modal__submit {
padding: 10px 20px;
border-radius: 8px;
border: none;
background: var(--accent);
color: #000;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.bug-feedback-modal__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bug-feedback-modal__history {
min-height: 120px;
}
.bug-feedback-modal__empty {
text-align: center;
color: var(--fg-muted);
font-size: 13px;
padding: 32px 0;
}
.bug-feedback-modal__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.bug-feedback-modal__item {
padding: 12px;
border-radius: 10px;
background: var(--bg-subtle);
border: 1px solid var(--border-subtle);
}
.bug-feedback-modal__item-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.bug-feedback-modal__item-title {
font-size: 14px;
font-weight: 500;
color: var(--fg-default);
}
.bug-feedback-modal__status {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
}
.bug-feedback-modal__status--pending {
background: rgba(255, 180, 0, 0.15);
color: #e6a700;
}
.bug-feedback-modal__status--approved {
background: rgba(0, 200, 80, 0.15);
color: #00c850;
}
.bug-feedback-modal__status--rejected {
background: rgba(255, 60, 60, 0.15);
color: #ff4040;
}
.bug-feedback-modal__item-desc {
font-size: 13px;
color: var(--fg-muted);
margin: 0 0 4px;
white-space: pre-wrap;
}
.bug-feedback-modal__item-note {
font-size: 12px;
color: var(--accent);
margin: 4px 0;
}
.bug-feedback-modal__item-date {
font-size: 11px;
color: var(--fg-muted);
opacity: 0.7;
}
+35
View File
@@ -3,6 +3,41 @@
@import "./shell/app-shell.css"; @import "./shell/app-shell.css";
@import "./components/primitives.css"; @import "./components/primitives.css";
@import "./components/legacy-components.css"; @import "./components/legacy-components.css";
@import "./pages/home.css";
@import "./pages/welcome-splash.css";
@import "./pages/toolbox.css";
@import "./pages/script-review-visual.css";
@import "./pages/script-review-showcase.css";
@import "./pages/model-generation-showcase.css";
@import "./pages/workbench.css";
@import "./pages/ecommerce.css";
@import "./pages/ecommerce-video.css";
@import "./pages/community.css";
@import "./pages/assets.css";
@import "./pages/more.css";
@import "./pages/avatar-console.css";
@import "./pages/more-tools.css";
@import "./pages/studio-layout.css";
@import "./pages/image-workbench.css";
@import "./pages/subtitle-removal.css";
@import "./pages/dialog-generator.css";
@import "./pages/size-template.css";
@import "./pages/script-tokens-v5.css";
@import "./pages/script-tokens.css";
@import "./pages/profile.css";
@import "./pages/canvas.css";
@import "./pages/agent.css";
@import "./pages/compliance.css";
@import "./pages/provider-health.css";
@import "./pages/legacy-pages.css";
@import "./pages/not-found.css";
@import "./components/recharge-modal.css";
@import "./components/dropzone.css";
@import "./components/skeleton.css";
@import "./components/toast.css"; @import "./components/toast.css";
@import "./components/empty-state.css";
@import "./components/page-transition.css"; @import "./components/page-transition.css";
@import "./components/motion.css"; @import "./components/motion.css";
@import "./components/bug-feedback-modal.css";
@import "./themes/dark-green.css";
@import "./pages/local-theme-parity.css";
-6
View File
@@ -1,6 +0,0 @@
let darkGreenThemePromise: Promise<unknown> | null = null;
export function loadDarkGreenTheme(): Promise<unknown> {
darkGreenThemePromise ??= import("./themes/dark-green.css");
return darkGreenThemePromise;
}
-7
View File
@@ -15,10 +15,3 @@
.profile-page__works-scroll .profile-page__list-grid { .profile-page__works-scroll .profile-page__list-grid {
grid-template-columns: repeat(3, 1fr); /* 固定3列,刚好3×3=9个可见 */ grid-template-columns: repeat(3, 1fr); /* 固定3列,刚好3×3=9个可见 */
} }
/* Dashboard uses natural page scrolling instead of a nested works scroller. */
.profile-page--dashboard .profile-page__works-scroll {
max-height: none;
overflow: visible;
scrollbar-width: auto;
}
-12
View File
@@ -189,18 +189,6 @@
gap: 8px; gap: 8px;
} }
.shell-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
}
.shell-icon svg {
width: 1em;
height: 1em;
}
.creator-button, .creator-button,
.member-button, .member-button,
.profile-button, .profile-button,
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -3,8 +3,6 @@
* Falls back gracefully when Notification API is unavailable. * Falls back gracefully when Notification API is unavailable.
*/ */
import { toast } from "../components/toast/toastStore";
let permissionGranted = false; let permissionGranted = false;
async function requestPermission(): Promise<boolean> { async function requestPermission(): Promise<boolean> {
@@ -37,7 +35,9 @@ export function notifyTaskCompleted(label: string, mode: "image" | "video" = "im
// Use the existing toast system for in-app notifications // Use the existing toast system for in-app notifications
function dispatchGenToast(msg: string) { function dispatchGenToast(msg: string) {
toast(msg, "success"); try {
import("../components/toast/toastStore").then((m) => m.toast(msg, "success"));
} catch { /* toast system not loaded */ }
} }
/** Call once on app init to pre-warm permission. */ /** Call once on app init to pre-warm permission. */