feat(workbench): 优化电商出品工作台

This commit is contained in:
ddcat666
2026-10-13 22:45:58 +08:00
parent f6b60a3855
commit 17e9f5ed4e
6 changed files with 1050 additions and 181 deletions

View File

@@ -5,6 +5,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试 # 待测试
## 图片与视频电商出品工作室
- 已实现:`/toolbox/image` 与 `/toolbox/video` 保留紧凑的工作区入口,移除顶部品牌介绍区,增加轻量创作流程提示、基于真实工作台工具的快捷出品目标卡、页面内可见的创作工具面板、语义彩色图标、分组提示词区域和更大的作品预览区;保留原有模型、上传、生成、任务、重试和详情逻辑。
- 已实现:结果区、工具说明、进行中任务和历史任务按作品优先的视觉层级重新组织;增加明暗主题可继承的组件色彩变量、hover/focus 状态和移动端适配。
- 已验证:`cd web && bun run typecheck`、`bun run lint`、`bun run build` 通过;`git diff --check` 通过。
- [ ] 用户验收:真实登录态访问 `/toolbox/image` 和 `/toolbox/video`,确认快捷目标卡使用真实工具、图片/视频切换、工具弹层、素材上传和生成提交均正常。
- [ ] 用户验收:明暗主题、窄屏滚动、不同工具数量、工具封面缺失、生成中/成功/失败结果状态下,确认布局和色彩层级符合预期。
## 灵感库中文提示词 ## 灵感库中文提示词
- 已实现:内置目录与首页画廊的提示词统一为中文;复用来源中文正文,补齐外文翻译;导入时校验原文摘要,缺译或旧译文阻止覆盖。 - 已实现:内置目录与首页画廊的提示词统一为中文;复用来源中文正文,补齐外文翻译;导入时校验原文摘要,缺译或旧译文阻止覆盖。

View File

