feat: 多页面拖拽上传、滚动条精简、UI优化
- 剧本评测/分辨率提升/数字人/角色迁移/图片工作台/去水印/电商:新增外部拖拽文件上传 - 电商:爆款图复刻上传框支持拖拽+大滚动条,短视频/模特图/详情图滚动条精简回退 - 图片工作台:右侧输出面板移至左侧提示词上方,删除局部重绘遮罩/结果框 - 数字人:生成按钮改为「开始生成」 - 局部重绘:编辑遮罩→编辑页面 - 对话框生成器:新增对话/视频模式、模型/速度/深度选择按钮 - 视频时长默认改为5秒 - 工具箱页面空状态logo统一绿底亮色图标 - 多处CSS滚动条和布局优化
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>交互式对话框生成器</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#165DFF',
|
||||
secondary: '#6B7280',
|
||||
accent: '#F59E0B'
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style type="text/tailwindcss">
|
||||
@layer utilities {
|
||||
.dialog-item {
|
||||
@apply p-3 border rounded-lg cursor-pointer transition-all hover:bg-primary/5 hover:border-primary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
.gen-dialog {
|
||||
position: absolute;
|
||||
min-width: 140px;
|
||||
max-width: 280px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.12);
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.gen-dialog:hover { box-shadow: 0 6px 32px rgba(0,0,0,0.18); }
|
||||
.gen-dialog.active-drag { z-index: 20; box-shadow: 0 8px 40px rgba(0,0,0,0.22); }
|
||||
|
||||
/* 样式1:白色圆角 */
|
||||
.gen-dialog.style1 { background: rgba(255,255,255,0.97); border: 2px solid #CBD5E1; }
|
||||
.gen-dialog.style1 .gen-confirm { background: #165DFF; }
|
||||
.gen-dialog.style1 .gen-text { color: #1e293b; }
|
||||
|
||||
/* 样式2:蓝色气泡 */
|
||||
.gen-dialog.style2 { background: rgba(22,93,255,0.95); border: 2px solid #4F8AFF; border-radius: 16px 16px 4px 16px; }
|
||||
.gen-dialog.style2 .gen-confirm { background: #fff; color: #165DFF; }
|
||||
.gen-dialog.style2 .gen-text { color: #fff; }
|
||||
.gen-dialog.style2 .gen-text::placeholder { color: rgba(255,255,255,0.6); }
|
||||
|
||||
/* 样式3:黄色提示 */
|
||||
.gen-dialog.style3 { background: rgba(255,247,237,0.97); border: 2px solid #F59E0B; }
|
||||
.gen-dialog.style3 .gen-confirm { background: #F59E0B; }
|
||||
.gen-dialog.style3 .gen-text { color: #92400e; }
|
||||
.gen-dialog.style3 .gen-text::placeholder { color: rgba(146,64,14,0.4); }
|
||||
|
||||
/* 样式4:灰色简约 */
|
||||
.gen-dialog.style4 { background: rgba(248,250,252,0.97); border: 2px solid #6B7280; border-radius: 4px; }
|
||||
.gen-dialog.style4 .gen-confirm { background: #6B7280; }
|
||||
.gen-dialog.style4 .gen-text { color: #1f2937; }
|
||||
|
||||
.gen-text {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
.gen-text::placeholder { color: rgba(0,0,0,0.3); }
|
||||
|
||||
.gen-confirm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.gen-confirm:hover { filter: brightness(1.1); transform: translateY(-1px); }
|
||||
.gen-confirm:active { transform: translateY(0); }
|
||||
|
||||
/* 已确认状态 */
|
||||
.gen-dialog.confirmed .gen-text { cursor: default; }
|
||||
.gen-dialog.confirmed .gen-confirm { display: none; }
|
||||
.gen-dialog.confirmed .gen-edit-hint { display: inline-block; }
|
||||
.gen-dialog:not(.confirmed) .gen-edit-hint { display: none; }
|
||||
|
||||
.gen-edit-hint {
|
||||
font-size: 10px;
|
||||
color: rgba(0,0,0,0.3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 删除按钮 */
|
||||
.gen-delete {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #EF4444;
|
||||
color: #fff;
|
||||
border: 2px solid #fff;
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 5;
|
||||
}
|
||||
.gen-dialog:hover .gen-delete { opacity: 1; }
|
||||
|
||||
#previewContainer { position: relative; }
|
||||
#previewImage { width: 100%; height: 100%; background-size: contain; background-position: center; background-repeat: no-repeat; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-[clamp(1.5rem,3vw,2.5rem)] font-bold text-center mb-8 text-gray-800">
|
||||
交互式对话框生成器
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<!-- 左侧面板 -->
|
||||
<div class="lg:w-1/3 bg-white rounded-xl shadow-md p-6">
|
||||
<!-- 文件上传区域 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold mb-4 text-gray-700">
|
||||
<i class="fa fa-upload mr-2 text-primary"></i>上传背景图片
|
||||
</h2>
|
||||
<div id="dropArea" class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary transition-colors">
|
||||
<i class="fa fa-image text-4xl text-gray-400 mb-3"></i>
|
||||
<p class="text-gray-500 mb-2">点击或拖拽图片到此处</p>
|
||||
<p class="text-xs text-gray-400">支持 JPG、PNG、WEBP 格式</p>
|
||||
<input type="file" id="fileInput" accept="image/*" class="hidden">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话框选择区域 -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-4 text-gray-700">
|
||||
<i class="fa fa-comment mr-2 text-primary"></i>点击添加对话框
|
||||
</h2>
|
||||
<p class="text-xs text-gray-400 mb-3">每点一次即在预览区新增一个对话框</p>
|
||||
<div class="space-y-3" id="dialogList">
|
||||
<div class="dialog-item" data-style="style1">
|
||||
<span class="inline-block w-3 h-3 rounded bg-white border border-gray-300 mr-2 align-middle"></span>白色圆角对话框
|
||||
</div>
|
||||
<div class="dialog-item" data-style="style2">
|
||||
<span class="inline-block w-3 h-3 rounded bg-blue-500 mr-2 align-middle"></span>蓝色气泡对话框
|
||||
</div>
|
||||
<div class="dialog-item" data-style="style3">
|
||||
<span class="inline-block w-3 h-3 rounded bg-amber-400 mr-2 align-middle"></span>黄色提示对话框
|
||||
</div>
|
||||
<div class="dialog-item" data-style="style4">
|
||||
<span class="inline-block w-3 h-3 rounded bg-gray-400 mr-2 align-middle"></span>灰色简约对话框
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<button id="clearBtn" class="w-full bg-gray-200 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-300 transition-all">
|
||||
<i class="fa fa-trash mr-1"></i>清空全部对话框
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧预览面板 -->
|
||||
<div class="lg:w-2/3 bg-white rounded-xl shadow-md p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 text-gray-700">
|
||||
<i class="fa fa-eye mr-2 text-primary"></i>预览区域
|
||||
</h2>
|
||||
<div id="previewContainer" class="relative w-full h-[500px] border rounded-lg bg-gray-100 overflow-hidden">
|
||||
<div id="previewImage"></div>
|
||||
<div id="dialogContainer" class="absolute inset-0"></div>
|
||||
<div id="emptyTip" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center text-gray-400 pointer-events-none">
|
||||
<i class="fa fa-image text-5xl mb-3"></i>
|
||||
<p>上传图片后开始编辑</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-3">
|
||||
<i class="fa fa-info-circle mr-1"></i>提示:对话框可拖动定位,输入文字后点确认即可渲染,双击已确认的框可重新编辑
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let dialogCount = 0;
|
||||
|
||||
const dropArea = document.getElementById('dropArea');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const previewImage = document.getElementById('previewImage');
|
||||
const emptyTip = document.getElementById('emptyTip');
|
||||
const dialogList = document.getElementById('dialogList');
|
||||
const dialogContainer = document.getElementById('dialogContainer');
|
||||
const clearBtn = document.getElementById('clearBtn');
|
||||
const previewContainer = document.getElementById('previewContainer');
|
||||
|
||||
// ===== 文件上传 =====
|
||||
dropArea.addEventListener('click', () => fileInput.click());
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(evt => {
|
||||
dropArea.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); });
|
||||
});
|
||||
dropArea.addEventListener('drop', e => handleFile(e.dataTransfer.files[0]));
|
||||
fileInput.addEventListener('change', e => handleFile(e.target.files[0]));
|
||||
|
||||
function handleFile(file) {
|
||||
if (!file || !file.type.startsWith('image/')) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
previewImage.style.backgroundImage = `url(${e.target.result})`;
|
||||
emptyTip.classList.add('hidden');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// ===== 点击添加对话框 =====
|
||||
dialogList.addEventListener('click', e => {
|
||||
const item = e.target.closest('.dialog-item');
|
||||
if (!item) return;
|
||||
createDialog(item.dataset.style);
|
||||
});
|
||||
|
||||
function createDialog(style) {
|
||||
dialogCount++;
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = `gen-dialog ${style}`;
|
||||
dialog.dataset.style = style;
|
||||
|
||||
// 随机偏移避免完全重叠
|
||||
const offsetX = 30 + (dialogCount * 25) % 200;
|
||||
const offsetY = 30 + (dialogCount * 20) % 150;
|
||||
dialog.style.left = offsetX + 'px';
|
||||
dialog.style.top = offsetY + 'px';
|
||||
dialog.style.padding = '12px 14px';
|
||||
|
||||
// 删除按钮
|
||||
const delBtn = document.createElement('div');
|
||||
delBtn.className = 'gen-delete';
|
||||
delBtn.innerHTML = '<i class="fa fa-times" style="font-size:9px"></i>';
|
||||
delBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
dialog.remove();
|
||||
});
|
||||
|
||||
// 文本输入区
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'gen-text';
|
||||
textarea.rows = 2;
|
||||
textarea.placeholder = '输入文本...';
|
||||
// 阻止拖动冲突
|
||||
textarea.addEventListener('mousedown', e => e.stopPropagation());
|
||||
textarea.addEventListener('touchstart', e => e.stopPropagation());
|
||||
|
||||
// 底部:确认按钮 + 编辑提示
|
||||
const bottomRow = document.createElement('div');
|
||||
bottomRow.style.cssText = 'display:flex; justify-content:flex-end; align-items:center;';
|
||||
|
||||
const editHint = document.createElement('span');
|
||||
editHint.className = 'gen-edit-hint';
|
||||
editHint.textContent = '双击编辑';
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.className = 'gen-confirm';
|
||||
confirmBtn.innerHTML = '<i class="fa fa-check" style="font-size:10px"></i> 确认';
|
||||
|
||||
bottomRow.appendChild(editHint);
|
||||
bottomRow.appendChild(confirmBtn);
|
||||
|
||||
dialog.appendChild(delBtn);
|
||||
dialog.appendChild(textarea);
|
||||
dialog.appendChild(bottomRow);
|
||||
dialogContainer.appendChild(dialog);
|
||||
|
||||
// ===== 确认 =====
|
||||
confirmBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const text = textarea.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
// 把textarea换成纯文本展示
|
||||
const textDisplay = document.createElement('div');
|
||||
textDisplay.className = 'gen-text';
|
||||
textDisplay.style.whiteSpace = 'pre-wrap';
|
||||
textDisplay.textContent = text;
|
||||
textarea.replaceWith(textDisplay);
|
||||
|
||||
dialog.classList.add('confirmed');
|
||||
});
|
||||
|
||||
// ===== 双击重新编辑 =====
|
||||
dialog.addEventListener('dblclick', e => {
|
||||
if (!dialog.classList.contains('confirmed')) return;
|
||||
e.stopPropagation();
|
||||
|
||||
const textDisplay = dialog.querySelector('.gen-text');
|
||||
const currentText = textDisplay.textContent;
|
||||
|
||||
const newTextarea = document.createElement('textarea');
|
||||
newTextarea.className = 'gen-text';
|
||||
newTextarea.rows = 2;
|
||||
newTextarea.value = currentText;
|
||||
newTextarea.addEventListener('mousedown', e => e.stopPropagation());
|
||||
newTextarea.addEventListener('touchstart', e => e.stopPropagation());
|
||||
|
||||
textDisplay.replaceWith(newTextarea);
|
||||
dialog.classList.remove('confirmed');
|
||||
newTextarea.focus();
|
||||
});
|
||||
|
||||
// ===== 拖动 =====
|
||||
bindDrag(dialog);
|
||||
|
||||
// 自动聚焦输入
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
// ===== 拖动逻辑 =====
|
||||
function bindDrag(element) {
|
||||
let dragging = false, ox, oy;
|
||||
|
||||
element.addEventListener('mousedown', e => {
|
||||
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'BUTTON' || e.target.closest('.gen-delete') || e.target.closest('.gen-confirm')) return;
|
||||
dragging = true;
|
||||
element.classList.add('active-drag');
|
||||
const rect = element.getBoundingClientRect();
|
||||
ox = e.clientX - rect.left;
|
||||
oy = e.clientY - rect.top;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', e => {
|
||||
if (!dragging) return;
|
||||
const cr = dialogContainer.getBoundingClientRect();
|
||||
let x = e.clientX - ox - cr.left;
|
||||
let y = e.clientY - oy - cr.top;
|
||||
x = Math.max(0, Math.min(x, cr.width - element.offsetWidth));
|
||||
y = Math.max(0, Math.min(y, cr.height - element.offsetHeight));
|
||||
element.style.left = x + 'px';
|
||||
element.style.top = y + 'px';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (dragging) {
|
||||
dragging = false;
|
||||
element.classList.remove('active-drag');
|
||||
}
|
||||
});
|
||||
|
||||
// 触摸支持
|
||||
element.addEventListener('touchstart', e => {
|
||||
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'BUTTON' || e.target.closest('.gen-delete') || e.target.closest('.gen-confirm')) return;
|
||||
dragging = true;
|
||||
element.classList.add('active-drag');
|
||||
const rect = element.getBoundingClientRect();
|
||||
const touch = e.touches[0];
|
||||
ox = touch.clientX - rect.left;
|
||||
oy = touch.clientY - rect.top;
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchmove', e => {
|
||||
if (!dragging) return;
|
||||
const touch = e.touches[0];
|
||||
const cr = dialogContainer.getBoundingClientRect();
|
||||
let x = touch.clientX - ox - cr.left;
|
||||
let y = touch.clientY - oy - cr.top;
|
||||
x = Math.max(0, Math.min(x, cr.width - element.offsetWidth));
|
||||
y = Math.max(0, Math.min(y, cr.height - element.offsetHeight));
|
||||
element.style.left = x + 'px';
|
||||
element.style.top = y + 'px';
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', () => {
|
||||
if (dragging) {
|
||||
dragging = false;
|
||||
element.classList.remove('active-drag');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 清空 =====
|
||||
clearBtn.addEventListener('click', () => {
|
||||
dialogContainer.innerHTML = '';
|
||||
dialogCount = 0;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
SendOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import type { WebGenerationPreviewTask } from "../../types";
|
||||
|
||||
@@ -72,6 +72,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 {
|
||||
@@ -93,6 +111,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);
|
||||
@@ -203,15 +236,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>
|
||||
|
||||
@@ -389,6 +389,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);
|
||||
@@ -1278,7 +1280,7 @@ function CanvasPage({
|
||||
model: defaultVideoModel,
|
||||
aspectRatio: "16:9",
|
||||
resolution: getDefaultVideoQuality(defaultVideoModel),
|
||||
duration: "4",
|
||||
duration: "5",
|
||||
videoMode: "text2video",
|
||||
sourceTextNodeId: source.id,
|
||||
position: {
|
||||
@@ -1302,7 +1304,7 @@ function CanvasPage({
|
||||
model: defaultVideoModel,
|
||||
aspectRatio: "16:9",
|
||||
resolution: getDefaultVideoQuality(defaultVideoModel),
|
||||
duration: "4",
|
||||
duration: "5",
|
||||
videoMode: "text2video",
|
||||
sourceTextNodeId: "",
|
||||
position,
|
||||
@@ -1358,7 +1360,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;
|
||||
@@ -1372,6 +1374,7 @@ function CanvasPage({
|
||||
imageSize: getDefaultImageQuality(fallbackVisibleImageModel),
|
||||
fileName,
|
||||
sourceImageNodeId: options?.sourceImageNodeId,
|
||||
sourceTextNodeId: options?.sourceTextNodeId,
|
||||
position,
|
||||
size: createCanvasNodeSize("image"),
|
||||
};
|
||||
@@ -1977,6 +1980,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;
|
||||
@@ -3550,7 +3667,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}
|
||||
@@ -3558,6 +3675,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`,
|
||||
@@ -4137,7 +4258,13 @@ function CanvasPage({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-canvas-text-composer">
|
||||
<div
|
||||
className={`studio-canvas-text-composer${textComposerDragNodeId === textNode.id ? " is-drag-over" : ""}`}
|
||||
onDragEnter={(e) => handleTextComposerDragEnter(e, textNode.id)}
|
||||
onDragOver={handleTextComposerDragOver}
|
||||
onDragLeave={handleTextComposerDragLeave}
|
||||
onDrop={(e) => handleTextComposerDrop(e, textNode)}
|
||||
>
|
||||
<div className="studio-canvas-text-composer__input-wrap">
|
||||
<textarea
|
||||
value={textNode.prompt}
|
||||
|
||||
@@ -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 StudioToolLayout from "../../components/StudioToolLayout";
|
||||
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
@@ -58,6 +58,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 () => {
|
||||
@@ -233,6 +234,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">
|
||||
@@ -292,7 +319,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>
|
||||
@@ -370,7 +407,7 @@ function CharacterMixPage({
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
canvas={
|
||||
isCreating ? (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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";
|
||||
|
||||
type DialogStyle = "style1" | "style2" | "style3" | "style4";
|
||||
type GenerationMode = "dialog" | "video";
|
||||
|
||||
interface DialogItem {
|
||||
id: number;
|
||||
@@ -39,16 +41,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();
|
||||
@@ -194,6 +248,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 { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
@@ -95,6 +95,7 @@ function DigitalHumanPage({
|
||||
const activeTaskIdRef = useRef(activeTaskId);
|
||||
activeTaskIdRef.current = activeTaskId;
|
||||
const keepaliveRestoredRef = useRef(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -146,6 +147,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);
|
||||
@@ -417,7 +440,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>
|
||||
@@ -490,7 +523,7 @@ function DigitalHumanPage({
|
||||
{audioPreview ? <audio src={audioPreview} controls className="studio-audio-preview" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
canvas={
|
||||
resultVideoUrl ? (
|
||||
@@ -558,7 +591,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="取消生成任务">
|
||||
|
||||
@@ -991,6 +991,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);
|
||||
@@ -1949,6 +1975,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}
|
||||
|
||||
@@ -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 type { WebImageWorkbenchTool, WebViewKey } from "../../types";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
@@ -138,6 +138,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);
|
||||
@@ -229,6 +231,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;
|
||||
@@ -261,9 +294,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<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsInpaintDragging(true); };
|
||||
const handleInpaintDragLeave = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsInpaintDragging(false); };
|
||||
const handleInpaintDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
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 = () => {
|
||||
@@ -302,7 +341,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
return;
|
||||
}
|
||||
if (!hasMask) {
|
||||
setStatus("请先编辑遮罩,涂抹需要重绘的区域");
|
||||
setStatus("请先编辑页面,涂抹需要重绘的区域");
|
||||
return;
|
||||
}
|
||||
if (generating) return;
|
||||
@@ -364,6 +403,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;
|
||||
@@ -696,7 +762,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>
|
||||
@@ -789,7 +861,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 && (
|
||||
@@ -845,7 +917,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">
|
||||
@@ -858,11 +930,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>
|
||||
@@ -870,36 +944,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">
|
||||
@@ -915,7 +959,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>
|
||||
@@ -1194,7 +1244,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>
|
||||
@@ -1225,6 +1281,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
|
||||
@@ -1297,34 +1380,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">
|
||||
{(["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>
|
||||
</aside>
|
||||
</main>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
|
||||
@@ -87,6 +87,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);
|
||||
@@ -164,6 +165,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)) {
|
||||
@@ -405,7 +424,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>
|
||||
@@ -574,11 +599,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,6 +1,7 @@
|
||||
import {
|
||||
BarChartOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FileTextOutlined,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
ThunderboltOutlined,
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent } from "react";
|
||||
import { evaluateScript } from "../../api/scriptEvalClient";
|
||||
import { buildApiUrl, getStoredToken } from "../../api/serverConnection";
|
||||
import { useSessionStore } from "../../stores";
|
||||
@@ -239,6 +240,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);
|
||||
|
||||
@@ -261,9 +263,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 });
|
||||
@@ -293,6 +293,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 = "";
|
||||
};
|
||||
|
||||
@@ -393,6 +399,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(/\.[^.]+$/, "") ?? "剧本评测";
|
||||
@@ -409,14 +439,31 @@ 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">
|
||||
<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>
|
||||
|
||||
@@ -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 { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
|
||||
@@ -73,6 +73,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);
|
||||
@@ -125,10 +126,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);
|
||||
@@ -140,6 +138,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)) {
|
||||
@@ -341,7 +343,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>
|
||||
@@ -435,9 +443,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>
|
||||
|
||||
@@ -99,6 +99,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,
|
||||
@@ -330,9 +333,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;
|
||||
|
||||
@@ -388,13 +395,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) => ({
|
||||
@@ -2648,6 +2655,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
|
||||
|
||||
@@ -6,6 +6,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"
|
||||
@@ -134,6 +137,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 的创作协作助手,像一个正在一起工作的同伴一样说话。",
|
||||
`默认使用自然、简洁的中文,不要官腔,不要机械套话,不要频繁使用“首先、其次、最后”这种模板。`,
|
||||
@@ -238,7 +259,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" },
|
||||
|
||||
@@ -722,3 +722,40 @@
|
||||
right: -9999px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
/* ── Canvas drag-and-drop visual feedback ─────────────────────────── */
|
||||
.studio-canvas.is-canvas-dragging::after {
|
||||
content: "释放以上传图片";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(33, 242, 154, 0.12);
|
||||
border: 3px dashed #21f29a;
|
||||
color: #111;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.studio-canvas-text-composer.is-drag-over {
|
||||
outline: 2px dashed #21f29a;
|
||||
outline-offset: 2px;
|
||||
background: rgba(33, 242, 154, 0.06);
|
||||
}
|
||||
|
||||
.studio-canvas-text-composer.is-drag-over::after {
|
||||
content: "释放图片以创建节点";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -553,6 +553,209 @@
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ── Generation controls ── */
|
||||
.dialog-generator-mode-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-generator-mode {
|
||||
min-height: 42px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.16);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #9aa1b8;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background 180ms ease,
|
||||
color 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.dialog-generator-mode:hover {
|
||||
border-color: rgba(0, 255, 136, 0.32);
|
||||
color: #dce3ed;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dialog-generator-mode.is-active {
|
||||
border-color: rgba(0, 255, 136, 0.42);
|
||||
background: rgba(0, 255, 136, 0.08);
|
||||
color: #00ff88;
|
||||
box-shadow: 0 0 16px rgba(0, 255, 136, 0.08);
|
||||
}
|
||||
|
||||
.dialog-generator-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-generator-pills {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dialog-generator-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 38px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #dce3ed;
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background 180ms ease,
|
||||
color 180ms ease;
|
||||
}
|
||||
|
||||
.dialog-generator-pill:hover {
|
||||
border-color: rgba(0, 255, 136, 0.28);
|
||||
color: #f6f8fb;
|
||||
}
|
||||
|
||||
.dialog-generator-pill.is-open {
|
||||
border-color: rgba(0, 255, 136, 0.38);
|
||||
background: rgba(0, 255, 136, 0.08);
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.dialog-generator-pill .anticon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dialog-generator-dropdown {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 148px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(10, 16, 26, 0.96);
|
||||
box-shadow:
|
||||
0 12px 36px rgba(0, 0, 0, 0.42),
|
||||
0 0 0 1px rgba(0, 255, 136, 0.06);
|
||||
backdrop-filter: blur(18px);
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-generator-dropdown__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #bcc4d6;
|
||||
cursor: pointer;
|
||||
padding: 9px 12px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
|
||||
.dialog-generator-dropdown__item:hover {
|
||||
background: rgba(0, 255, 136, 0.08);
|
||||
color: #e8eaef;
|
||||
}
|
||||
|
||||
.dialog-generator-dropdown__item.is-active {
|
||||
background: rgba(0, 255, 136, 0.12);
|
||||
color: #00ff88;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
/* ── Video duration ── */
|
||||
.dialog-generator-duration {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-generator-duration__label {
|
||||
color: #9aa1b8;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.dialog-generator-duration__options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dialog-generator-duration__btn {
|
||||
min-width: 42px;
|
||||
min-height: 34px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #9aa1b8;
|
||||
cursor: pointer;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background 180ms ease,
|
||||
color 180ms ease;
|
||||
}
|
||||
|
||||
.dialog-generator-duration__btn:hover {
|
||||
border-color: rgba(0, 255, 136, 0.28);
|
||||
color: #dce3ed;
|
||||
}
|
||||
|
||||
.dialog-generator-duration__btn.is-active {
|
||||
border-color: rgba(0, 255, 136, 0.42);
|
||||
background: rgba(0, 255, 136, 0.12);
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
/* ── Generate button ── */
|
||||
.dialog-generator-run {
|
||||
min-height: 48px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.28);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, rgba(0, 255, 136, 0.14) 0%, rgba(34, 240, 192, 0.08) 100%);
|
||||
color: #00ff88;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background 180ms ease,
|
||||
transform 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.dialog-generator-run:hover:not(:disabled) {
|
||||
border-color: rgba(0, 255, 136, 0.5);
|
||||
background: linear-gradient(135deg, rgba(0, 255, 136, 0.2) 0%, rgba(34, 240, 192, 0.12) 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 24px rgba(0, 255, 136, 0.1);
|
||||
}
|
||||
|
||||
.dialog-generator-run:disabled {
|
||||
opacity: 0.52;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.dialog-generator-shell {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
+103
-55
@@ -990,8 +990,8 @@
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 20px 18px;
|
||||
scrollbar-color: #3a3f49 #15171c;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3a3f49 #15171c;
|
||||
transition:
|
||||
opacity 360ms ease,
|
||||
transform var(--clone-settings-motion-duration) var(--clone-settings-motion-ease);
|
||||
@@ -1541,12 +1541,11 @@
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-panel {
|
||||
display: grid;
|
||||
flex: 0 0 272px;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
flex: 0 0 auto;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 9px;
|
||||
height: 272px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
border: 1px solid #303540;
|
||||
border-radius: 14px;
|
||||
background: #1c1f26;
|
||||
@@ -1608,7 +1607,7 @@
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 78px;
|
||||
min-height: 96px;
|
||||
overflow: visible;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
@@ -1617,7 +1616,7 @@
|
||||
border-radius: 12px;
|
||||
background: #20242c;
|
||||
color: #eef2f6;
|
||||
padding: 8px;
|
||||
padding: 16px 12px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
@@ -1625,15 +1624,52 @@
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload.has-files {
|
||||
min-height: 120px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload:hover {
|
||||
border-color: #00ff88;
|
||||
background: #202c28;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload.is-dragging {
|
||||
border-color: #00ff88;
|
||||
background: #1a2e24;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 255, 136, 0.08);
|
||||
color: #00ff88;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload-overlay .anticon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload-overlay span {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload > span {
|
||||
display: inline-grid;
|
||||
grid-template-columns: auto minmax(0, max-content);
|
||||
@@ -1676,75 +1712,86 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-preview {
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
/* ── Reference image file grid (inside upload button) ── */
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-files {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(0, 56px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
|
||||
gap: 6px;
|
||||
border-radius: 10px;
|
||||
background: #20242c;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0.98);
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload:hover .clone-ai-replicate-preview,
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload:focus-visible .clone-ai-replicate-preview {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-preview figure {
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-file {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 56px;
|
||||
height: 52px;
|
||||
aspect-ratio: 1;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-preview figure > img {
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-file > img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #3a4555;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
background: #111720;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-preview figure:only-child {
|
||||
width: min(170px, 100%);
|
||||
height: 52px;
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-file > img:hover {
|
||||
border-color: #00ff88;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-preview figure:only-child > img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-preview b {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
border: 1px solid #3a4555;
|
||||
border-radius: 999px;
|
||||
background: #151b24;
|
||||
color: #eef2f6;
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-add-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
justify-self: center;
|
||||
width: fit-content;
|
||||
max-width: calc(100% - 8px);
|
||||
height: 28px;
|
||||
min-width: 0;
|
||||
border-radius: 7px;
|
||||
background: #2b3039;
|
||||
color: #9aa4b4;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-add-more .anticon {
|
||||
font-size: 13px;
|
||||
color: #5d84bd;
|
||||
}
|
||||
|
||||
/* ── Portal-based zoom preview (avoids overflow clipping) ── */
|
||||
.clone-ai-zoom-portal {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
width: min(280px, 52vw);
|
||||
max-height: 340px;
|
||||
border: 1px solid #3a4555;
|
||||
border-radius: 14px;
|
||||
background: #101115;
|
||||
padding: 8px;
|
||||
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.5);
|
||||
transform: translate(-50%, calc(-100% - 12px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.clone-ai-zoom-portal img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 324px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-link input {
|
||||
@@ -3510,8 +3557,8 @@
|
||||
.product-set-thumb:focus-within .uploaded-image-zoom,
|
||||
.product-clone-uploaded-thumb:hover .uploaded-image-zoom,
|
||||
.product-clone-uploaded-thumb:focus-within .uploaded-image-zoom,
|
||||
.clone-ai-replicate-preview figure:hover .uploaded-image-zoom,
|
||||
.clone-ai-replicate-preview figure:focus-within .uploaded-image-zoom {
|
||||
.clone-ai-replicate-file:hover .uploaded-image-zoom,
|
||||
.clone-ai-replicate-file:focus-within .uploaded-image-zoom {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
visibility: visible;
|
||||
@@ -9458,3 +9505,4 @@
|
||||
min-height: calc(100% - 59px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -216,14 +216,14 @@
|
||||
|
||||
.image-workbench-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 220px;
|
||||
grid-template-columns: 280px 1fr;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-workbench-layout--inpaint {
|
||||
grid-template-columns: 260px 1fr 240px;
|
||||
grid-template-columns: 260px 1fr;
|
||||
}
|
||||
|
||||
.image-workbench-layout--camera {
|
||||
@@ -278,6 +278,27 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-workbench-upload-shell.is-dragging {
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.image-workbench-upload-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
color: var(--accent);
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-workbench-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -14618,6 +14618,55 @@
|
||||
background: #ddf5e2;
|
||||
}
|
||||
|
||||
.agent-tool-pill.is-open {
|
||||
background: #ddf5e2;
|
||||
box-shadow: 1px 1px 0 #111;
|
||||
}
|
||||
|
||||
.agent-tool-pills {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.agent-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 120;
|
||||
min-width: 160px;
|
||||
background: #fff;
|
||||
border: 3px solid #111;
|
||||
box-shadow: 3px 3px 0 #111;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-dropdown__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
border-bottom: 1px solid #ddd;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.agent-dropdown__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agent-dropdown__item:hover {
|
||||
background: #ddf5e2;
|
||||
}
|
||||
|
||||
.agent-dropdown__item.is-active {
|
||||
background: #c8f0d6;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.agent-run-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
|
||||
/* Upload zone */
|
||||
.script-eval-v5-upload-zone {
|
||||
position: relative;
|
||||
border: 2px dashed var(--v5-border2);
|
||||
border-radius: 12px;
|
||||
padding: 22px 18px;
|
||||
@@ -155,6 +156,37 @@
|
||||
background: var(--v5-green-deep);
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-zone.is-dragging {
|
||||
border-color: var(--v5-green);
|
||||
border-style: solid;
|
||||
background: var(--v5-green-deep);
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 255, 136, 0.06);
|
||||
color: var(--v5-green);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-drop-overlay .anticon {
|
||||
font-size: 40px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-drop-overlay span {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-icon {
|
||||
margin-bottom: 10px;
|
||||
font-size: 38px;
|
||||
@@ -195,10 +227,11 @@
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-done {
|
||||
position: relative;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
padding: 12px 28px 12px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--v5-green-deep);
|
||||
border: 1px solid var(--v5-green-border);
|
||||
@@ -208,6 +241,30 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-delete {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--v5-text3);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-delete:hover {
|
||||
background: rgba(255, 77, 103, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-done .anticon {
|
||||
font-size: 16px;
|
||||
color: var(--v5-green);
|
||||
@@ -218,7 +275,7 @@
|
||||
font-size: 13px;
|
||||
color: var(--v5-green);
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
max-width: 16ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -2805,7 +2862,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 8em;
|
||||
max-width: 16em;
|
||||
}
|
||||
|
||||
.script-eval-v5-uf-size {
|
||||
@@ -3425,8 +3482,6 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
|
||||
/* Script review left panel overflow guard: keep actions available while history remains scrollable. */
|
||||
.script-eval-v5-left {
|
||||
@@ -3935,4 +3990,3 @@
|
||||
.script-eval-v5.is-ready .script-eval-v5-status-dot {
|
||||
box-shadow: none;
|
||||
}
|
||||
>>>>>>> c1c4086383ddd7c1c8c152c2d5a97a4f432fa260
|
||||
|
||||
@@ -307,9 +307,10 @@
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(var(--accent-rgb), 0.13);
|
||||
background: rgba(var(--accent-rgb), 0.22);
|
||||
color: var(--accent);
|
||||
font-size: 26px;
|
||||
box-shadow: 0 0 20px rgba(var(--accent-rgb), 0.08);
|
||||
}
|
||||
|
||||
.studio-canvas-ghost__title {
|
||||
|
||||
Reference in New Issue
Block a user