feat: refine generation workspace experience

This commit is contained in:
2026-06-08 13:44:03 +08:00
35 changed files with 5249 additions and 350 deletions
+110 -7
View File
@@ -13,7 +13,7 @@ import {
SendOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import "../../styles/pages/agent.css";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import type { WebGenerationPreviewTask } from "../../types";
@@ -73,6 +73,24 @@ const agentModes = [
},
];
const agentModelOptions = [
{ id: "gemini-3.1-pro", label: "Gemini 3.1 Pro" },
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ id: "gpt-4o", label: "GPT-4o" },
];
const thinkingSpeedOptions = [
{ id: "fast", label: "快速" },
{ id: "balanced", label: "均衡" },
{ id: "precise", label: "精细" },
];
const thinkingDepthOptions = [
{ id: "concise", label: "简洁" },
{ id: "standard", label: "标准" },
{ id: "deep", label: "深度" },
];
const quickStarts = ["「新品发布」全链路运营", "「销售日报」自动分析", "「竞品监控」每周报告"];
function getTaskSourceLabel(task: WebGenerationPreviewTask): string | null {
@@ -94,6 +112,21 @@ function AgentPage({
const [prompt, setPrompt] = useState("让 Omni Agent 帮我规划「新品发布会全流程」");
const [isRunning, setIsRunning] = useState(false);
const [notice, setNotice] = useState("选择一个 Agent 模式,输入目标后即可开始。");
const [agentModel, setAgentModel] = useState(agentModelOptions[0].id);
const [thinkingSpeed, setThinkingSpeed] = useState(thinkingSpeedOptions[1].id);
const [thinkingDepth, setThinkingDepth] = useState(thinkingDepthOptions[1].id);
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
const controlsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (controlsRef.current && !controlsRef.current.contains(event.target as Node)) {
setActiveDropdown(null);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const selectedMode = agentModes.find((item) => item.id === activeMode) ?? agentModes[0];
const recentTasks = tasks.slice(0, 3);
@@ -204,15 +237,85 @@ function AgentPage({
/>
<div className="agent-composer__footer">
<div className="agent-composer__controls" aria-label="输入设置">
<div className="agent-composer__controls" aria-label="输入设置" ref={controlsRef}>
<button type="button" className="agent-tool-icon" aria-label="上传附件">
<PaperClipOutlined />
</button>
<button type="button" className="agent-tool-pill">
<ThunderboltOutlined />
<DownOutlined />
</button>
<div className="agent-tool-pills">
<button
type="button"
className={`agent-tool-pill${activeDropdown === "model" ? " is-open" : ""}`}
onClick={() => setActiveDropdown(activeDropdown === "model" ? null : "model")}
>
<RobotOutlined />
{agentModelOptions.find((m) => m.id === agentModel)?.label ?? "模型选择"}
<DownOutlined />
</button>
{activeDropdown === "model" && (
<div className="agent-dropdown">
{agentModelOptions.map((m) => (
<button
key={m.id}
type="button"
className={`agent-dropdown__item${agentModel === m.id ? " is-active" : ""}`}
onClick={() => { setAgentModel(m.id); setActiveDropdown(null); }}
>
{m.label}
</button>
))}
</div>
)}
</div>
<div className="agent-tool-pills">
<button
type="button"
className={`agent-tool-pill${activeDropdown === "speed" ? " is-open" : ""}`}
onClick={() => setActiveDropdown(activeDropdown === "speed" ? null : "speed")}
>
<ThunderboltOutlined />
{thinkingSpeedOptions.find((s) => s.id === thinkingSpeed)?.label ?? "思考速度"}
<DownOutlined />
</button>
{activeDropdown === "speed" && (
<div className="agent-dropdown">
{thinkingSpeedOptions.map((s) => (
<button
key={s.id}
type="button"
className={`agent-dropdown__item${thinkingSpeed === s.id ? " is-active" : ""}`}
onClick={() => { setThinkingSpeed(s.id); setActiveDropdown(null); }}
>
{s.label}
</button>
))}
</div>
)}
</div>
<div className="agent-tool-pills">
<button
type="button"
className={`agent-tool-pill${activeDropdown === "depth" ? " is-open" : ""}`}
onClick={() => setActiveDropdown(activeDropdown === "depth" ? null : "depth")}
>
<ApartmentOutlined />
{thinkingDepthOptions.find((d) => d.id === thinkingDepth)?.label ?? "思考深度"}
<DownOutlined />
</button>
{activeDropdown === "depth" && (
<div className="agent-dropdown">
{thinkingDepthOptions.map((d) => (
<button
key={d.id}
type="button"
className={`agent-dropdown__item${thinkingDepth === d.id ? " is-active" : ""}`}
onClick={() => { setThinkingDepth(d.id); setActiveDropdown(null); }}
>
{d.label}
</button>
))}
</div>
)}
</div>
<button type="button" className="agent-tool-icon" aria-label="工具集">
<AppstoreOutlined />
</button>
+125 -4
View File
@@ -398,6 +398,8 @@ function CanvasPage({
const canvasRef = useRef<HTMLElement>(null);
const videoGenerationInFlightRef = useRef(new Set<string>());
const canvasReferenceUploadPromisesRef = useRef(new Map<string, Promise<string | null>>());
const canvasDragCounterRef = useRef(0);
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
const suppressNextPaneClickRef = useRef(false);
const canvasAutoSaveTimerRef = useRef<number | null>(null);
const canvasAutoSaveIdleHandleRef = useRef<number | null>(null);
@@ -1335,7 +1337,7 @@ function CanvasPage({
model: defaultVideoModel,
aspectRatio: "16:9",
resolution: getDefaultVideoQuality(defaultVideoModel),
duration: "4",
duration: "5",
videoMode: "text2video",
sourceTextNodeId: source.id,
position: {
@@ -1359,7 +1361,7 @@ function CanvasPage({
model: defaultVideoModel,
aspectRatio: "16:9",
resolution: getDefaultVideoQuality(defaultVideoModel),
duration: "4",
duration: "5",
videoMode: "text2video",
sourceTextNodeId: "",
position,
@@ -1415,7 +1417,7 @@ function CanvasPage({
imageUrl = "",
fileName = "本地图片",
position = { x: 0, y: 0 },
options?: { title?: string; sourceImageNodeId?: string }
options?: { title?: string; sourceImageNodeId?: string; sourceTextNodeId?: string }
) => {
const nodeNumber = imageNodeIdRef.current;
imageNodeIdRef.current += 1;
@@ -1429,6 +1431,7 @@ function CanvasPage({
imageSize: getDefaultImageQuality(fallbackVisibleImageModel),
fileName,
sourceImageNodeId: options?.sourceImageNodeId,
sourceTextNodeId: options?.sourceTextNodeId,
position,
size: createCanvasNodeSize("image"),
};
@@ -2034,6 +2037,120 @@ function CanvasPage({
setNodeMenu(null);
};
// ── Canvas drag-and-drop file upload ──────────────────────────────
const handleCanvasDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
canvasDragCounterRef.current += 1;
if (canvasDragCounterRef.current === 1) {
setIsCanvasDragging(true);
}
}, []);
const handleCanvasDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
canvasDragCounterRef.current -= 1;
if (canvasDragCounterRef.current <= 0) {
canvasDragCounterRef.current = 0;
setIsCanvasDragging(false);
}
}, []);
const handleCanvasDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleCanvasDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
canvasDragCounterRef.current = 0;
setIsCanvasDragging(false);
const files = Array.from(e.dataTransfer.files).filter(
(f) => f.type.startsWith("image/")
);
if (files.length === 0) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const dropPosition = {
x: (e.clientX - rect.left - canvasViewport.x) / canvasViewport.zoom,
y: (e.clientY - rect.top - canvasViewport.y) / canvasViewport.zoom,
};
let offsetX = 0;
let offsetY = 0;
for (const file of files) {
const imageUrl = URL.createObjectURL(file);
addImageNode(imageUrl, file.name, {
x: dropPosition.x + offsetX,
y: dropPosition.y + offsetY,
});
offsetX += 60;
offsetY += 60;
}
setContextMenu(null);
setNodeMenu(null);
},
[canvasViewport.x, canvasViewport.y, canvasViewport.zoom, addImageNode],
);
// ── Text composer drag-and-drop ──────────────────────────────────
const [textComposerDragNodeId, setTextComposerDragNodeId] = useState<string | null>(null);
const textComposerDragCounterRef = useRef(0);
const handleTextComposerDragEnter = useCallback((_e: React.DragEvent, nodeId: string) => {
_e.preventDefault();
_e.stopPropagation();
textComposerDragCounterRef.current += 1;
if (textComposerDragCounterRef.current === 1) {
setTextComposerDragNodeId(nodeId);
}
}, []);
const handleTextComposerDragLeave = useCallback((_e: React.DragEvent) => {
_e.preventDefault();
_e.stopPropagation();
textComposerDragCounterRef.current -= 1;
if (textComposerDragCounterRef.current <= 0) {
textComposerDragCounterRef.current = 0;
setTextComposerDragNodeId(null);
}
}, []);
const handleTextComposerDragOver = useCallback((_e: React.DragEvent) => {
_e.preventDefault();
_e.stopPropagation();
}, []);
const handleTextComposerDrop = useCallback(
(e: React.DragEvent, sourceNode: CanvasTextNode) => {
e.preventDefault();
e.stopPropagation();
textComposerDragCounterRef.current = 0;
setTextComposerDragNodeId(null);
const files = Array.from(e.dataTransfer.files).filter(
(f) => f.type.startsWith("image/")
);
if (files.length === 0) return;
let offsetX = 0;
let offsetY = 0;
for (const file of files) {
const imageUrl = URL.createObjectURL(file);
addImageNode(imageUrl, file.name, {
x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX,
y: sourceNode.position.y + offsetY,
}, { sourceTextNodeId: sourceNode.id });
offsetX += 60;
offsetY += 60;
}
},
[addImageNode],
);
const activeTextNode = textNodeMenu
? textNodes.find((node) => node.id === textNodeMenu.nodeId) ?? null
: null;
@@ -3632,7 +3749,7 @@ function CanvasPage({
<WorkspacePageShell title="画布" fullWidth className="canvas-page page-motion">
<div className={`studio-tool-layout studio-tool-layout--no-top studio-tool-layout--no-left studio-tool-layout--no-right studio-tool-layout--canvas${(shouldShowEmptyProjectState || isWaitingForProjects) ? " studio-tool-layout--canvas-empty" : ""}`}>
<section
className={`studio-canvas${pendingLinkPort ? " is-linking" : ""}${(shouldShowEmptyProjectState || isWaitingForProjects) ? " is-empty-projects" : ""}`}
className={`studio-canvas${pendingLinkPort ? " is-linking" : ""}${isCanvasDragging ? " is-canvas-dragging" : ""}${(shouldShowEmptyProjectState || isWaitingForProjects) ? " is-empty-projects" : ""}`}
ref={canvasRef}
onAuxClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasAuxClick}
onContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? (event) => event.preventDefault() : handleCanvasContextMenu}
@@ -3640,6 +3757,10 @@ function CanvasPage({
onDoubleClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDoubleClick}
onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove}
onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel}
onDragEnter={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDragEnter}
onDragOver={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDragOver}
onDragLeave={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDragLeave}
onDrop={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDrop}
style={{
"--canvas-bg-size": `${34 * canvasViewport.zoom}px`,
"--canvas-bg-dot": `${1.35 * canvasViewport.zoom}px`,
-1
View File
@@ -140,7 +140,6 @@ export const videoRatioOptions: CanvasOption[] = [
];
export const videoDurationOptions: CanvasOption[] = [
{ value: "4", label: "4s" },
{ value: "5", label: "5s" },
{ value: "6", label: "6s" },
{ value: "7", label: "7s" },
@@ -16,7 +16,7 @@ import {
ThunderboltOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import StudioToolLayout from "../../components/StudioToolLayout";
@@ -60,6 +60,7 @@ function CharacterMixPage({
const [resultUrl, setResultUrl] = useState<string | null>(null);
const abortRef = useRef(false);
const taskIdRef = useRef<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
return () => {
@@ -235,6 +236,32 @@ function CharacterMixPage({
}
};
const handleDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer?.types?.includes("Files")) setIsDragging(true); };
const handleDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); };
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer?.files?.[0];
if (!file) return;
if (file.type.startsWith("image/")) {
if (characterPreview) URL.revokeObjectURL(characterPreview);
setCharacterFile(file.name);
setCharacterPreview(URL.createObjectURL(file));
const reader = new FileReader();
reader.onload = () => { if (typeof reader.result === "string") setCharacterDataUrl(reader.result); };
reader.readAsDataURL(file);
setNotice(`已选择人物图 ${file.name}`);
} else if (file.type.startsWith("video/")) {
if (videoPreview) URL.revokeObjectURL(videoPreview);
setVideoFile(file.name);
setVideoPreview(URL.createObjectURL(file));
const reader2 = new FileReader();
reader2.onload = () => { if (typeof reader2.result === "string") setVideoDataUrl(reader2.result); };
reader2.readAsDataURL(file);
setNotice(`已选择参考视频 ${file.name}`);
}
};
return (
<section className="image-workbench-page character-mix-page" aria-label="角色迁移">
<header className="image-workbench-topbar">
@@ -294,7 +321,17 @@ function CharacterMixPage({
<StudioToolLayout
noTop
leftPanel={
<>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{ position: "relative" }}
>
{isDragging ? (
<div style={{ position: "absolute", inset: 0, zIndex: 100, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,0.55)", border: "2px dashed var(--primary, #4a9eff)", borderRadius: 12, pointerEvents: "none" }}>
<span style={{ fontSize: 18, color: "#fff", fontWeight: 600 }}></span>
</div>
) : null}
<div className="studio-panel__section">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
@@ -372,7 +409,7 @@ function CharacterMixPage({
</label>
</div>
</div>
</>
</div>
}
canvas={
isCreating ? (
@@ -1,7 +1,9 @@
import { useCallback, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
import { useCallback, useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
import { ApartmentOutlined, DownOutlined, RobotOutlined, ThunderboltOutlined } from "@ant-design/icons";
import "../../styles/pages/dialog-generator.css";
type DialogStyle = "style1" | "style2" | "style3" | "style4";
type GenerationMode = "dialog" | "video";
interface DialogItem {
id: number;
@@ -40,16 +42,68 @@ const textColorOptions = [
{ value: "#00ff88", label: "绿色" },
];
const dialogModelOptions = [
{ id: "gemini", label: "Gemini" },
{ id: "wanxian", label: "万相" },
{ id: "deepseek", label: "DeepSeek" },
];
const thinkingSpeedOptions = [
{ id: "default", label: "默认" },
{ id: "high", label: "高" },
{ id: "ultra", label: "急速" },
];
const thinkingDepthOptions = [
{ id: "default", label: "默认" },
{ id: "strong", label: "强" },
{ id: "extreme", label: "极限" },
];
const videoDurationOptions = [
{ value: "5", label: "5s" },
{ value: "6", label: "6s" },
{ value: "7", label: "7s" },
{ value: "8", label: "8s" },
{ value: "9", label: "9s" },
{ value: "10", label: "10s" },
{ value: "11", label: "11s" },
{ value: "12", label: "12s" },
{ value: "13", label: "13s" },
{ value: "14", label: "14s" },
{ value: "15", label: "15s" },
];
function DialogGeneratorPage() {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const previewRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragState | null>(null);
const nextIdRef = useRef(0);
const controlsRef = useRef<HTMLDivElement>(null);
const [backgroundUrl, setBackgroundUrl] = useState("");
const [dialogs, setDialogs] = useState<DialogItem[]>([]);
const [selectedTextColor, setSelectedTextColor] = useState(textColorOptions[0].value);
const [activeDragId, setActiveDragId] = useState<number | null>(null);
// ── Generation state ──
const [generationMode, setGenerationMode] = useState<GenerationMode>("dialog");
const [dialogModel, setDialogModel] = useState(dialogModelOptions[0].id);
const [thinkingSpeed, setThinkingSpeed] = useState(thinkingSpeedOptions[0].id);
const [thinkingDepth, setThinkingDepth] = useState(thinkingDepthOptions[0].id);
const [videoDuration, setVideoDuration] = useState(videoDurationOptions[0].value);
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (controlsRef.current && !controlsRef.current.contains(event.target as Node)) {
setActiveDropdown(null);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleFile = useCallback((file?: File | null) => {
if (!file || !file.type.startsWith("image/")) return;
const reader = new FileReader();
@@ -195,6 +249,141 @@ function DialogGeneratorPage() {
</div>
</div>
<div className="dialog-generator-section">
<h2></h2>
<div className="dialog-generator-mode-switch" role="radiogroup" aria-label="生成模式">
<button
type="button"
className={`dialog-generator-mode${generationMode === "dialog" ? " is-active" : ""}`}
role="radio"
aria-checked={generationMode === "dialog"}
onClick={() => setGenerationMode("dialog")}
>
</button>
<button
type="button"
className={`dialog-generator-mode${generationMode === "video" ? " is-active" : ""}`}
role="radio"
aria-checked={generationMode === "video"}
onClick={() => setGenerationMode("video")}
>
</button>
</div>
<div className="dialog-generator-controls" ref={controlsRef}>
{generationMode === "dialog" ? (
<>
<div className="dialog-generator-pills">
<button
type="button"
className={`dialog-generator-pill${activeDropdown === "model" ? " is-open" : ""}`}
onClick={() => setActiveDropdown(activeDropdown === "model" ? null : "model")}
>
<RobotOutlined />
{dialogModelOptions.find((m) => m.id === dialogModel)?.label ?? "模型选择"}
<DownOutlined />
</button>
{activeDropdown === "model" && (
<div className="dialog-generator-dropdown">
{dialogModelOptions.map((m) => (
<button
key={m.id}
type="button"
className={`dialog-generator-dropdown__item${dialogModel === m.id ? " is-active" : ""}`}
onClick={() => { setDialogModel(m.id); setActiveDropdown(null); }}
>
{m.label}
</button>
))}
</div>
)}
</div>
<div className="dialog-generator-pills">
<button
type="button"
className={`dialog-generator-pill${activeDropdown === "speed" ? " is-open" : ""}`}
onClick={() => setActiveDropdown(activeDropdown === "speed" ? null : "speed")}
>
<ThunderboltOutlined />
{thinkingSpeedOptions.find((s) => s.id === thinkingSpeed)?.label ?? "思考速度"}
<DownOutlined />
</button>
{activeDropdown === "speed" && (
<div className="dialog-generator-dropdown">
{thinkingSpeedOptions.map((s) => (
<button
key={s.id}
type="button"
className={`dialog-generator-dropdown__item${thinkingSpeed === s.id ? " is-active" : ""}`}
onClick={() => { setThinkingSpeed(s.id); setActiveDropdown(null); }}
>
{s.label}
</button>
))}
</div>
)}
</div>
<div className="dialog-generator-pills">
<button
type="button"
className={`dialog-generator-pill${activeDropdown === "depth" ? " is-open" : ""}`}
onClick={() => setActiveDropdown(activeDropdown === "depth" ? null : "depth")}
>
<ApartmentOutlined />
{thinkingDepthOptions.find((d) => d.id === thinkingDepth)?.label ?? "思考深度"}
<DownOutlined />
</button>
{activeDropdown === "depth" && (
<div className="dialog-generator-dropdown">
{thinkingDepthOptions.map((d) => (
<button
key={d.id}
type="button"
className={`dialog-generator-dropdown__item${thinkingDepth === d.id ? " is-active" : ""}`}
onClick={() => { setThinkingDepth(d.id); setActiveDropdown(null); }}
>
{d.label}
</button>
))}
</div>
)}
</div>
</>
) : (
<div className="dialog-generator-duration">
<span className="dialog-generator-duration__label"></span>
<div className="dialog-generator-duration__options">
{videoDurationOptions.map((opt) => (
<button
key={opt.value}
type="button"
className={`dialog-generator-duration__btn${videoDuration === opt.value ? " is-active" : ""}`}
onClick={() => setVideoDuration(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
)}
</div>
<button
type="button"
className="dialog-generator-run"
disabled={isGenerating}
onClick={() => {
setIsGenerating(true);
// TODO: wire to actual generation API
setTimeout(() => setIsGenerating(false), 2000);
}}
>
{isGenerating ? "生成中..." : "生成"}
</button>
</div>
<button type="button" className="dialog-generator-clear" onClick={() => setDialogs([])}>
</button>
@@ -17,7 +17,7 @@ import {
ThunderboltOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
@@ -97,6 +97,7 @@ function DigitalHumanPage({
const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
return () => {
@@ -148,6 +149,28 @@ function DigitalHumanPage({
setNotice("已取消");
}, [activeTaskId]);
const handleDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer?.types?.includes("Files")) setIsDragging(true); };
const handleDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); };
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer?.files?.[0];
if (!file) return;
if (file.type.startsWith("image/")) {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImageName(file.name);
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
setNotice(`已拖放参考图 ${file.name}`);
} else if (file.type.startsWith("audio/")) {
if (audioPreview) URL.revokeObjectURL(audioPreview);
setAudioName(file.name);
setAudioFile(file);
setAudioPreview(URL.createObjectURL(file));
setNotice(`已拖放音频 ${file.name}`);
}
};
const handleDownloadResult = async () => {
if (!resultVideoUrl || isDownloadingResult) return;
setIsDownloadingResult(true);
@@ -419,7 +442,17 @@ function DigitalHumanPage({
<StudioToolLayout
noTop
leftPanel={
<>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{ position: "relative" }}
>
{isDragging ? (
<div style={{ position: "absolute", inset: 0, zIndex: 100, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,0.55)", border: "2px dashed var(--primary, #4a9eff)", borderRadius: 12, pointerEvents: "none" }}>
<span style={{ fontSize: 18, color: "#fff", fontWeight: 600 }}></span>
</div>
) : null}
<div className="studio-panel__section">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
@@ -492,7 +525,7 @@ function DigitalHumanPage({
{audioPreview ? <audio src={audioPreview} controls className="studio-audio-preview" /> : null}
</div>
</div>
</>
</div>
}
canvas={
resultVideoUrl ? (
@@ -560,7 +593,7 @@ function DigitalHumanPage({
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !imageFile || !audioFile}>
<PlayCircleOutlined />
{isCreating ? "生成中..." : "提交 wan2.2-s2v"}
{isCreating ? "生成中..." : "开始生成"}
</button>
{isCreating && (
<button type="button" className="studio-generate-btn digital-human-cancel-btn" onClick={handleCancel} aria-label="取消生成任务">
+59 -8
View File
@@ -518,6 +518,18 @@ const formatRatioDisplayValue = (value: string) => {
const ratio = value.match(/\d+(?:\.\d+)?\s*[:]\s*\d+(?:\.\d+)?/u)?.[0]?.replace(/\s+/g, "").replace(/:/g, "") ?? "";
return size && ratio ? `${size}\u00a0\u00a0\u00a0${ratio}` : value.replace(/^套图[:]\s*/, "");
};
/** Extract CSS aspect-ratio from a ratio string like "1000x1000px 1:1" -> "1 / 1" */
const parseRatioToAspectCss = (ratioStr: string): string => {
const match = ratioStr.match(/(\d+)\s*[:]\s*(\d+)/u);
if (!match) return "1 / 1";
return `${match[1]} / ${match[2]}`;
};
/** Normalize ratio display string ("1000×1000px 11") to API format ("1:1") */
const normalizeRatioForApi = (ratioStr: string): string => {
const match = ratioStr.match(/(\d+)\s*[:]\s*(\d+)/u);
if (!match) return "1:1";
return `${match[1]}:${match[2]}`;
};
const greatestCommonDivisor = (left: number, right: number): number => {
let a = Math.abs(left);
let b = Math.abs(right);
@@ -869,6 +881,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [videoOutfitVideoFile, setVideoOutfitVideoFile] = useState<File | null>(null);
const [videoOutfitRefFile, setVideoOutfitRefFile] = useState<File | null>(null);
const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false);
const [previewZoom, setPreviewZoom] = useState(1);
const [requirement, setRequirement] = useState("");
const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState<string | null>(null);
const [cloneSettingName, setCloneSettingName] = useState("新建创作");
@@ -1126,6 +1139,32 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.target.value = "";
};
const [isCloneReferenceDragging, setIsCloneReferenceDragging] = useState(false);
const handleCloneReferenceDragOver = (event: DragEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer.types.includes("Files")) {
setIsCloneReferenceDragging(true);
}
};
const handleCloneReferenceDragLeave = (event: DragEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) {
setIsCloneReferenceDragging(false);
}
};
const handleCloneReferenceDrop = (event: DragEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setIsCloneReferenceDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addCloneReferenceImages(files);
};
const updateCloneSetCount = (key: CloneSetCountKey, delta: -1 | 1) => {
setCloneSetCounts((current) => {
const total = Object.values(current).reduce((sum, value) => sum + value, 0);
@@ -1637,6 +1676,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const stamp = Date.now();
for (const countKey of cloneSetCountKeys) {
if (imageAbortRef.current.current) break;
const count = counts[countKey];
for (let i = 0; i < count; i++) {
if (imageAbortRef.current.current) break;
@@ -1646,7 +1686,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const { taskId } = await aiGenerationClient.createImageTask({
model: IMAGE_MODEL,
prompt: fullPrompt,
ratio: pRatio,
ratio: normalizeRatioForApi(pRatio),
quality: pRatio.includes("720") ? "720P" : "1080P",
gridMode: "single",
referenceUrls,
@@ -1683,7 +1723,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
return;
}
setResultFn(generatedUrls);
setStatusFn(generatedUrls.some(Boolean) ? "done" : "idle");
setStatusFn(generatedUrls.some(Boolean) ? "done" : "failed");
} catch (err) {
if (imageAbortRef.current.current) {
setStatusFn("idle");
@@ -1730,7 +1770,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const { taskId } = await aiGenerationClient.createImageTask({
model: IMAGE_MODEL,
prompt,
ratio: pRatio,
ratio: normalizeRatioForApi(pRatio),
quality: pRatio.includes("720") ? "720P" : "1080P",
gridMode: "single",
referenceUrls,
@@ -2059,7 +2099,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
for (let i = 0; i < count; i++) {
clonePreviewCards.push({
id: `${countKey}-${i}`,
src: results[cloneIndex]?.src || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "",
src: productSetResultImages[cloneIndex] || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "",
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
});
cloneIndex++;
@@ -2179,6 +2219,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setOpenCloneBasicSelect={setOpenCloneBasicSelect}
setCloneReferenceMode={setCloneReferenceMode}
handleCloneReferenceUpload={handleCloneReferenceUpload}
isCloneReferenceDragging={isCloneReferenceDragging}
handleCloneReferenceDragOver={handleCloneReferenceDragOver}
handleCloneReferenceDragLeave={handleCloneReferenceDragLeave}
handleCloneReferenceDrop={handleCloneReferenceDrop}
setCloneReplicateLevel={setCloneReplicateLevel}
startCloneSetCountHold={startCloneSetCountHold}
clearCloneSetCountHold={clearCloneSetCountHold}
@@ -2362,6 +2406,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<span>
AI <b></b>
</span>
<div className="clone-ai-preview-zoom">
<button type="button" onClick={() => setPreviewZoom((z) => Math.max(0.25, z - 0.1))} disabled={previewZoom <= 0.25} aria-label="缩小"></button>
<span>{Math.round(previewZoom * 100)}%</span>
<button type="button" onClick={() => setPreviewZoom((z) => Math.min(2, z + 0.1))} disabled={previewZoom >= 2} aria-label="放大">+</button>
</div>
</header>
{cloneOutput === "video" ? (
@@ -2484,8 +2533,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
) : (
<>
{status === "done" ? (
<div className="clone-ai-preview-zoom-wrap" style={{ zoom: previewZoom }}>
<section className="clone-ai-preview-showcase" aria-label="生成结果">
<button type="button" className="clone-ai-main-result" onClick={() => openProductSetPreview(cloneOutput === "set" ? clonePreviewCards[0] : results[0])}>
<button type="button" className="clone-ai-main-result" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} onClick={() => openProductSetPreview(cloneOutput === "set" ? clonePreviewCards[0] : results[0])}>
<img src={productImages[0]?.src ?? (cloneOutput === "set" ? clonePreviewCards[0].src : results[0]?.src ?? "")} alt="上传商品原图" />
<span></span>
</button>
@@ -2493,19 +2543,20 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<div className="clone-ai-result-grid result-reveal">
{cloneOutput === "set" ? (
clonePreviewCards.map((card) => (
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
<button key={card.id} type="button" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} onClick={() => openProductSetPreview(card)}>
<img src={card.src} alt={card.label} />
<span>{card.label}</span>
</button>
))
) : results[0]?.src ? (
<button type="button" onClick={() => openProductSetPreview(results[0])}>
<button type="button" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} onClick={() => openProductSetPreview(results[0])}>
<img src={results[0].src} alt={selectedCloneOutput.label} />
<span>{selectedCloneOutput.label}</span>
</button>
) : null}
</div>
</section>
</div>
) : (
<section className="clone-ai-empty-state" aria-live="polite">
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
@@ -2694,7 +2745,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
) : null}
{isSetTool ? setPreview : isDetail ? detailPreview : isTryOn ? tryOnPreview : isCloneTool ? (cloneOutput === "video" ? (
<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 }}>
<EcommerceVideoWorkspace
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
productImageDataUrls={ecommerceVideoImageDataUrls}
@@ -120,6 +120,7 @@ export default function EcommerceVideoWorkspace({
const [error, setError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
const [flowZoom, setFlowZoom] = useState(1);
const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false });
const actionNoticeTimerRef = useRef<number | null>(null);
@@ -616,6 +617,12 @@ export default function EcommerceVideoWorkspace({
})}
</div>
<div className="ecom-video-flowbar__zoom">
<button type="button" onClick={() => setFlowZoom((z) => Math.max(0.25, z - 0.1))} disabled={flowZoom <= 0.25} aria-label="缩小"></button>
<span>{Math.round(flowZoom * 100)}%</span>
<button type="button" onClick={() => setFlowZoom((z) => Math.min(2, z + 0.1))} disabled={flowZoom >= 2} aria-label="放大">+</button>
</div>
<div className="ecom-video-flowbar__actions">
{onOpenHistory ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" onClick={onOpenHistory} title="生成记录">
@@ -660,9 +667,10 @@ export default function EcommerceVideoWorkspace({
{/* ── Flow canvas ──────────────────────────────────── */}
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
<div style={{ zoom: flowZoom, flexShrink: 0, display: "flex", alignItems: "flex-start", justifyContent: "center", minWidth: "max-content" }}>
{!sourceImage ? (
<div className="ecom-video-empty">
<span></span>
<span>"一键策划"</span>
</div>
) : (
<div className="ecom-video-tree">
@@ -778,6 +786,7 @@ export default function EcommerceVideoWorkspace({
</div>
</div>
)}
</div>
{/* ── Delivery dock ────────────────────────────── */}
{primaryVideo ? (
@@ -7,6 +7,7 @@ import {
ReloadOutlined,
SettingOutlined,
} from "@ant-design/icons";
import { createPortal } from "react-dom";
import type { CSSProperties, ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react";
import { useRef, useState } from "react";
@@ -118,6 +119,10 @@ interface EcommerceClonePanelProps {
setOpenCloneBasicSelect: (value: CloneBasicSelectKey | null) => void;
setCloneReferenceMode: (value: CloneReferenceMode) => void;
handleCloneReferenceUpload: (event: ChangeEvent<HTMLInputElement>) => void;
isCloneReferenceDragging: boolean;
handleCloneReferenceDragOver: (event: DragEvent<HTMLButtonElement>) => void;
handleCloneReferenceDragLeave: (event: DragEvent<HTMLButtonElement>) => void;
handleCloneReferenceDrop: (event: DragEvent<HTMLButtonElement>) => void;
setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void;
startCloneSetCountHold: (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => void;
clearCloneSetCountHold: () => void;
@@ -186,6 +191,10 @@ export default function EcommerceClonePanel({
setOpenCloneBasicSelect,
setCloneReferenceMode,
handleCloneReferenceUpload,
isCloneReferenceDragging,
handleCloneReferenceDragOver,
handleCloneReferenceDragLeave,
handleCloneReferenceDrop,
setCloneReplicateLevel,
startCloneSetCountHold,
clearCloneSetCountHold,
@@ -210,6 +219,14 @@ export default function EcommerceClonePanel({
const videoOutfitRefRef = useRef<HTMLInputElement>(null);
const [videoOutfitVideoUrl, setVideoOutfitVideoUrl] = useState<string | null>(null);
const [videoOutfitRefUrl, setVideoOutfitRefUrl] = useState<string | null>(null);
const [zoomImage, setZoomImage] = useState<{ src: string; x: number; y: number } | null>(null);
const handleFileMouseEnter = (src: string, event: React.MouseEvent<HTMLElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
setZoomImage({ src, x: rect.left + rect.width / 2, y: rect.top });
};
const handleFileMouseLeave = () => setZoomImage(null);
const handleVideoOutfitVideoChange = () => {
const file = videoOutfitVideoRef.current?.files?.[0] || null;
@@ -383,23 +400,44 @@ export default function EcommerceClonePanel({
</button>
</div>
{cloneReferenceMode === "upload" ? (
<button type="button" className="clone-ai-replicate-upload" onClick={() => cloneReferenceInputRef.current?.click()}>
<span>
<CloudUploadOutlined />
<span className="clone-ai-replicate-upload-text"></span>
</span>
<em>{cloneReferenceImages.length ? `已选 ${cloneReferenceImages.length}/${maxCloneReferenceImages}` : `最多 ${maxCloneReferenceImages}`}</em>
<button
type="button"
className={`clone-ai-replicate-upload${isCloneReferenceDragging ? " is-dragging" : ""}${cloneReferenceImages.length ? " has-files" : ""}`}
onClick={() => cloneReferenceInputRef.current?.click()}
onDragOver={handleCloneReferenceDragOver}
onDragLeave={handleCloneReferenceDragLeave}
onDrop={handleCloneReferenceDrop}
>
{cloneReferenceImages.length ? (
<div className="clone-ai-replicate-preview" aria-hidden="true">
{cloneReferenceImages.slice(0, 4).map((item) => (
<figure key={item.id}>
<img src={item.src} alt="" />
<span className="uploaded-image-zoom">
<>
<div className="clone-ai-replicate-files">
{cloneReferenceImages.map((item) => (
<figure
key={item.id}
className="clone-ai-replicate-file"
onMouseEnter={(e) => handleFileMouseEnter(item.src, e)}
onMouseLeave={handleFileMouseLeave}
>
<img src={item.src} alt="" />
</span>
</figure>
))}
{cloneReferenceImages.length > 4 ? <b>+{cloneReferenceImages.length - 4}</b> : null}
</figure>
))}
</div>
<span className="clone-ai-replicate-add-more">
<CloudUploadOutlined />
</span>
</>
) : (
<span>
<CloudUploadOutlined />
<span className="clone-ai-replicate-upload-text"></span>
</span>
)}
<em>{cloneReferenceImages.length ? `已选 ${cloneReferenceImages.length}/${maxCloneReferenceImages}` : `最多 ${maxCloneReferenceImages}`}</em>
{isCloneReferenceDragging ? (
<div className="clone-ai-replicate-upload-overlay">
<CloudUploadOutlined />
<span></span>
</div>
) : null}
</button>
@@ -754,6 +792,18 @@ export default function EcommerceClonePanel({
</button>
) : null}
</div>
{zoomImage
? createPortal(
<div
className="clone-ai-zoom-portal"
style={{ left: zoomImage.x, top: zoomImage.y } as CSSProperties}
onMouseLeave={handleFileMouseLeave}
>
<img src={zoomImage.src} alt="" />
</div>,
document.body,
)
: null}
</>
);
}
@@ -23,7 +23,7 @@ import {
TableOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
@@ -157,6 +157,8 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
const [downloadingResultUrl, setDownloadingResultUrl] = useState<string | null>(null);
const [savingAssetResultUrl, setSavingAssetResultUrl] = useState<string | null>(null);
const [generationError, setGenerationError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isCameraDragging, setIsCameraDragging] = useState(false);
const abortRef = useRef(false);
const taskIdRef = useRef<string | null>(null);
const keepaliveRestoredRef = useRef(false);
@@ -249,6 +251,37 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
event.target.value = "";
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
const files = Array.from(event.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (!files.length) return;
const selectedFiles = mode === 'blend' ? files : files.slice(0, 1);
selectedFiles.forEach((file) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') return;
setReferenceImages((current) => (mode === 'blend' ? [...current, reader.result as string] : [reader.result as string]));
setStatus(mode === 'blend' ? `已追加 ${file.name}` : `已导入 ${file.name}`);
};
reader.readAsDataURL(file);
});
};
const handleAddUrl = () => {
const nextUrl = imageUrlInput.trim();
if (!nextUrl) return;
@@ -281,9 +314,15 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
event.target.value = "";
};
const handleInpaintDrop = (event: React.DragEvent) => {
event.preventDefault();
const file = Array.from(event.dataTransfer.files).find((f) => f.type.startsWith("image/"));
const [isInpaintDragging, setIsInpaintDragging] = useState(false);
const handleInpaintDragOver = (e: DragEvent<HTMLElement>) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsInpaintDragging(true); };
const handleInpaintDragLeave = (e: DragEvent<HTMLElement>) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsInpaintDragging(false); };
const handleInpaintDrop = (e: DragEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setIsInpaintDragging(false);
const file = Array.from(e.dataTransfer.files).find((f) => f.type.startsWith("image/"));
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
@@ -322,7 +361,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
return;
}
if (!hasMask) {
setStatus("请先编辑遮罩,涂抹需要重绘的区域");
setStatus("请先编辑页面,涂抹需要重绘的区域");
return;
}
if (generating) return;
@@ -384,6 +423,33 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
event.target.value = "";
};
const handleCameraDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsCameraDragging(true);
};
const handleCameraDragLeave = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsCameraDragging(false);
};
const handleCameraDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsCameraDragging(false);
const file = Array.from(event.dataTransfer.files).find((f) => f.type.startsWith('image/'));
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') return;
setCameraImage(reader.result);
setStatus(`已导入镜头参考图 ${file.name}`);
};
reader.readAsDataURL(file);
};
const handleAddCameraUrl = () => {
const nextUrl = cameraUrlInput.trim();
if (!nextUrl) return;
@@ -715,7 +781,13 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
accept="image/png,image/jpeg,image/webp"
onChange={handleInpaintFileChange}
/>
<div className="image-workbench-upload-shell">
<div
className={`image-workbench-upload-shell${isInpaintDragging ? " is-dragging" : ""}`}
onDragOver={handleInpaintDragOver}
onDragLeave={handleInpaintDragLeave}
onDrop={handleInpaintDrop}
>
{isInpaintDragging ? <div className="image-workbench-upload-drop-overlay"><span></span></div> : null}
<button type="button" className="image-workbench-upload" onClick={() => inpaintFileInputRef.current?.click()}>
{inpaintImage ? <img src={inpaintImage} alt="" /> : <FileImageOutlined />}
<strong>{inpaintImage ? "更换原图" : "选择图片"}</strong>
@@ -808,7 +880,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
<img src={inpaintResultImages[0]} alt="重绘结果" style={{ maxWidth: "95%", maxHeight: "95%", borderRadius: 8, objectFit: "contain" }} />
<div className="image-workbench-inpaint-bottom-bar">
<button type="button" className="image-workbench-inpaint-edit-btn" onClick={() => { setInpaintResultImages([]); setIsMaskEditing(true); setInpaintTool("brush"); setCanvasInitCounter((c) => c + 1); }}>
<HighlightOutlined />
<HighlightOutlined />
</button>
{renderResultActions(inpaintResultImages[0], 0)}
{inpaintResultImages.length > 1 && (
@@ -864,7 +936,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
<div className="image-workbench-inpaint-bottom-bar">
{!isMaskEditing && (
<button type="button" className="image-workbench-inpaint-edit-btn" onClick={() => { setInpaintTool("brush"); setIsMaskEditing(true); }}>
<HighlightOutlined /> {hasMask ? "重新编辑遮罩" : "编辑遮罩"}
<HighlightOutlined /> {hasMask ? "重新编辑页面" : "编辑页面"}
</button>
)}
<span className="image-workbench-inpaint-zoom-controls">
@@ -877,11 +949,13 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
) : (
<button
type="button"
className="image-workbench-empty image-workbench-empty--button"
className={`image-workbench-empty image-workbench-empty--button${isInpaintDragging ? " is-dragging" : ""}`}
onClick={() => inpaintFileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDragOver={handleInpaintDragOver}
onDragLeave={handleInpaintDragLeave}
onDrop={handleInpaintDrop}
>
{isInpaintDragging ? <span className="image-workbench-upload-drop-overlay" style={{ borderRadius: "var(--radius-sm)" }}><span></span></span> : null}
<FileImageOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
@@ -889,36 +963,6 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
)}
</section>
<aside className="image-workbench-panel image-workbench-panel--right">
<section className="image-workbench-right-note">
<div className="image-workbench-section-title">
<h3></h3>
<span>{isMaskEditing ? (inpaintTool === "eraser" ? "橡皮中" : "画笔中") : hasMask ? "已保存" : "待编辑"}</span>
</div>
<span>{inpaintImage ? (hasMask ? "遮罩区域已标记,可开始重绘。" : "点击画布上的「编辑遮罩」开始涂抹。") : "上传原图后可编辑遮罩"}</span>
</section>
<section className="image-workbench-right-note">
<div className="image-workbench-section-title">
<h3></h3>
<span>{inpaintResultImages.length > 0 ? `${inpaintResultImages.length}` : "待生成"}</span>
</div>
{inpaintResultImages.length > 0 ? (
<div className="image-workbench-result-grid">
{inpaintResultImages.map((url, i) => (
<div key={url} className="image-workbench-result-card">
<a href={url} target="_blank" rel="noopener noreferrer" className="image-workbench-result-thumb">
<img src={url} alt={`重绘结果 ${i + 1}`} />
</a>
{renderResultActions(url, i)}
</div>
))}
</div>
) : (
<span></span>
)}
</section>
</aside>
</main>
) : activeTool === "camera" ? (
<main className="image-workbench-layout image-workbench-layout--camera">
@@ -934,7 +978,13 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
accept="image/png,image/jpeg,image/webp"
onChange={handleCameraFileChange}
/>
<div className="image-workbench-upload-shell">
<div
className={`image-workbench-upload-shell${isCameraDragging ? " is-dragging" : ""}`}
onDragOver={handleCameraDragOver}
onDragLeave={handleCameraDragLeave}
onDrop={handleCameraDrop}
>
{isCameraDragging && <div className="image-workbench-upload-overlay"></div>}
<button type="button" className="image-workbench-upload" onClick={() => cameraFileInputRef.current?.click()}>
{cameraImage ? <img src={cameraImage} alt="" /> : <FileImageOutlined />}
<strong>{cameraImage ? "更换参考图" : "导入参考图"}</strong>
@@ -1213,7 +1263,13 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</button>
</div>
) : (
<div className="image-workbench-upload-shell">
<div
className={`image-workbench-upload-shell${isDragging ? " is-dragging" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragging && <div className="image-workbench-upload-overlay"></div>}
<button type="button" className="image-workbench-upload" onClick={() => fileInputRef.current?.click()}>
{referenceImage ? <img src={referenceImage} alt="" /> : <FileImageOutlined />}
<strong>{referenceImage ? "更换参考图" : "导入参考图"}</strong>
@@ -1244,6 +1300,33 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</div>
</section>
<section className="image-workbench-control-card">
<h3></h3>
<span className="image-workbench-field-label"></span>
<div className="image-workbench-segmented">
{(["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)}>
{s}
</button>
))}
</div>
<div className="image-workbench-count">
<span></span>
<div>
{([1, 2, 3, 4] as OutputCount[]).map((count) => (
<button
key={count}
type="button"
className={outputCount === count ? "is-active" : ""}
onClick={() => setOutputCount(count)}
>
{count}
</button>
))}
</div>
</div>
</section>
<section className="image-workbench-control-card image-workbench-prompt">
<h3></h3>
<textarea
@@ -1316,34 +1399,6 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
)}
</section>
<aside className="image-workbench-panel image-workbench-panel--right">
<section className="image-workbench-control-card">
<h3></h3>
<span className="image-workbench-field-label"></span>
<div className="image-workbench-segmented">
{OUTPUT_SIZE_OPTIONS.map((s) => (
<button key={s} type="button" className={outputSize === s ? "is-active" : ""} onClick={() => setOutputSize(s)}>
{s}
</button>
))}
</div>
<div className="image-workbench-count">
<span></span>
<div>
{OUTPUT_COUNT_OPTIONS.map((count) => (
<button
key={count}
type="button"
className={outputCount === count ? "is-active" : ""}
onClick={() => setOutputCount(count)}
>
{count}
</button>
))}
</div>
</div>
</section>
</aside>
</main>
)}
+157 -38
View File
@@ -12,8 +12,8 @@ import {
ThunderboltOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import "../../styles/pages/more.css";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
@@ -29,6 +29,8 @@ interface MoreTool {
id: string;
title: string;
text: string;
useCase: string;
tags: string[];
icon: ReactNode;
category: ToolCategory;
target?: WebViewKey;
@@ -38,23 +40,77 @@ interface MoreTool {
featured?: boolean;
}
type CompareScene =
| "workbench"
| "inpaint"
| "camera"
| "upscale"
| "watermark"
| "dialog"
| "subtitle"
| "digital-human"
| "character"
| "avatar";
const toolCompareScenes: Record<string, CompareScene> = {
workbench: "workbench",
inpaint: "inpaint",
camera: "camera",
upscale: "upscale",
watermarkRemoval: "watermark",
dialogGenerator: "dialog",
subtitleRemoval: "subtitle",
digitalHuman: "digital-human",
characterMix: "character",
avatarConsole: "avatar",
};
function ToolComparePanel({ scene }: { scene: CompareScene }) {
return (
<span className={`more-card__compare more-card__compare--${scene}`} aria-hidden="true">
<span className="more-card__compare-labels">
<span>Before</span>
<span>After</span>
</span>
<span className="more-card__compare-stage">
<span className="more-card__compare-side more-card__compare-side--before">
<span className="more-card__scene-subject" />
<span className="more-card__scene-detail" />
<span className="more-card__scene-overlay" />
</span>
<span className="more-card__compare-divider">
<span />
</span>
<span className="more-card__compare-side more-card__compare-side--after">
<span className="more-card__scene-subject" />
<span className="more-card__scene-detail" />
<span className="more-card__scene-overlay" />
</span>
</span>
</span>
);
}
const tools: MoreTool[] = [
{ id: "workbench", title: "图片工作台", text: "融合、修复、局部增强", icon: <EditOutlined />, category: "image", imageTool: "workbench", ready: true, featured: true },
{ id: "inpaint", title: "局部重绘", text: "遮罩区域重新生成", icon: <HighlightOutlined />, category: "image", imageTool: "inpaint", ready: true },
{ id: "camera", title: "镜头实验室", text: "角度、焦段和机位控制", icon: <CameraOutlined />, category: "image", imageTool: "camera", ready: true },
{ id: "upscale", title: "分辨率提升", text: "图片视频高清超分", icon: <ColumnWidthOutlined />, category: "image", target: "resolutionUpscale", ready: true },
{ id: "watermarkRemoval", title: "去水印", text: "AI 智能去除图片水印文字", icon: <DeleteOutlined />, category: "image", target: "watermarkRemoval", ready: true },
{ id: "dialogGenerator", title: "交互式对话框生成器", text: "上传背景图,添加可拖拽编辑的对话框", icon: <MessageOutlined />, category: "image", target: "dialogGenerator", ready: true },
{ id: "subtitleRemoval", title: "字幕去除", text: "AI 智能擦除视频字幕", icon: <DeleteOutlined />, category: "video", target: "subtitleRemoval", ready: true },
{ id: "digitalHuman", title: "数字人", text: "参考人像音频生成口播视频", icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true },
{ id: "characterMix", title: "角色迁移", text: "人物图迁移到参考视频动作", icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true },
{ id: "avatarConsole", title: "数字人控制台", text: "形象、播报、互动与接入配置", icon: <DashboardOutlined />, category: "video", target: "avatarConsole", ready: true },
{ id: "workbench", title: "图片工作台", text: "融合、修复、局部增强", useCase: "适合商品图精修、创意合成和局部画面重做", tags: ["热门", "一站式", "商品图"], icon: <EditOutlined />, category: "image", imageTool: "workbench", ready: true, featured: true },
{ id: "inpaint", title: "局部重绘", text: "修掉瑕疵、替换物体、重做局部画面", useCase: "适合快速处理商品瑕疵、人物细节和背景杂物", tags: ["新手推荐", "精修"], icon: <HighlightOutlined />, category: "image", imageTool: "inpaint", ready: true },
{ id: "camera", title: "镜头实验室", text: "快速生成俯拍、特写、广角等商业镜头", useCase: "适合做产品主图、种草图和不同机位方案", tags: ["电商常用", "镜头"], icon: <CameraOutlined />, category: "image", imageTool: "camera", ready: true },
{ id: "upscale", title: "分辨率提升", text: "把低清图片视频提升到可交付质感", useCase: "适合修复旧素材、放大商品图和增强短视频清晰度", tags: ["高清", "交付前"], icon: <ColumnWidthOutlined />, category: "image", target: "resolutionUpscale", ready: true },
{ id: "watermarkRemoval", title: "去水印", text: "智能去除图片水印文字和遮挡元素", useCase: "适合整理素材、清理参考图和恢复画面干净度", tags: ["素材清理", "高频"], icon: <DeleteOutlined />, category: "image", target: "watermarkRemoval", ready: true },
{ id: "dialogGenerator", title: "交互式对话框生成器", text: "上传背景图,快速制作可拖拽编辑的对话框", useCase: "适合剧情海报、社媒截图和角色对白设计", tags: ["内容创作", "可编辑"], icon: <MessageOutlined />, category: "image", target: "dialogGenerator", ready: true },
{ id: "subtitleRemoval", title: "字幕去除", text: "擦除视频字幕,让画面重新变干净", useCase: "适合二创前素材整理、短视频重剪和画面修复", tags: ["视频增强", "素材清理"], icon: <DeleteOutlined />, category: "video", target: "subtitleRemoval", ready: true },
{ id: "digitalHuman", title: "数字人", text: "用一张人像音频生成口播视频", useCase: "适合品牌讲解、课程口播和带货短视频", tags: ["热门", "口播", "视频"], icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true },
{ id: "characterMix", title: "角色迁移", text: "人物图迁移到参考视频动作", useCase: "适合角色短片、动作复刻和虚拟人内容生产", tags: ["角色视频", "动作"], icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true },
{ id: "avatarConsole", title: "数字人控制台", text: "管理形象、播报、互动与接入配置", useCase: "适合持续运营数字人、配置品牌形象和复用口播模板", tags: ["运营台", "企业"], icon: <DashboardOutlined />, category: "video", target: "avatarConsole", ready: true },
];
interface FeaturedTool {
id: string;
title: string;
desc: string;
kicker: string;
steps: string[];
outcome: string;
icon: ReactNode;
imageTool?: WebImageWorkbenchTool;
target?: WebViewKey;
@@ -66,7 +122,10 @@ const featuredTools: FeaturedTool[] = [
{
id: "workbench",
title: "图片工作台",
desc: "融合、修复、局部增强 — 一站式图片创作",
desc: "从一张素材开始,完成精修、合成和二次创作",
kicker: "图片精修工作流",
steps: ["上传素材", "局部修复", "高清导出"],
outcome: "适合商品图、海报图和创意视觉",
icon: <EditOutlined />,
imageTool: "workbench",
category: "image",
@@ -75,7 +134,10 @@ const featuredTools: FeaturedTool[] = [
{
id: "digitalHuman",
title: "数字人",
desc: "参考人像音频,一键生成口播视频",
desc: "参考人像音频,快速生成可交付口播视频",
kicker: "口播视频工作流",
steps: ["选择人像", "上传音频", "生成视频"],
outcome: "适合品牌讲解、课程和带货短视频",
icon: <CustomerServiceOutlined />,
target: "digitalHuman",
category: "video",
@@ -137,6 +199,18 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
}
}, [onOpenImageTool, onSelectView]);
const openFeaturedTool = useCallback((tool: FeaturedTool) => {
pushRecentToolId(tool.id);
setRecentIds(getRecentToolIds());
if (tool.imageTool && onOpenImageTool) {
onOpenImageTool(tool.imageTool);
return;
}
if (tool.target && onSelectView) {
onSelectView(tool.target);
}
}, [onOpenImageTool, onSelectView]);
const filteredTools = tools.filter((t) => {
if (t.featured) return false;
if (filter === "all") return true;
@@ -144,12 +218,16 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
return t.category === filter;
});
const toolById = useMemo(() => new Map(tools.map((tool) => [tool.id, tool])), []);
const recentTools = recentIds.reduce<MoreTool[]>((acc, id) => {
const tool = toolById.get(id);
if (tool?.ready) acc.push(tool);
return acc;
}, []);
const filterCounts: Record<FilterKey, number> = {
all: tools.filter((t) => !t.featured).length,
image: tools.filter((t) => !t.featured && t.category === "image").length,
video: tools.filter((t) => !t.featured && t.category === "video").length,
upcoming: tools.filter((t) => !t.featured && !t.ready).length,
};
const recentTools = recentIds
.map((id) => tools.find((t) => t.id === id))
.filter((t): t is MoreTool => Boolean(t) && (t?.ready ?? false));
const groupedTools = filteredTools.reduce<Record<ToolCategory, MoreTool[]>>((acc, t) => {
if (!acc[t.category]) acc[t.category] = [];
@@ -157,19 +235,31 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
return acc;
}, {} as Record<ToolCategory, MoreTool[]>);
const activeFilterLabel = filters.find((item) => item.key === filter)?.label ?? "全部";
const hasGroupedTools = (["image", "video"] as ToolCategory[]).some((cat) => groupedTools[cat]?.length);
return (
<div className="more-page-v2">
<header className="more-page-v2__header">
<h1></h1>
<nav className="more-page-v2__filters">
<div className="more-page-v2__title-group">
<span className="more-page-v2__eyebrow">AI Tool Hub</span>
<h1></h1>
</div>
<div className="more-page-v2__header-meta" aria-label="工具盒概览">
<span>{tools.filter((tool) => tool.ready).length} </span>
<span>{featuredTools.length} </span>
</div>
<nav className="more-page-v2__filters" aria-label="工具分类筛选">
{filters.map((f) => (
<button
key={f.key}
type="button"
className={filter === f.key ? "is-active" : ""}
aria-pressed={filter === f.key}
onClick={() => setFilter(f.key)}
>
{f.label}
<span>{f.label}</span>
<em>{filterCounts[f.key]}</em>
</button>
))}
</nav>
@@ -180,6 +270,7 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
<section className="more-page-v2__section">
<h2 className="more-page-v2__section-title">
<ClockCircleOutlined /> 使
<span>使</span>
</h2>
<div className="more-page-v2__recent-row">
{recentTools.map((tool) => (
@@ -187,10 +278,14 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
key={tool.id}
type="button"
className="more-card more-card--recent"
aria-label={`打开最近使用工具:${tool.title}`}
onClick={() => openTool(tool)}
>
<span className="more-card__icon">{tool.icon}</span>
<strong>{tool.title}</strong>
<span className="more-card__recent-body">
<strong>{tool.title}</strong>
<small>{categoryLabels[tool.category]}</small>
</span>
</button>
))}
</div>
@@ -208,23 +303,22 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
key={tool.id}
type="button"
className="more-card more-card--featured"
style={{ "--card-gradient": tool.gradient } as React.CSSProperties}
onClick={() => {
pushRecentToolId(tool.id);
setRecentIds(getRecentToolIds());
if (tool.imageTool && onOpenImageTool) {
onOpenImageTool(tool.imageTool);
return;
}
if (tool.target && onSelectView) {
onSelectView(tool.target);
}
}}
style={{ "--card-gradient": tool.gradient } as CSSProperties}
aria-label={`打开核心工具:${tool.title}${tool.desc}`}
onClick={() => openFeaturedTool(tool)}
>
<span className="more-card__featured-icon">{tool.icon}</span>
<div className="more-card__featured-body">
<span className="more-card__featured-kicker">{tool.kicker}</span>
<strong>{tool.title}</strong>
<span>{tool.desc}</span>
<ToolComparePanel scene={toolCompareScenes[tool.id]} />
<span className="more-card__featured-desc">{tool.desc}</span>
<span className="more-card__steps" aria-hidden="true">
{tool.steps.map((step) => (
<span key={step}>{step}</span>
))}
</span>
<span className="more-card__outcome">{tool.outcome}</span>
<span className="more-card__cta">使 </span>
</div>
</button>
@@ -233,13 +327,14 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
</section>
)}
{(["image", "video", "template"] as ToolCategory[]).map((cat) => {
{(["image", "video"] as ToolCategory[]).map((cat) => {
const group = groupedTools[cat];
if (!group || group.length === 0) return null;
return (
<section key={cat} className="more-page-v2__section">
<h2 className="more-page-v2__section-title">
{categoryIcons[cat]} {categoryLabels[cat]}
<span>{group.length}</span>
</h2>
<div className="more-page-v2__grid">
{group.map((tool) => (
@@ -247,12 +342,21 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
key={tool.id}
type="button"
className={`more-card${tool.ready ? " more-card--ready" : " more-card--pending"}`}
aria-label={tool.ready ? `打开工具:${tool.title}${tool.text}` : `${tool.title}暂未开放`}
onClick={() => openTool(tool)}
disabled={!tool.ready}
>
<span className="more-card__icon">{tool.icon}</span>
<span className="more-card__topline">
{tool.tags.slice(0, 2).map((tag) => (
<span key={tag}>{tag}</span>
))}
</span>
<strong>{tool.title}</strong>
<ToolComparePanel scene={toolCompareScenes[tool.id]} />
<span className="more-card__desc">{tool.text}</span>
<span className="more-card__use-case">{tool.useCase}</span>
<span className="more-card__action"> </span>
{tool.badge && <span className="more-card__badge">{tool.badge}</span>}
</button>
))}
@@ -260,6 +364,21 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
</section>
);
})}
{!hasGroupedTools && (
<section className="more-page-v2__section">
<div className="more-page-v2__empty">
<span className="more-page-v2__empty-icon">
<ClockCircleOutlined />
</span>
<strong>{activeFilterLabel}</strong>
<p>线</p>
<button type="button" className="more-page-v2__empty-action" onClick={() => setFilter("all")}>
</button>
</div>
</section>
)}
</div>
</div>
);
+260 -5
View File
@@ -4,6 +4,7 @@ import {
CheckCircleFilled,
CloseOutlined,
DeleteOutlined,
DownloadOutlined,
EditOutlined,
FileImageOutlined,
FolderOpenOutlined,
@@ -17,7 +18,8 @@ import {
ShareAltOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent } from "react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import "../../styles/pages/profile.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { assetClient } from "../../api/assetClient";
@@ -27,6 +29,7 @@ import { isServerRequestError } from "../../api/serverConnection";
import { ossAssets } from "../../data/ossAssets";
import type { WebAuthMode, WebGenerationPreviewTask, WebProjectSummary, WebUsageSummary, WebUserSession } from "../../types";
import type { SavedAssetItem } from "../assets/localAssetStore";
import { downloadResultAsset } from "../workbench/workbenchDownload";
interface ProfilePageProps {
session: WebUserSession | null;
@@ -42,11 +45,16 @@ interface ProfilePageProps {
onOpenWorkbench: () => void;
onOpenCommunity: () => void;
onDeleteProject?: (project: WebProjectSummary) => void;
onOpenProject?: (project: WebProjectSummary) => void;
onRemoveWork?: (task: WebGenerationPreviewTask) => void;
}
type AuthTab = "password" | "email" | "phone";
type ProfilePanel = "works" | "projects" | "assets" | "community";
type AccountPanel = "credits" | "tasks";
type ProfileDetailSelection =
| { kind: "work"; item: WebGenerationPreviewTask }
| { kind: "asset"; item: SavedAssetItem };
const PROFILE_LOCAL_STORAGE_PREFIX = "omniai-web-profile-ui";
const AUTH_LOGO_URL = ossAssets.brand.logo;
@@ -211,6 +219,8 @@ function ProfilePage({
onOpenWorkbench,
onOpenCommunity,
onDeleteProject,
onOpenProject,
onRemoveWork,
}: ProfilePageProps) {
const isLoggedIn = Boolean(session);
const userId = session?.user.id;
@@ -254,6 +264,10 @@ function ProfilePage({
const [bioEditBackup, setBioEditBackup] = useState("");
const [bioStatusNotice, setBioStatusNotice] = useState<string | null>(null);
const [bannerUrl, setBannerUrl] = useState(() => session?.user.backgroundUrl || readLocalProfileValue(userId, "background"));
const [detailSelection, setDetailSelection] = useState<ProfileDetailSelection | null>(null);
const [detailNotice, setDetailNotice] = useState<string | null>(null);
const [isDeletingDetail, setIsDeletingDetail] = useState(false);
const [isDownloadingDetail, setIsDownloadingDetail] = useState(false);
const completedTasks = useMemo(
() => tasks.filter((task) => task.status === "completed"),
@@ -642,6 +656,217 @@ function ProfilePage({
setBioStatusNotice(null);
};
const handleInteractiveCardKeyDown = (event: KeyboardEvent<HTMLElement>, action: () => void) => {
if (event.target !== event.currentTarget) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
action();
};
const openDetailSelection = (selection: ProfileDetailSelection) => {
setDetailNotice(null);
setIsDeletingDetail(false);
setIsDownloadingDetail(false);
setDetailSelection(selection);
};
const closeDetailSelection = () => {
if (isDeletingDetail || isDownloadingDetail) return;
setDetailSelection(null);
setDetailNotice(null);
};
useEffect(() => {
if (!detailSelection) return undefined;
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") closeDetailSelection();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [detailSelection, isDeletingDetail, isDownloadingDetail]);
useEffect(() => {
if (!detailSelection || typeof document === "undefined") return undefined;
const { body, documentElement } = document;
const previousBodyOverflow = body.style.overflow;
const previousRootOverscroll = documentElement.style.overscrollBehavior;
body.style.overflow = "hidden";
documentElement.style.overscrollBehavior = "contain";
return () => {
body.style.overflow = previousBodyOverflow;
documentElement.style.overscrollBehavior = previousRootOverscroll;
};
}, [detailSelection]);
const handleDownloadSelectedDetail = async () => {
if (!detailSelection || isDownloadingDetail) return;
const url =
detailSelection.kind === "work"
? detailSelection.item.outputUrl
: detailSelection.item.imageUrl || detailSelection.item.url || "";
if (!url) {
setDetailNotice("暂无可下载的媒体文件");
return;
}
const isVideo =
detailSelection.kind === "work"
? detailSelection.item.type === "video"
: detailSelection.item.type === "video" || /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(url);
const taskId = detailSelection.kind === "work" ? detailSelection.item.id : detailSelection.item.sourceTaskId || undefined;
const name = detailSelection.kind === "work" ? detailSelection.item.title : detailSelection.item.name;
setIsDownloadingDetail(true);
setDetailNotice("正在准备下载...");
try {
const status = await downloadResultAsset(url, name, isVideo, taskId);
setDetailNotice(status === "saved" ? "已保存到本地" : "已开始保存到本地");
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
setDetailNotice("已取消下载");
} else {
setDetailNotice(error instanceof Error ? error.message : "下载失败,请稍后重试");
}
} finally {
setIsDownloadingDetail(false);
}
};
const handleDeleteSelectedDetail = async () => {
if (!detailSelection || isDeletingDetail) return;
if (detailSelection.kind === "work") {
onRemoveWork?.(detailSelection.item);
setDetailNotice("已从当前代表作列表移除");
setDetailSelection(null);
return;
}
setIsDeletingDetail(true);
setDetailNotice(null);
try {
await assetClient.delete(detailSelection.item.id, { cleanupUserData: true });
setSavedAssets((current) => current.filter((asset) => asset.id !== detailSelection.item.id));
setDetailSelection(null);
setAssetNotice(`已删除 ${detailSelection.item.name}`);
} catch (error) {
setDetailNotice(formatProfileLoadError(error, "资产删除失败"));
} finally {
setIsDeletingDetail(false);
}
};
const renderDetailMedia = (url: string | null | undefined, type: "image" | "video" | "asset") => {
const mediaUrl = typeof url === "string" ? url.trim() : "";
const isVideoPreview = type === "video" || /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(mediaUrl);
if (!mediaUrl) {
return (
<div className="profile-page__detail-placeholder">
{type === "video" ? <PlayCircleOutlined /> : <FileImageOutlined />}
<span></span>
</div>
);
}
return isVideoPreview ? (
<video className="profile-page__detail-media" src={mediaUrl} controls playsInline />
) : (
<img className="profile-page__detail-media" src={mediaUrl} alt="" />
);
};
const renderDetailModal = () => {
if (!detailSelection) return null;
const modalTarget = typeof document === "undefined" ? null : document.querySelector(".web-shell") || document.body;
if (!modalTarget) return null;
const isWork = detailSelection.kind === "work";
const title = isWork ? detailSelection.item.title : detailSelection.item.name;
const description = isWork ? detailSelection.item.prompt : detailSelection.item.description;
const mediaUrl = isWork ? detailSelection.item.outputUrl : detailSelection.item.imageUrl || detailSelection.item.url;
const mediaType = isWork
? detailSelection.item.type === "video" ? "video" : "image"
: detailSelection.item.type === "video" ? "video" : "asset";
return createPortal(
<div className="profile-page__detail-overlay" role="dialog" aria-modal="true" aria-labelledby="profile-detail-title">
<button type="button" className="profile-page__detail-backdrop" aria-label="关闭详情" onClick={closeDetailSelection} />
<section className="profile-page__detail-panel">
<header className="profile-page__detail-head">
<div>
<span className="profile-page__detail-eyebrow">{isWork ? "代表作详情" : "资产详情"}</span>
<h2 id="profile-detail-title">{title}</h2>
</div>
<button type="button" className="profile-page__detail-close" aria-label="关闭详情" onClick={closeDetailSelection}>
<CloseOutlined />
</button>
</header>
<div className="profile-page__detail-body">
<div className="profile-page__detail-preview">
{renderDetailMedia(mediaUrl, mediaType)}
</div>
<div className="profile-page__detail-info">
<p>{description || "暂无描述"}</p>
<dl>
<div>
<dt>{isWork ? "类型" : "资产类型"}</dt>
<dd>{isWork ? formatTaskType(detailSelection.item.type) : formatAssetType(detailSelection.item.type)}</dd>
</div>
<div>
<dt></dt>
<dd>{isWork ? formatTaskStatus(detailSelection.item.status) : formatAssetStatus(detailSelection.item.status)}</dd>
</div>
<div>
<dt>{isWork ? "创建时间" : "更新时间"}</dt>
<dd>{formatProfileDate(isWork ? detailSelection.item.createdAt : detailSelection.item.updatedAt)}</dd>
</div>
{!isWork ? (
<div>
<dt></dt>
<dd>{detailSelection.item.tags?.length ? detailSelection.item.tags.join(" / ") : "服务器素材"}</dd>
</div>
) : null}
</dl>
{detailNotice ? <span className="profile-page__detail-notice">{detailNotice}</span> : null}
</div>
</div>
<footer className="profile-page__detail-actions">
<button
type="button"
className="profile-page__detail-action profile-page__detail-action--primary"
onClick={() => void handleDownloadSelectedDetail()}
disabled={isDownloadingDetail}
>
<DownloadOutlined />
{isDownloadingDetail ? "下载中..." : "下载"}
</button>
<button type="button" className="profile-page__detail-action profile-page__detail-action--secondary" onClick={onOpenWorkbench}>
<EditOutlined />
{isWork ? "继续编辑" : "使用素材"}
</button>
<button
type="button"
className="profile-page__detail-action profile-page__detail-action--danger"
onClick={() => void handleDeleteSelectedDetail()}
disabled={isDeletingDetail}
>
<DeleteOutlined />
{isDeletingDetail ? "删除中..." : isWork ? "移除代表作" : "删除资产"}
</button>
</footer>
</section>
</div>,
modalTarget,
);
};
const renderEmptyState = (text: string, actionLabel: string, action: () => void) => (
<div className="profile-page__empty-state">
<span className="profile-page__empty-mark" aria-hidden="true">
@@ -687,7 +912,15 @@ function ProfilePage({
<div className="profile-page__works-scroll">
<div className="profile-page__list-grid motion-stagger">
{visibleWorks.map((task) => (
<article key={task.id} className="profile-page__list-card profile-page__media-card">
<article
key={task.id}
className="profile-page__list-card profile-page__media-card profile-page__interactive-card"
role="button"
tabIndex={0}
aria-label={`查看代表作 ${task.title}`}
onClick={() => openDetailSelection({ kind: "work", item: task })}
onKeyDown={(event) => handleInteractiveCardKeyDown(event, () => openDetailSelection({ kind: "work", item: task }))}
>
{renderCardPreview(task.outputUrl, task.type === "video" ? "video" : "image", formatTaskType(task.type))}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head">
@@ -712,7 +945,17 @@ function ProfilePage({
return projects.length ? (
<div className="profile-page__list-grid motion-stagger">
{projects.map((project) => (
<article key={project.id} className="profile-page__list-card profile-page__media-card">
<article
key={project.id}
className="profile-page__list-card profile-page__media-card profile-page__interactive-card"
role="button"
tabIndex={0}
aria-label={`打开项目 ${project.name}`}
onClick={() => (onOpenProject ? onOpenProject(project) : onOpenWorkbench())}
onKeyDown={(event) =>
handleInteractiveCardKeyDown(event, () => (onOpenProject ? onOpenProject(project) : onOpenWorkbench()))
}
>
{renderCardPreview(project.thumbnailUrl, "project", "项目")}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head">
@@ -722,7 +965,10 @@ function ProfilePage({
type="button"
className="profile-page__delete-project"
aria-label={`删除项目 ${project.name}`}
onClick={() => onDeleteProject(project)}
onClick={(event) => {
event.stopPropagation();
onDeleteProject(project);
}}
>
<DeleteOutlined />
</button>
@@ -746,7 +992,15 @@ function ProfilePage({
return savedAssets.length ? (
<div className="profile-page__list-grid">
{savedAssets.map((asset) => (
<article key={asset.id} className="profile-page__list-card profile-page__media-card">
<article
key={asset.id}
className="profile-page__list-card profile-page__media-card profile-page__interactive-card"
role="button"
tabIndex={0}
aria-label={`查看资产 ${asset.name}`}
onClick={() => openDetailSelection({ kind: "asset", item: asset })}
onKeyDown={(event) => handleInteractiveCardKeyDown(event, () => openDetailSelection({ kind: "asset", item: asset }))}
>
{renderCardPreview(asset.imageUrl || asset.url, asset.type === "video" ? "video" : "asset", formatAssetType(asset.type))}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head">
@@ -966,6 +1220,7 @@ function ProfilePage({
</div>
</main>
</div>
{renderDetailModal()}
</section>
);
}
@@ -15,7 +15,7 @@ import {
ThunderboltOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
@@ -89,6 +89,7 @@ function ResolutionUpscalePage({
const [isProcessing, setIsProcessing] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [isSavingAsset, setIsSavingAsset] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
@@ -166,6 +167,24 @@ function ResolutionUpscalePage({
event.currentTarget.value = "";
};
const processDroppedFile = (file: File) => {
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
setSourceName(file.name);
setSourceFile(file);
setSourceUrl("");
setSourcePreview(URL.createObjectURL(file));
setResultPreview("");
setSourceDimensions(null);
setVideoViewMode("source");
setActiveTaskId("");
setTaskProgress(0);
setStatus(`已导入 ${file.name}`);
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsDragging(true); };
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); };
const handleDrop = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const file = e.dataTransfer.files[0]; if (file) processDroppedFile(file); };
const handleImportUrl = () => {
const normalizedUrl = sourceUrl.trim();
if (!/^https?:\/\//i.test(normalizedUrl)) {
@@ -407,7 +426,13 @@ function ResolutionUpscalePage({
accept={mode === "image" ? "image/png,image/jpeg,image/webp" : "video/mp4,video/quicktime,video/webm,video/*"}
onChange={handleFileChange}
/>
<div className="image-workbench-upload-shell">
<div
className={`image-workbench-upload-shell${isDragging ? " is-dragging" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragging ? <div className="image-workbench-upload-drop-overlay"><span></span></div> : null}
<button type="button" className="image-workbench-upload" onClick={() => fileInputRef.current?.click()}>
{sourcePreview && mode === "image" ? <img src={sourcePreview} alt="" /> : <FileImageOutlined />}
<strong>{sourceName || (mode === "image" ? "选择图片" : "选择视频")}</strong>
@@ -576,11 +601,13 @@ function ResolutionUpscalePage({
</div>
)
) : (
<button type="button" className="image-workbench-empty image-workbench-empty--button" onClick={() => fileInputRef.current?.click()}>
<ColumnWidthOutlined />
<strong>{mode === "image" ? "拖拽或选择图片" : "拖拽或选择视频"}</strong>
<span>{mode === "image" ? "支持 PNG / JPG / WebP" : "支持 MP4 / MOV / WebM"}</span>
</button>
<div className="studio-canvas-ghost">
<div className="studio-canvas-ghost__icon">
<ThunderboltOutlined />
</div>
<div className="studio-canvas-ghost__title">{mode === "image" ? "拖拽或选择图片" : "拖拽或选择视频"}</div>
<div className="studio-canvas-ghost__hint">{mode === "image" ? "支持 PNG / JPG / WebP" : "支持 MP4 / MOV / WebM"}</div>
</div>
)}
</section>
</main>
@@ -1,4 +1,15 @@
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
import {
BarChartOutlined,
CheckCircleFilled,
CloseOutlined,
CopyOutlined,
DownloadOutlined,
FileTextOutlined,
LoadingOutlined,
ThunderboltOutlined,
UploadOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent } from "react";
import "../../styles/pages/script-tokens-v5.css";
import "../../styles/pages/script-tokens.css";
import { evaluateScript } from "../../api/scriptEvalClient";
@@ -307,6 +318,7 @@ function ScriptTokensPage() {
const [animatedScore, setAnimatedScore] = useState(0);
const [activeHistoryIndex, setActiveHistoryIndex] = useState<number>(0);
const [history, setHistory] = useState<HistoryEntry[]>(loadHistory);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const scoreFrameRef = useRef<number | null>(null);
@@ -329,9 +341,7 @@ function ScriptTokensPage() {
return () => { if (scoreFrameRef.current) cancelAnimationFrame(scoreFrameRef.current); };
}, [result]);
const handleFileUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const processUploadedFile = async (file: File) => {
const ext = getFileExtension(file.name);
const readable = isReadableTextFile(file, ext);
setUploadedFile({ name: file.name, size: file.size });
@@ -361,6 +371,12 @@ function ScriptTokensPage() {
} else {
setScript(`[已上传文件:${file.name}]\n\n暂不支持解析 ${ext ? ext.toUpperCase() : "未知"} 格式,请上传常见文本类文件。`);
}
};
const handleFileUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
await processUploadedFile(file);
event.target.value = "";
};
@@ -461,6 +477,30 @@ function ScriptTokensPage() {
fileInputRef.current?.click();
};
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer.types.includes("Files")) {
setIsDragging(true);
}
};
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) {
setIsDragging(false);
}
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
const file = event.dataTransfer.files[0];
if (file) processUploadedFile(file);
};
const grade = result ? getGrade(result.totalScore) : null;
const beatPct = result ? (result.totalScore >= 95 ? 97 : result.totalScore >= 88 ? 92 : result.totalScore >= 80 ? 85 : 72) : 0;
const compactTitle = uploadedFile?.name?.replace(/\.[^.]+$/, "") ?? "剧本评测";
@@ -477,15 +517,32 @@ function ScriptTokensPage() {
<div className="script-eval-v5-lp-section">
<div className="script-eval-v5-lp-label"></div>
<div
className="script-eval-v5-upload-zone"
className={`script-eval-v5-upload-zone${isDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
onKeyDown={uploadKeyDown}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragging ? (
<div className="script-eval-v5-upload-drop-overlay">
<UploadOutlined />
<span></span>
</div>
) : null}
{uploadedFile ? (
<div className="script-eval-v5-upload-done is-show">
<ShellIcon name="check-circle" />
<button
type="button"
className="script-eval-v5-upload-delete"
onClick={(e) => { e.stopPropagation(); handleReset(); }}
aria-label="删除文件"
>
<CloseOutlined />
</button>
<CheckCircleFilled />
<span className="script-eval-v5-uf-meta">
<span className="script-eval-v5-uf-name">{uploadedFile.name}</span>
<span className="script-eval-v5-uf-size">{formatFileSize(uploadedFile.size)}</span>
@@ -12,7 +12,7 @@ import {
SwapOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { useCallback, useEffect, useRef, useState, type CSSProperties, type DragEvent } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import "../../styles/pages/subtitle-removal.css";
@@ -76,6 +76,7 @@ function SubtitleRemovalPage({
const [isProcessing, setIsProcessing] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [isSavingAsset, setIsSavingAsset] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
@@ -128,10 +129,7 @@ function SubtitleRemovalPage({
event.currentTarget.value = "";
};
const handleFileDrop = (event: React.DragEvent) => {
event.preventDefault();
const file = Array.from(event.dataTransfer.files).find((f) => f.type.startsWith("video/"));
if (!file) return;
const processDroppedFile = (file: File) => {
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
setSourceName(file.name);
setSourceFile(file);
@@ -143,6 +141,10 @@ function SubtitleRemovalPage({
setStatus(`已导入 ${file.name}`);
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsDragging(true); };
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); };
const handleFileDrop = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const file = Array.from(e.dataTransfer.files).find((f) => f.type.startsWith("video/")); if (file) processDroppedFile(file); };
const handleImportUrl = () => {
const normalized = sourceUrl.trim();
if (!/^https?:\/\//i.test(normalized)) {
@@ -344,7 +346,13 @@ function SubtitleRemovalPage({
accept="video/mp4"
onChange={handleFileChange}
/>
<div className="image-workbench-upload-shell" onDragOver={(e) => e.preventDefault()} onDrop={handleFileDrop}>
<div
className={`image-workbench-upload-shell${isDragging ? " is-dragging" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleFileDrop}
>
{isDragging ? <div className="image-workbench-upload-drop-overlay"><span></span></div> : null}
<button type="button" className="image-workbench-upload" onClick={() => fileInputRef.current?.click()}>
<FileImageOutlined />
<strong>{sourceName || "拖拽或选择视频"}</strong>
@@ -438,9 +446,17 @@ function SubtitleRemovalPage({
)}
</div>
) : (
<div className="image-workbench-empty-canvas">
<DeleteOutlined style={{ fontSize: 48, opacity: 0.2 }} />
<p></p>
<div
className="studio-canvas-ghost"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleFileDrop}
>
<div className="studio-canvas-ghost__icon">
<VideoCameraOutlined />
</div>
<div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"> MP4 1GB 1080P</div>
</div>
)}
</section>
+50 -3
View File
@@ -106,6 +106,9 @@ import {
type WorkbenchKeepaliveTask,
MODE_META,
MODE_OPTIONS,
CHAT_MODEL_OPTIONS,
THINKING_SPEED_OPTIONS,
THINKING_DEPTH_OPTIONS,
IMAGE_MODEL_OPTIONS,
VIDEO_MODEL_OPTIONS,
RATIO_OPTIONS,
@@ -354,9 +357,13 @@ function WorkbenchPage({
const [videoModel, setVideoModel] = useState(VIDEO_MODEL_OPTIONS[0].value);
const [videoFrameMode, setVideoFrameMode] = useState("omni");
const [videoRatio, setVideoRatio] = useState("16:9");
const [videoDuration, setVideoDuration] = useState("4");
const [videoDuration, setVideoDuration] = useState("5");
const [videoQuality, setVideoQuality] = useState(() => getDefaultVideoQuality(VIDEO_MODEL_OPTIONS[0].value));
const [chatModel, setChatModel] = useState(CHAT_MODEL_OPTIONS[0].value);
const [thinkingSpeed, setThinkingSpeed] = useState(THINKING_SPEED_OPTIONS[0].value);
const [thinkingDepth, setThinkingDepth] = useState(THINKING_DEPTH_OPTIONS[0].value);
useEffect(() => {
let cancelled = false;
@@ -412,13 +419,13 @@ function WorkbenchPage({
const referenceCount = referenceItems.length;
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
const activeModelValue =
activeMode === "image" ? imageModel : activeMode === "video" ? activeVideoModelValue : CHAT_MODEL;
activeMode === "image" ? imageModel : activeMode === "video" ? activeVideoModelValue : chatModel;
const activeModel =
activeMode === "image"
? imageModelOptions.find((item) => item.value === imageModel)?.label || imageModel
: activeMode === "video"
? videoModelOptions.find((item) => item.value === activeVideoModelValue)?.label || activeVideoModelValue
: "OmniChat";
: CHAT_MODEL_OPTIONS.find((item) => item.value === chatModel)?.label || chatModel;
const conversationRecords = useMemo<WebProjectSummary[]>(
() =>
conversations.map((conversation) => ({
@@ -2737,6 +2744,46 @@ function WorkbenchPage({
ariaLabel="工作台模式"
direction={dropdownDirection}
/>
{activeMode === "chat" && (
<>
<SelectChip
chipId="chat-model"
value={chatModel}
options={CHAT_MODEL_OPTIONS}
disabled={disabled}
isOpen={toolbarMenuId === "chat-model"}
onToggle={() => toggleToolbarMenu("chat-model")}
onClose={closeToolbarMenus}
onChange={setChatModel}
ariaLabel="对话模型"
direction={dropdownDirection}
/>
<SelectChip
chipId="chat-speed"
value={thinkingSpeed}
options={THINKING_SPEED_OPTIONS}
disabled={disabled}
isOpen={toolbarMenuId === "chat-speed"}
onToggle={() => toggleToolbarMenu("chat-speed")}
onClose={closeToolbarMenus}
onChange={setThinkingSpeed}
ariaLabel="思考速度"
direction={dropdownDirection}
/>
<SelectChip
chipId="chat-depth"
value={thinkingDepth}
options={THINKING_DEPTH_OPTIONS}
disabled={disabled}
isOpen={toolbarMenuId === "chat-depth"}
onToggle={() => toggleToolbarMenu("chat-depth")}
onClose={closeToolbarMenus}
onChange={setThinkingDepth}
ariaLabel="思考深度"
direction={dropdownDirection}
/>
</>
)}
{activeMode === "image" && (
<>
<SelectChip
+28 -8
View File
@@ -7,6 +7,9 @@ import type { ReactNode } from "react";
export type WorkbenchMode = "chat" | "image" | "video";
export type ToolbarMenuId =
| "studio-mode"
| "chat-model"
| "chat-speed"
| "chat-depth"
| "image-model"
| "image-settings"
| "image-grid-mode"
@@ -138,6 +141,24 @@ export const REFERENCE_IMAGE_INITIAL_QUALITY = 0.84;
export const REFERENCE_IMAGE_MIN_QUALITY = 0.62;
export const CHAT_MODEL = "gemini-3.1-pro";
export const CHAT_MODEL_OPTIONS: WorkbenchOption[] = [
{ value: "gemini", label: "Gemini" },
{ value: "wanxian", label: "万相" },
{ value: "deepseek", label: "DeepSeek" },
];
export const THINKING_SPEED_OPTIONS: WorkbenchOption[] = [
{ value: "default", label: "默认" },
{ value: "high", label: "高" },
{ value: "ultra", label: "急速" },
];
export const THINKING_DEPTH_OPTIONS: WorkbenchOption[] = [
{ value: "default", label: "默认" },
{ value: "strong", label: "强" },
{ value: "extreme", label: "极限" },
];
export const CHAT_NATURAL_SYSTEM_PROMPT = [
"你是 OmniAI 的创作协作助手,像一个正在一起工作的同伴一样说话。",
`默认使用自然、简洁的中文,不要官腔,不要机械套话,不要频繁使用“首先、其次、最后”这种模板。`,
@@ -210,13 +231,13 @@ export const MODE_OPTIONS: WorkbenchOption[] = (Object.keys(MODE_META) as Workbe
}));
export const IMAGE_MODEL_OPTIONS: WorkbenchOption[] = [
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro 4K · 0.20 积分" },
{ value: "wan2.7-image", label: "wan 2.7 · 0.20 积分" },
{ value: "gpt-image-2", label: "GPT-Image-2 · 0.20 积分" },
{ value: "gpt-image-2-vip", label: "GPT-Image-2 VIP · 0.20 积分" },
{ value: "nano-banana-pro", label: "Nano Banana Pro · 0.20 积分" },
{ value: "nano-banana-2", label: "Nano Banana 2 · 0.20 积分" },
{ value: "nano-banana-fast", label: "Nano Banana · 0.20 积分" },
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro 4K" },
{ value: "wan2.7-image", label: "wan 2.7" },
{ value: "gpt-image-2", label: "GPT-Image-2" },
{ value: "gpt-image-2-vip", label: "GPT-Image-2 VIP" },
{ value: "nano-banana-pro", label: "Nano Banana Pro" },
{ value: "nano-banana-2", label: "Nano Banana 2" },
{ value: "nano-banana-fast", label: "Nano Banana" },
];
export const VIDEO_MODEL_OPTIONS: WorkbenchOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({ ...option }));
@@ -249,7 +270,6 @@ export const VIDEO_FRAME_OPTIONS: WorkbenchOption[] = [
];
export const VIDEO_DURATION_OPTIONS: WorkbenchOption[] = [
{ value: "4", label: "4s" },
{ value: "5", label: "5s" },
{ value: "6", label: "6s" },
{ value: "7", label: "7s" },