@@ -138,7 +138,7 @@ export function ModelPicker({
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
const buttons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[data-model-picker-item]:not(:disabled)'); const buttons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[data-model-picker-item]:not(:disabled)');
const target = last ? buttons?.item((buttons?.length || 1) - 1) : buttons?.item(0); const target = last ? buttons?.item((buttons?.length || 1) - 1) : buttons?.item(0);
target?.focus(); focusPickerOption(target);
}); });
}; };
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => { const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
@@ -151,7 +151,7 @@ export function ModelPicker({
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
setOpen(false); setOpen(false);
triggerRef.current?.focus(); triggerRef.current?.focus({ preventScroll: true });
return; return;
} }
if (event.key === "ArrowLeft" && activeGroupKey !== null) { if (event.key === "ArrowLeft" && activeGroupKey !== null) {
@@ -166,7 +166,7 @@ export function ModelPicker({
event.preventDefault(); event.preventDefault();
const activeIndex = buttons.indexOf(document.activeElement as HTMLButtonElement); const activeIndex = buttons.indexOf(document.activeElement as HTMLButtonElement);
const nextIndex = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : event.key === "ArrowUp" ? Math.max(0, activeIndex - 1) : Math.min(buttons.length - 1, activeIndex + 1); const nextIndex = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : event.key === "ArrowUp" ? Math.max(0, activeIndex - 1) : Math.min(buttons.length - 1, activeIndex + 1);
buttons[nextIndex]?.focus(); focusPickerOption(buttons[nextIndex]);
}; };
const content = ( const content = (
<div <div
@@ -265,6 +265,7 @@ export function ModelPicker({
placement="bottomLeft" placement="bottomLeft"
align={{ overflow: { adjustY: true, shiftX: true } }} align={{ overflow: { adjustY: true, shiftX: true } }}
arrow={false} arrow={false}
motion={{ motionName: "" }}
content={content} content={content}
classNames={{ classNames={{
root: cn("canvas-model-picker-popover", "creation-model-picker-popover", popoverClassName), root: cn("canvas-model-picker-popover", "creation-model-picker-popover", popoverClassName),
@@ -296,6 +297,19 @@ export function ModelPicker({
); );
} }
function focusPickerOption(target: HTMLButtonElement | null | undefined) {
if (!target) return;
target.focus({ preventScroll: true });
// Only reveal the focused row inside its pane; scrolling portal ancestors moves the page.
const pane = target.closest<HTMLElement>(".canvas-model-picker-model-pane, .canvas-model-picker-menu");
if (!pane) return;
const row = target.getBoundingClientRect();
const top = pane.getBoundingClientRect().top + pane.clientTop;
const bottom = top + pane.clientHeight;
const delta = row.top < top ? row.top - top : row.bottom > bottom ? row.bottom - bottom : 0;
if (delta) pane.scrollTop += delta;
}
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) { function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : ""; const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
if (capability && config.models.length) return `暂无支持当前输入的${label}模型`; if (capability && config.models.length) return `暂无支持当前输入的${label}模型`;

View File

@@ -98,10 +98,9 @@
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 8px 18px color-mix(in srgb, var(--user-ink) 8%, transparent); box-shadow: 0 8px 18px color-mix(in srgb, var(--user-ink) 8%, transparent);
} }
.app-user-workspace .creation-chat-control > svg:first-child, .app-user-workspace .creation-chat-control > svg:first-child { transition: transform var(--user-motion), color var(--user-motion); color: currentColor; }
.app-user-workspace .creation-model-picker .canvas-model-picker-trigger-icon { transition: transform var(--user-motion), color var(--user-motion); color: currentColor; } .app-user-workspace .creation-model-picker .canvas-model-picker-trigger-icon { color: currentColor; }
.app-user-workspace .creation-chat-control:hover > svg:first-child, .app-user-workspace .creation-chat-control:hover > svg:first-child { transform: scale(1.06); }
.app-user-workspace .creation-model-picker:hover .canvas-model-picker-trigger-icon { transform: scale(1.06); }
.app-user-workspace .creation-chat-control.is-clear:hover { background: color-mix(in srgb, var(--status-error, #c45b5b) 12%, var(--user-surface)) !important; color: var(--status-error, #c45b5b) !important; } .app-user-workspace .creation-chat-control.is-clear:hover { background: color-mix(in srgb, var(--status-error, #c45b5b) 12%, var(--user-surface)) !important; color: var(--status-error, #c45b5b) !important; }
.app-user-workspace .creation-launchpad .creation-reference-add-button:hover:not(:disabled) { .app-user-workspace .creation-launchpad .creation-reference-add-button:hover:not(:disabled) {
border-style: solid; border-style: solid;

View File

@@ -1,5 +1,5 @@
import { App, Button, Input, InputNumber, Modal, Popover, Progress, Segmented, Select, Spin, Switch, Tooltip } from "antd"; import { App, Button, Input, InputNumber, Modal, Progress, Segmented, Select, Spin, Switch, Tooltip } from "antd";
import { Check, ChevronDown, Expand, FileImage, History, ImagePlus, LoaderCircle, Mic, Plus, Search, Sparkles, Upload, Video, X } from "lucide-react"; import { Check, Camera, ChevronDown, Clapperboard, Expand, FileImage, History, ImagePlus, Images, LoaderCircle, Mic, Palette, Plus, RotateCcw, Search, ShoppingBag, Sparkles, Upload, UserRound, Video, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
@@ -22,7 +22,7 @@ import { capabilitySupportedModes, createWorkbenchTask, listWorkbenchTasks, list
import { resourceStorageKey, resolveResourceUrl, uploadResourceFile } from "@/services/api/resources"; import { resourceStorageKey, resolveResourceUrl, uploadResourceFile } from "@/services/api/resources";
import { formatCredits, requestCreditCost } from "@/constant/credits"; import { formatCredits, requestCreditCost } from "@/constant/credits";
import { useCreationPreferencesStore } from "@/stores/use-creation-preferences-store"; import { useCreationPreferencesStore } from "@/stores/use-creation-preferences-store";
import { logicalModelIDForConfig, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { logicalModelIDForConfig, modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store"; import { useUserStore } from "@/stores/use-user-store";
import { workbenchCategoryLabel, workbenchCategories } from "./workbench-catalog"; import { workbenchCategoryLabel, workbenchCategories } from "./workbench-catalog";
@@ -136,6 +136,32 @@ function revokeReferencePreview(reference: WorkbenchReference) {
if (reference.previewUrl.startsWith("blob:")) URL.revokeObjectURL(reference.previewUrl); if (reference.previewUrl.startsWith("blob:")) URL.revokeObjectURL(reference.previewUrl);
} }
// 当后台工具副标题看起来像模型 ID(前后端配置错误遗留),回退到工具描述或留空,避免泄露原始模型标识。
function isModelIdLike(value: string | undefined) {
if (!value) return true;
const normalized = value.trim();
if (!normalized) return true;
if (/^[a-z0-9][a-z0-9._-]{1,40}$/i.test(normalized) && /[._-]/.test(normalized)) return true;
return false;
}
function toolVisual(tool: WorkbenchTool, kind: WorkbenchKind) {
const text = `${tool.name} ${tool.subtitle || ""} ${tool.description || ""} ${(tool.searchKeywords || []).join(" ")}`.toLocaleLowerCase();
if (text.includes("模特") || text.includes("人物") || text.includes("上身") || text.includes("穿搭")) return { icon: <UserRound />, tone: "people", label: "模特与人物" };
if (text.includes("视频") || kind === "video") return { icon: <Clapperboard />, tone: "video", label: "商品视频" };
if (text.includes("场景") || text.includes("氛围") || text.includes("背景")) return { icon: <Images />, tone: "scene", label: "场景与氛围" };
if (text.includes("风格") || text.includes("海报") || text.includes("视觉")) return { icon: <Palette />, tone: "style", label: "风格与视觉" };
if (text.includes("主图") || text.includes("商品") || text.includes("电商") || text.includes("白底")) return { icon: <ShoppingBag />, tone: "product", label: "商品出品" };
return { icon: kind === "image" ? <Camera /> : <Sparkles />, tone: "creative", label: "创意生成" };
}
function flowStepState(step: string, values: { tool: WorkbenchTool | null; references: WorkbenchReference[]; prompt: string; selectedModel: string }) {
if (step === "goal") return values.tool ? "done" : "active";
if (step === "reference") return values.references.length ? "done" : values.tool?.inputContract?.slots.some((slot) => slot.required) ? "active" : "ready";
if (step === "prompt") return values.prompt.trim() ? "done" : "active";
return values.selectedModel ? "done" : "active";
}
export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
const { message } = App.useApp(); const { message } = App.useApp();
const config = useEffectiveConfig(); const config = useEffectiveConfig();
@@ -159,6 +185,8 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
}; };
useEffect(() => () => referencesRef.current.forEach(revokeReferencePreview), []); useEffect(() => () => referencesRef.current.forEach(revokeReferencePreview), []);
const [activeTask, setActiveTask] = useState<GenerationTask | null>(null); const [activeTask, setActiveTask] = useState<GenerationTask | null>(null);
const [resultHidden, setResultHidden] = useState(false);
const workbenchRevisionRef = useRef(0);
const [tasks, setTasks] = useState<GenerationTask[]>([]); const [tasks, setTasks] = useState<GenerationTask[]>([]);
const [tasksLoading, setTasksLoading] = useState(true); const [tasksLoading, setTasksLoading] = useState(true);
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null); const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
@@ -194,6 +222,10 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
const imageProfile = profile.image; const imageProfile = profile.image;
const videoProfile = profile.video; const videoProfile = profile.video;
const filteredTools = useMemo(() => filterWorkbenchTools(tools, toolCategory, toolSearch), [toolCategory, toolSearch, tools]); const filteredTools = useMemo(() => filterWorkbenchTools(tools, toolCategory, toolSearch), [toolCategory, toolSearch, tools]);
const quickTools = useMemo(() => {
const preferred = tools.filter((item) => item.recommended || item.categoryId === "ecommerce" || item.categoryId === "product");
return (preferred.length ? preferred : tools).slice(0, 4);
}, [tools]);
const promptRequired = workbenchPromptRequired(tool); const promptRequired = workbenchPromptRequired(tool);
const missingInput = missingWorkbenchInput(tool, references); const missingInput = missingWorkbenchInput(tool, references);
const activeTasks = useMemo(() => tasks.filter((task) => task.status === "queued" || task.status === "running"), [tasks]); const activeTasks = useMemo(() => tasks.filter((task) => task.status === "queued" || task.status === "running"), [tasks]);
@@ -370,9 +402,10 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
let stopped = false; let stopped = false;
let timer = 0; let timer = 0;
const poll = async () => { const poll = async () => {
const revision = workbenchRevisionRef.current;
const result = await loadTasks(); const result = await loadTasks();
if (stopped) return; if (stopped) return;
if (activeTask) { if (activeTask && revision === workbenchRevisionRef.current) {
const latest = result.find((item) => item.id === activeTask.id); const latest = result.find((item) => item.id === activeTask.id);
if (latest) { if (latest) {
setActiveTask(latest); setActiveTask(latest);
@@ -396,18 +429,55 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
}; };
}, [activeTask?.id, loadTasks]); }, [activeTask?.id, loadTasks]);
const resetWorkbench = () => {
workbenchRevisionRef.current += 1;
references.forEach(revokeReferencePreview);
setReferences([]);
setUploadSlotKey(undefined);
setTool(null);
setPreset(null);
setPresetPending(null);
setPrompt("");
setActiveTask(null);
setResultHidden(true);
detailRequestRef.current += 1;
setDetailLoading(false);
setDetailTask(null);
setMobileTab("generate");
setCount("1");
setRatio(kind === "image" ? imageProfile?.size.default || "1:1" : videoProfile?.defaultRatio || "16:9");
setQuality(imageProfile?.quality.default || "auto");
setSeconds(String(videoProfile?.duration.default || 6));
setVideoQuality((videoProfile?.defaultResolution || "720p").replace(/p$/i, ""));
const params = new URLSearchParams(searchParams);
params.delete("tool");
params.delete("preset");
setSearchParams(params, { replace: true });
message.success("工作台已重置");
};
const chooseTool = (next: WorkbenchTool | null) => { const chooseTool = (next: WorkbenchTool | null) => {
references.forEach(revokeReferencePreview); references.forEach(revokeReferencePreview);
setReferences([]); setReferences([]);
setUploadSlotKey(undefined); setUploadSlotKey(undefined);
setTool(next); setTool(next);
setPreset(null);
setToolLibraryOpen(false); setToolLibraryOpen(false);
setPreset(null);
setPresetPending(null);
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
if (next) params.set("tool", next.id); if (next) params.set("tool", next.id);
else params.delete("tool"); else params.delete("tool");
params.delete("preset"); params.delete("preset");
setSearchParams(params, { replace: true }); setSearchParams(params, { replace: true });
// 工具选择必须真实作用于提示词:有预设模板且当前提示词为空时直接填充首个预设,
// 否则展示预设 chips 让用户主动应用,避免"选了工具没反应"。
if (next?.presets.length) {
const first = next.presets[0];
if (!prompt.trim()) {
setPrompt(first.promptTemplate);
setPreset(first);
}
}
}; };
const choosePreset = (next: WorkbenchPromptPreset) => { const choosePreset = (next: WorkbenchPromptPreset) => {
@@ -477,6 +547,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
}; };
const submit = async () => { const submit = async () => {
const revision = workbenchRevisionRef.current;
const submittedPrompt = normalizeCanvasNodeMentionTokens(prompt.trim(), promptReferences); const submittedPrompt = normalizeCanvasNodeMentionTokens(prompt.trim(), promptReferences);
if (tool?.inputContract?.promptRequired && !submittedPrompt) { if (tool?.inputContract?.promptRequired && !submittedPrompt) {
message.warning("当前工具需要填写提示词"); message.warning("当前工具需要填写提示词");
@@ -588,9 +659,12 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
}, },
}); });
const task = await queryGenerationTask(createdTask.id); const task = await queryGenerationTask(createdTask.id);
setActiveTask(task);
setTasks((current) => [task, ...current.filter((item) => item.id !== task.id)]); setTasks((current) => [task, ...current.filter((item) => item.id !== task.id)]);
setMobileTab("result"); if (revision === workbenchRevisionRef.current) {
setResultHidden(false);
setActiveTask(task);
setMobileTab("result");
}
message.success("生成任务已提交"); message.success("生成任务已提交");
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : "任务提交失败"); message.error(error instanceof Error ? error.message : "任务提交失败");
@@ -672,6 +746,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
else params.delete("preset"); else params.delete("preset");
setSearchParams(params, { replace: true }); setSearchParams(params, { replace: true });
setActiveTask(task); setActiveTask(task);
setResultHidden(false);
setMobileTab("generate"); setMobileTab("generate");
setDetailTask(null); setDetailTask(null);
if (notify) message.success("任务配置已载入编辑器"); if (notify) message.success("任务配置已载入编辑器");
@@ -725,7 +800,14 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
: estimateTone === "usage" : estimateTone === "usage"
? "按实际用量结算" ? "按实际用量结算"
: "报价暂不可用"; : "报价暂不可用";
const resultTask = activeTask || tasks.find((task) => task.status === "succeeded") || null; const taskModelLabel = (task: GenerationTask) => {
const rawModel = task.model?.trim() || "";
if (!rawModel) return kind === "image" ? "图片生成" : "视频生成";
const configuredModel = models.find((item) => item === rawModel || modelOptionName(item) === rawModel);
const display = configuredModel ? modelDisplayName(config, configuredModel) : rawModel;
return isModelIdLike(display) ? "生成模型" : display;
};
const resultTask = resultHidden ? null : activeTask || tasks.find((task) => task.status === "succeeded") || null;
const resultUrl = resultTask ? taskPreview(resultTask) : ""; const resultUrl = resultTask ? taskPreview(resultTask) : "";
const settingsPanel = const settingsPanel =
kind === "image" && imageProfile ? ( kind === "image" && imageProfile ? (
@@ -857,7 +939,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
) : ( ) : (
<div className="workbench-tool-grid"> <div className="workbench-tool-grid">
{filteredTools.map((item) => ( {filteredTools.map((item) => (
<ToolCard key={item.id} tool={item} selected={item.id === tool?.id} onClick={() => chooseTool(item)} /> <ToolCard key={item.id} tool={item} selected={item.id === tool?.id} onClick={() => { chooseTool(item); setToolLibraryOpen(false); }} />
))} ))}
{!filteredTools.length ? <div className="workbench-picker-state">没有找到匹配的创作工具</div> : null} {!filteredTools.length ? <div className="workbench-picker-state">没有找到匹配的创作工具</div> : null}
</div> </div>
@@ -868,6 +950,26 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
return ( return (
<WorkspacePage fluid scroll={false} className="workbench-page"> <WorkspacePage fluid scroll={false} className="workbench-page">
<div className="workbench-shell" data-kind={kind}> <div className="workbench-shell" data-kind={kind}>
<div className="workbench-flow-steps" aria-label="创作流程">
<Tooltip title="清空工具、提示词、参考素材和生成结果">
<button type="button" className="workbench-flow-reset" onClick={resetWorkbench} aria-label="重置工作台">
<RotateCcw />
<span>重置</span>
</button>
</Tooltip>
{[
["goal", "01", "选择出品目标", flowStepState("goal", { tool, references, prompt, selectedModel })],
["reference", "02", "准备参考素材", flowStepState("reference", { tool, references, prompt, selectedModel })],
["prompt", "03", "描述商品画面", flowStepState("prompt", { tool, references, prompt, selectedModel })],
["settings", "04", "确认参数并生成", flowStepState("settings", { tool, references, prompt, selectedModel })],
].map(([key, number, label, state], index) => (
<div className={`workbench-flow-step is-${state}`} key={key}>
<span className="workbench-flow-number">{state === "done" ? <Check /> : number}</span>
<span>{label}</span>
{index < 3 ? <span className="workbench-flow-line" aria-hidden /> : null}
</div>
))}
</div>
<section className={`workbench-columns ${mobileTab !== "generate" ? "is-mobile-secondary" : ""}`}> <section className={`workbench-columns ${mobileTab !== "generate" ? "is-mobile-secondary" : ""}`}>
<div className="workbench-mobile-tabs"> <div className="workbench-mobile-tabs">
<Segmented <Segmented
@@ -885,26 +987,66 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
<div className="workbench-composer"> <div className="workbench-composer">
<div className="workbench-tool-bar"> <div className="workbench-tool-bar">
<span className="workbench-kind-label">{kind === "image" ? "图片生成" : "视频生成"}</span> <span className="workbench-kind-label">{kind === "image" ? "图片生成" : "视频生成"}</span>
<Popover <button type="button" className="workbench-tool-choice" aria-expanded={toolLibraryOpen} aria-haspopup="dialog" aria-controls="workbench-tool-library" onClick={() => setToolLibraryOpen((open) => !open)}>
open={toolLibraryOpen} <span className="workbench-tool-choice-mark">{tool?.iconUrl ? <img src={tool.iconUrl} alt="" /> : kind === "image" ? <FileImage /> : <Video />}</span>
onOpenChange={setToolLibraryOpen} <span className="workbench-tool-choice-copy">
trigger="click" <small>{tool ? (isModelIdLike(tool.subtitle) ? tool.description || "创作工具" : tool.subtitle || tool.description || "创作工具") : "直接创作"}</small>
placement="bottomLeft" <strong>{tool?.name || "选择创作工具"}</strong>
arrow={false} </span>
classNames={{ root: "workbench-tool-popover", container: "workbench-tool-popover-surface", content: "workbench-tool-popover-content" }} <ChevronDown className={toolLibraryOpen ? "is-open" : ""} />
content={toolLibrary} </button>
>
<button type="button" className="workbench-tool-choice" aria-expanded={toolLibraryOpen} aria-haspopup="dialog" aria-controls="workbench-tool-library">
<span className="workbench-tool-choice-mark">{tool?.iconUrl ? <img src={tool.iconUrl} alt="" /> : kind === "image" ? <FileImage /> : <Video />}</span>
<span className="workbench-tool-choice-copy">
<small>{tool?.subtitle || "不使用工具"}</small>
<strong>{tool?.name || "直接创作"}</strong>
</span>
<ChevronDown className={toolLibraryOpen ? "is-open" : ""} />
</button>
</Popover>
</div> </div>
<Modal
title="选择创作工具"
open={toolLibraryOpen}
onCancel={() => setToolLibraryOpen(false)}
footer={null}
width="min(1120px, calc(100vw - 32px))"
className="workbench-picker-modal"
>
{toolLibrary}
</Modal>
{!tool && tools.length ? (
<section className="workbench-goal-picker" aria-label="快速选择出品目标">
<div className="workbench-goal-heading">
<div>
<span className="workbench-section-index">01</span>
<div>
<strong>你现在要做什么?</strong>
<span>选择一个出品目标,参数和提示词会更快就位</span>
</div>
</div>
<button type="button" onClick={() => setToolLibraryOpen(true)}>查看更多 <ChevronDown /></button>
</div>
<div className="workbench-goal-grid">
{quickTools.map((item) => <QuickGoalCard key={item.id} tool={item} kind={kind} onClick={() => chooseTool(item)} />)}
</div>
</section>
) : null}
<div className="workbench-studio"> <div className="workbench-studio">
<div className="workbench-block-label">
<span className="workbench-section-index">03</span>
<div>
<strong>描述你想要的成品</strong>
<span>说清商品、场景、构图、光线和需要保留的细节</span>
</div>
</div>
{tool?.presets.length ? (
<div className="workbench-preset-row" role="group" aria-label="工具提示词模板">
{tool.presets.slice(0, 5).map((item) => (
<button
type="button"
key={item.id}
className={`workbench-preset-chip ${preset?.id === item.id ? "is-active" : ""}`}
aria-pressed={preset?.id === item.id}
title={item.summary || item.name}
onClick={() => choosePreset(item)}
>
{item.name}
</button>
))}
</div>
) : null}
<div className="workbench-prompt-surface"> <div className="workbench-prompt-surface">
<CanvasResourceMentionTextarea <CanvasResourceMentionTextarea
aria-label="生成提示词,可输入 @ 引用参考素材" aria-label="生成提示词,可输入 @ 引用参考素材"
@@ -1091,18 +1233,22 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
</a> </a>
</div> </div>
)} )}
<div className="workbench-dock-actions"> <div className="workbench-submit-estimate" aria-live="polite">
<Button <span className="workbench-submit-estimate-label">{estimateLabel || "预估可用积分"}</span>
className="workbench-submit" {selectedModel ? (
type="primary" <span className="workbench-submit-estimate-model">使用 {modelDisplayName(config, selectedModel)}</span>
loading={busy} ) : null}
disabled={(promptRequired && !prompt.trim()) || Boolean(missingInput) || !selectedModel || references.some((reference) => reference.uploading || Boolean(reference.error))}
onClick={() => void submit()}
>
<span>{kind === "image" ? "生成图片" : "生成视频"}</span>
<small>{estimateLabel}</small>
</Button>
</div> </div>
<Button
className="workbench-submit"
type="primary"
loading={busy}
disabled={(promptRequired && !prompt.trim()) || Boolean(missingInput) || !selectedModel || references.some((reference) => reference.uploading || Boolean(reference.error))}
onClick={() => void submit()}
>
<Sparkles />
<span>{kind === "image" ? "生成图片" : "生成视频"}</span>
</Button>
</div> </div>
</div> </div>
</section> </section>
@@ -1143,7 +1289,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
<MediaPreview src={resultUrl} kind="image" alt="生成结果" className="size-full object-contain" fallbackLabel="结果暂不可用" /> <MediaPreview src={resultUrl} kind="image" alt="生成结果" className="size-full object-contain" fallbackLabel="结果暂不可用" />
)} )}
<div className="workbench-result-caption"> <div className="workbench-result-caption">
<span>{resultTask.model || "生成作品"}</span> <span>{taskModelLabel(resultTask)}</span>
<Button size="small" icon={<Expand className="size-3.5" />} onClick={() => setDetailTask(resultTask)}> <Button size="small" icon={<Expand className="size-3.5" />} onClick={() => setDetailTask(resultTask)}>
查看详情 查看详情
</Button> </Button>
@@ -1226,7 +1372,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
</div> </div>
<div className="workbench-task-list"> <div className="workbench-task-list">
{activeTasks.length ? ( {activeTasks.length ? (
activeTasks.map((task) => <TaskRow key={task.id} task={task} kind={kind} busy={taskBusyId === task.id} onOpen={() => void openDetail(task)} onCancel={() => void cancelTask(task)} />) activeTasks.map((task) => <TaskRow key={task.id} task={task} kind={kind} modelLabel={taskModelLabel(task)} busy={taskBusyId === task.id} onOpen={() => void openDetail(task)} onCancel={() => void cancelTask(task)} />)
) : ( ) : (
<p className="workbench-list-empty">暂无进行中的任务</p> <p className="workbench-list-empty">暂无进行中的任务</p>
)} )}
@@ -1247,7 +1393,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
historyTasks historyTasks
.filter((task) => !query.trim() || task.prompt.toLowerCase().includes(query.trim().toLowerCase())) .filter((task) => !query.trim() || task.prompt.toLowerCase().includes(query.trim().toLowerCase()))
.slice(0, 30) .slice(0, 30)
.map((task) => <TaskRow key={task.id} task={task} kind={kind} busy={taskBusyId === task.id} onOpen={() => void openDetail(task)} onRetry={() => void retryTask(task)} />) .map((task) => <TaskRow key={task.id} task={task} kind={kind} modelLabel={taskModelLabel(task)} busy={taskBusyId === task.id} onOpen={() => void openDetail(task)} onRetry={() => void retryTask(task)} />)
)} )}
{!tasksLoading && !historyTasks.length ? <p className="workbench-list-empty">完成的作品会保留在这里</p> : null} {!tasksLoading && !historyTasks.length ? <p className="workbench-list-empty">完成的作品会保留在这里</p> : null}
</div> </div>
@@ -1283,7 +1429,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
</div> </div>
<div className="workbench-detail-info"> <div className="workbench-detail-info">
<span className={`workbench-status is-${detailTask.status}`}>{displayTaskState(detailTask)}</span> <span className={`workbench-status is-${detailTask.status}`}>{displayTaskState(detailTask)}</span>
<h3>{detailTask.model || "生成任务"}</h3> <h3>{taskModelLabel(detailTask)}</h3>
<label>提示词</label> <label>提示词</label>
<p className="workbench-detail-prompt">{detailTask.prompt}</p> <p className="workbench-detail-prompt">{detailTask.prompt}</p>
<label>参数</label> <label>参数</label>
@@ -1380,14 +1526,30 @@ function ParameterChips({ label, value, options, onChange }: { label: string; va
); );
} }
function ToolCard({ tool, selected, onClick }: { tool: WorkbenchTool; selected: boolean; onClick: () => void }) { function QuickGoalCard({ tool, kind, onClick }: { tool: WorkbenchTool; kind: WorkbenchKind; onClick: () => void }) {
const visual = toolVisual(tool, kind);
return ( return (
<button type="button" className={`workbench-tool-card ${selected ? "is-selected" : ""}`} aria-pressed={selected} onClick={onClick}> <button type="button" className={`workbench-goal-card is-${visual.tone}`} onClick={onClick}>
<span className="workbench-goal-icon">{tool.coverUrl || tool.iconUrl ? <img src={tool.coverUrl || tool.iconUrl} alt="" /> : visual.icon}</span>
<span className="workbench-goal-copy">
<strong>{tool.name}</strong>
<small>{tool.subtitle || visual.label}</small>
</span>
<span className="workbench-goal-arrow">→</span>
</button>
);
}
function ToolCard({ tool, selected, onClick }: { tool: WorkbenchTool; selected: boolean; onClick: () => void }) {
const visual = toolVisual(tool, tool.kind);
const subtitle = isModelIdLike(tool.subtitle) ? tool.description || "" : tool.subtitle || tool.description;
return (
<button type="button" className={`workbench-tool-card ${selected ? "is-selected" : ""} is-${visual.tone}`} aria-pressed={selected} onClick={onClick}>
<SpotlightSurface className="workbench-tool-card-surface" spotlightColor="color-mix(in srgb, var(--user-ink) 5%, transparent)"> <SpotlightSurface className="workbench-tool-card-surface" spotlightColor="color-mix(in srgb, var(--user-ink) 5%, transparent)">
<span className="workbench-tool-card-image">{tool.coverUrl || tool.iconUrl ? <img src={tool.coverUrl || tool.iconUrl} alt="" /> : <Sparkles />}</span> <span className="workbench-tool-card-image">{tool.coverUrl || tool.iconUrl ? <img src={tool.coverUrl || tool.iconUrl} alt="" /> : visual.icon}</span>
<span className="workbench-tool-card-copy"> <span className="workbench-tool-card-copy">
<strong>{tool.name}</strong> <strong>{tool.name}</strong>
<small>{tool.subtitle || tool.description}</small> <small>{subtitle}</small>
</span> </span>
{selected ? ( {selected ? (
<span className="workbench-tool-check"> <span className="workbench-tool-check">
@@ -1436,14 +1598,14 @@ function ReferenceCard({ reference, kind, onRemove, onRoleChange }: { reference:
); );
} }
function TaskRow({ task, kind, busy, onOpen, onCancel, onRetry }: { task: GenerationTask; kind: WorkbenchKind; busy: boolean; onOpen: () => void; onCancel?: () => void; onRetry?: () => void }) { function TaskRow({ task, kind, modelLabel, busy, onOpen, onCancel, onRetry }: { task: GenerationTask; kind: WorkbenchKind; modelLabel: string; busy: boolean; onOpen: () => void; onCancel?: () => void; onRetry?: () => void }) {
const url = taskPreview(task); const url = taskPreview(task);
return ( return (
<article className="workbench-task-row"> <article className="workbench-task-row">
<button type="button" className="workbench-task-main" onClick={onOpen}> <button type="button" className="workbench-task-main" onClick={onOpen}>
<span className="workbench-task-thumb">{url && task.previewKind !== "video" ? <MediaPreview src={url} kind="image" alt="" className="size-full object-cover" /> : kind === "video" ? <Video /> : <FileImage />}</span> <span className="workbench-task-thumb">{url && task.previewKind !== "video" ? <MediaPreview src={url} kind="image" alt="" className="size-full object-cover" /> : kind === "video" ? <Video /> : <FileImage />}</span>
<span className="workbench-task-copy"> <span className="workbench-task-copy">
<strong>{task.model || (kind === "image" ? "图片生成" : "视频生成")}</strong> <strong>{modelLabel}</strong>
<small>{task.prompt}</small> <small>{task.prompt}</small>
<em> <em>
{displayTaskState(task)} · {elapsedLabel(task)} {displayTaskState(task)} · {elapsedLabel(task)}

File diff suppressed because it is too large Load Diff

View File

@@ -52,7 +52,7 @@
padding: 0 !important; padding: 0 !important;
backdrop-filter: blur(28px) saturate(140%); backdrop-filter: blur(28px) saturate(140%);
-webkit-backdrop-filter: blur(28px) saturate(140%); -webkit-backdrop-filter: blur(28px) saturate(140%);
animation: creation-popover-enter var(--motion-dur-fast-calc) var(--motion-ease-out) both; animation: none;
} }
.dark .creation-model-picker-surface { .dark .creation-model-picker-surface {
@@ -114,7 +114,7 @@
min-height: 52px; min-height: 52px;
border-radius: 9px; border-radius: 9px;
padding: 7px 9px; padding: 7px 9px;
transition: background-color 150ms ease, transform 120ms ease; transition: none;
} }
@@ -155,7 +155,7 @@
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .055); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .055);
transition: transform 150ms var(--motion-ease-out), border-color 150ms ease, background-color 150ms ease; transition: none;
} }
html:not(.dark) .creation-model-picker.canvas-composer-model-picker { html:not(.dark) .creation-model-picker.canvas-composer-model-picker {
@@ -169,7 +169,6 @@ html:not(.dark) .creation-model-picker.canvas-composer-model-picker {
.creation-model-picker.canvas-composer-model-picker[aria-expanded="true"] { .creation-model-picker.canvas-composer-model-picker[aria-expanded="true"] {
border-color: rgba(255, 255, 255, .25); border-color: rgba(255, 255, 255, .25);
background: #292d34; background: #292d34;
transform: translateY(-1px);
} }
html:not(.dark) .creation-model-picker.canvas-composer-model-picker:hover, html:not(.dark) .creation-model-picker.canvas-composer-model-picker:hover,
@@ -197,10 +196,10 @@ html:not(.dark) .creation-model-picker.canvas-composer-model-picker[aria-expande
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
box-shadow: none; box-shadow: none;
transition: border-color 140ms ease, background-color 140ms ease, transform 140ms var(--motion-ease-out); transition: none;
} }
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover, .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover,
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker[aria-expanded="true"] { border-color: rgba(255, 255, 255, .12); background: #272b32; transform: translateY(-1px); } .creation-chat-composer .creation-model-picker.canvas-composer-model-picker[aria-expanded="true"] { border-color: rgba(255, 255, 255, .12); background: #272b32; }
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker { width: min(196px, 26vw); justify-content: flex-start; } .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { width: min(196px, 26vw); justify-content: flex-start; }
.creation-chat-composer .creation-model-picker .canvas-model-picker-trigger-icon { width: 19px; height: 19px; border-radius: 5px; } .creation-chat-composer .creation-model-picker .canvas-model-picker-trigger-icon { width: 19px; height: 19px; border-radius: 5px; }
@@ -252,10 +251,10 @@ html:not(.dark) .creation-model-picker.canvas-composer-model-picker[aria-expande
line-height: 1; line-height: 1;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
outline: none !important; outline: none;
padding: 0 9px; padding: 0 9px;
text-align: left; text-align: left;
transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out; transition: none;
} }
.canvas-composer-model-picker:hover { .canvas-composer-model-picker:hover {
@@ -267,7 +266,11 @@ html:not(.dark) .creation-model-picker.canvas-composer-model-picker[aria-expande
.canvas-composer-model-picker[aria-expanded="true"] { .canvas-composer-model-picker[aria-expanded="true"] {
background: color-mix(in srgb, var(--workspace-surface-strong) 84%, var(--workspace-accent) 16%); background: color-mix(in srgb, var(--workspace-surface-strong) 84%, var(--workspace-accent) 16%);
box-shadow: none !important; box-shadow: none !important;
outline: none !important; }
.canvas-composer-model-picker:focus-visible {
outline: 2px solid var(--control-focus-ring);
outline-offset: 2px;
} }
.canvas-model-picker-label { .canvas-model-picker-label {
@@ -289,7 +292,7 @@ html:not(.dark) .creation-model-picker.canvas-composer-model-picker[aria-expande
color: color-mix(in srgb, var(--foreground) 52%, transparent); color: color-mix(in srgb, var(--foreground) 52%, transparent);
flex: 0 0 14px; flex: 0 0 14px;
height: 14px; height: 14px;
transition: transform var(--motion-instant) ease-out; transition: none;
width: 14px; width: 14px;
} }
@@ -345,7 +348,7 @@ html:not(.dark) .creation-model-picker.canvas-composer-model-picker[aria-expande
overflow: hidden; overflow: hidden;
padding: 6px 7px; padding: 6px 7px;
text-align: left; text-align: left;
transition: background-color var(--motion-instant) ease-out; transition: none;
width: 100%; width: 100%;
} }
@@ -495,7 +498,7 @@ html:not(.dark) .canvas-composer-model-picker[aria-expanded="true"] {
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
box-shadow: none; box-shadow: none;
transition: color var(--motion-state) ease, background-color var(--motion-state) ease, transform var(--motion-state) var(--motion-ease-out); transition: none;
} }
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover, .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover,
@@ -885,13 +888,12 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
border-radius: 999px !important; border-radius: 999px !important;
background: var(--user-surface-muted) !important; background: var(--user-surface-muted) !important;
color: var(--user-ink-muted) !important; color: var(--user-ink-muted) !important;
transition: background var(--user-motion), color var(--user-motion), transform var(--user-motion), box-shadow var(--user-motion) !important; transition: none !important;
} }
.app-user-workspace .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover, .app-user-workspace .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover,
.app-user-workspace .creation-chat-composer .creation-model-picker.canvas-composer-model-picker[aria-expanded="true"] { .app-user-workspace .creation-chat-composer .creation-model-picker.canvas-composer-model-picker[aria-expanded="true"] {
background: var(--user-surface-hover) !important; background: var(--user-surface-hover) !important;
color: var(--user-ink) !important; color: var(--user-ink) !important;
transform: translateY(-1px);
box-shadow: 0 8px 18px color-mix(in srgb, var(--user-ink) 8%, transparent); box-shadow: 0 8px 18px color-mix(in srgb, var(--user-ink) 8%, transparent);
} }
.app-user-workspace .creation-launchpad .creation-model-picker.canvas-composer-model-picker { .app-user-workspace .creation-launchpad .creation-model-picker.canvas-composer-model-picker {
@@ -911,7 +913,7 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brands { display: grid; gap: 5px; } .app-user-workspace .creation-model-picker-menu .canvas-model-picker-brands { display: grid; gap: 5px; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-subheading { display: flex; align-items: baseline; justify-content: space-between; padding: 3px 5px 8px; color: var(--user-ink); font-size: 12px; font-weight: 650; } .app-user-workspace .creation-model-picker-menu .canvas-model-picker-subheading { display: flex; align-items: baseline; justify-content: space-between; padding: 3px 5px 8px; color: var(--user-ink); font-size: 12px; font-weight: 650; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-subheading small { color: var(--user-ink-soft); font-size: 10px; font-weight: 500; } .app-user-workspace .creation-model-picker-menu .canvas-model-picker-subheading small { color: var(--user-ink-soft); font-size: 10px; font-weight: 500; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand { display: flex; min-height: 58px; align-items: center; gap: 10px; border: 1px solid transparent; border-radius: 13px; padding: 8px 10px; background: transparent; color: var(--user-ink); text-align: left; transition: background-color 160ms ease, border-color 160ms ease; } .app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand { display: flex; min-height: 58px; align-items: center; gap: 10px; border: 1px solid transparent; border-radius: 13px; padding: 8px 10px; background: transparent; color: var(--user-ink); text-align: left; transition: none; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 11px; background: var(--user-surface-muted); color: var(--user-ink-muted); } .app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 11px; background: var(--user-surface-muted); color: var(--user-ink-muted); }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy { display: grid; min-width: 0; flex: 1; gap: 4px; } .app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy { display: grid; min-width: 0; flex: 1; gap: 4px; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy strong { overflow: hidden; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } .app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy strong { overflow: hidden; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
@@ -943,7 +945,7 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
.creation-model-picker-surface .canvas-model-picker-brands { display: grid; gap: 5px; } .creation-model-picker-surface .canvas-model-picker-brands { display: grid; gap: 5px; }
.creation-model-picker-surface .canvas-model-picker-subheading { display: flex; align-items: baseline; justify-content: space-between; padding: 3px 5px 8px; color: var(--foreground); font-size: 12px; font-weight: 650; } .creation-model-picker-surface .canvas-model-picker-subheading { display: flex; align-items: baseline; justify-content: space-between; padding: 3px 5px 8px; color: var(--foreground); font-size: 12px; font-weight: 650; }
.creation-model-picker-surface .canvas-model-picker-subheading small { color: color-mix(in srgb, var(--foreground) 52%, transparent); font-size: 10px; font-weight: 500; } .creation-model-picker-surface .canvas-model-picker-subheading small { color: color-mix(in srgb, var(--foreground) 52%, transparent); font-size: 10px; font-weight: 500; }
.creation-model-picker-surface .canvas-model-picker-brand { display: flex; min-height: 58px; align-items: center; gap: 10px; border: 1px solid transparent; border-radius: 13px; padding: 8px 10px; background: transparent; color: var(--foreground); text-align: left; transition: background-color 160ms ease, border-color 160ms ease; } .creation-model-picker-surface .canvas-model-picker-brand { display: flex; min-height: 58px; align-items: center; gap: 10px; border: 1px solid transparent; border-radius: 13px; padding: 8px 10px; background: transparent; color: var(--foreground); text-align: left; transition: none; }
.creation-model-picker-surface .canvas-model-picker-brand-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 11px; background: var(--workspace-surface-hover); color: var(--foreground); } .creation-model-picker-surface .canvas-model-picker-brand-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 11px; background: var(--workspace-surface-hover); color: var(--foreground); }
.creation-model-picker-surface .canvas-model-picker-brand-copy { display: grid; min-width: 0; flex: 1; gap: 4px; } .creation-model-picker-surface .canvas-model-picker-brand-copy { display: grid; min-width: 0; flex: 1; gap: 4px; }
.creation-model-picker-surface .canvas-model-picker-brand-copy strong { overflow: hidden; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } .creation-model-picker-surface .canvas-model-picker-brand-copy strong { overflow: hidden; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }