From 17e9f5ed4e9c3d17d128530cc198a8e54f741888 Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:45:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(workbench):=20=E4=BC=98=E5=8C=96=E7=94=B5?= =?UTF-8?q?=E5=95=86=E5=87=BA=E5=93=81=E5=B7=A5=E4=BD=9C=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/docs/progress/pending-test.mdx | 8 + web/src/components/model-picker.tsx | 20 +- web/src/pages/create/creation-product.css | 7 +- .../pages/workbench/image-workbench-page.tsx | 258 ++++- web/src/pages/workbench/workbench.css | 904 +++++++++++++++--- web/src/styles/shared/model-picker.css | 34 +- 6 files changed, 1050 insertions(+), 181 deletions(-) diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 0989dfffa..62cabda8f 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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`,确认快捷目标卡使用真实工具、图片/视频切换、工具弹层、素材上传和生成提交均正常。 +- [ ] 用户验收:明暗主题、窄屏滚动、不同工具数量、工具封面缺失、生成中/成功/失败结果状态下,确认布局和色彩层级符合预期。 + ## 灵感库中文提示词 - 已实现:内置目录与首页画廊的提示词统一为中文;复用来源中文正文,补齐外文翻译;导入时校验原文摘要,缺译或旧译文阻止覆盖。 diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 4c6d4f6c5..59e7f77eb 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -138,7 +138,7 @@ export function ModelPicker({ window.requestAnimationFrame(() => { const buttons = menuRef.current?.querySelectorAll('[data-model-picker-item]:not(:disabled)'); const target = last ? buttons?.item((buttons?.length || 1) - 1) : buttons?.item(0); - target?.focus(); + focusPickerOption(target); }); }; const handleTriggerKeyDown = (event: KeyboardEvent) => { @@ -151,7 +151,7 @@ export function ModelPicker({ if (event.key === "Escape") { event.preventDefault(); setOpen(false); - triggerRef.current?.focus(); + triggerRef.current?.focus({ preventScroll: true }); return; } if (event.key === "ArrowLeft" && activeGroupKey !== null) { @@ -166,7 +166,7 @@ export function ModelPicker({ event.preventDefault(); 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); - buttons[nextIndex]?.focus(); + focusPickerOption(buttons[nextIndex]); }; const content = (
(".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) { const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : ""; if (capability && config.models.length) return `暂无支持当前输入的${label}模型`; diff --git a/web/src/pages/create/creation-product.css b/web/src/pages/create/creation-product.css index bb2c76928..adb9f1dd2 100644 --- a/web/src/pages/create/creation-product.css +++ b/web/src/pages/create/creation-product.css @@ -98,10 +98,9 @@ transform: translateY(-1px); 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-model-picker .canvas-model-picker-trigger-icon { transition: transform var(--user-motion), color var(--user-motion); color: currentColor; } -.app-user-workspace .creation-chat-control:hover > svg:first-child, -.app-user-workspace .creation-model-picker:hover .canvas-model-picker-trigger-icon { transform: scale(1.06); } +.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 { color: currentColor; } +.app-user-workspace .creation-chat-control:hover > svg:first-child { 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-launchpad .creation-reference-add-button:hover:not(:disabled) { border-style: solid; diff --git a/web/src/pages/workbench/image-workbench-page.tsx b/web/src/pages/workbench/image-workbench-page.tsx index 2d2634df8..761765973 100644 --- a/web/src/pages/workbench/image-workbench-page.tsx +++ b/web/src/pages/workbench/image-workbench-page.tsx @@ -1,5 +1,5 @@ -import { App, Button, Input, InputNumber, Modal, Popover, 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 { App, Button, Input, InputNumber, Modal, Progress, Segmented, Select, Spin, Switch, Tooltip } from "antd"; +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 { useSearchParams } from "react-router"; @@ -22,7 +22,7 @@ import { capabilitySupportedModes, createWorkbenchTask, listWorkbenchTasks, list import { resourceStorageKey, resolveResourceUrl, uploadResourceFile } from "@/services/api/resources"; import { formatCredits, requestCreditCost } from "@/constant/credits"; 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 { workbenchCategoryLabel, workbenchCategories } from "./workbench-catalog"; @@ -136,6 +136,32 @@ function revokeReferencePreview(reference: WorkbenchReference) { 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: , tone: "people", label: "模特与人物" }; + if (text.includes("视频") || kind === "video") return { icon: , tone: "video", label: "商品视频" }; + if (text.includes("场景") || text.includes("氛围") || text.includes("背景")) return { icon: , tone: "scene", label: "场景与氛围" }; + if (text.includes("风格") || text.includes("海报") || text.includes("视觉")) return { icon: , tone: "style", label: "风格与视觉" }; + if (text.includes("主图") || text.includes("商品") || text.includes("电商") || text.includes("白底")) return { icon: , tone: "product", label: "商品出品" }; + return { icon: kind === "image" ? : , 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 }) { const { message } = App.useApp(); const config = useEffectiveConfig(); @@ -159,6 +185,8 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { }; useEffect(() => () => referencesRef.current.forEach(revokeReferencePreview), []); const [activeTask, setActiveTask] = useState(null); + const [resultHidden, setResultHidden] = useState(false); + const workbenchRevisionRef = useRef(0); const [tasks, setTasks] = useState([]); const [tasksLoading, setTasksLoading] = useState(true); const [detailTask, setDetailTask] = useState(null); @@ -194,6 +222,10 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { const imageProfile = profile.image; const videoProfile = profile.video; 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 missingInput = missingWorkbenchInput(tool, references); 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 timer = 0; const poll = async () => { + const revision = workbenchRevisionRef.current; const result = await loadTasks(); if (stopped) return; - if (activeTask) { + if (activeTask && revision === workbenchRevisionRef.current) { const latest = result.find((item) => item.id === activeTask.id); if (latest) { setActiveTask(latest); @@ -396,18 +429,55 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { }; }, [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) => { references.forEach(revokeReferencePreview); setReferences([]); setUploadSlotKey(undefined); setTool(next); - setPreset(null); setToolLibraryOpen(false); + setPreset(null); + setPresetPending(null); const params = new URLSearchParams(searchParams); if (next) params.set("tool", next.id); else params.delete("tool"); params.delete("preset"); 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) => { @@ -477,6 +547,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { }; const submit = async () => { + const revision = workbenchRevisionRef.current; const submittedPrompt = normalizeCanvasNodeMentionTokens(prompt.trim(), promptReferences); if (tool?.inputContract?.promptRequired && !submittedPrompt) { message.warning("当前工具需要填写提示词"); @@ -588,9 +659,12 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { }, }); const task = await queryGenerationTask(createdTask.id); - setActiveTask(task); setTasks((current) => [task, ...current.filter((item) => item.id !== task.id)]); - setMobileTab("result"); + if (revision === workbenchRevisionRef.current) { + setResultHidden(false); + setActiveTask(task); + setMobileTab("result"); + } message.success("生成任务已提交"); } catch (error) { message.error(error instanceof Error ? error.message : "任务提交失败"); @@ -672,6 +746,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { else params.delete("preset"); setSearchParams(params, { replace: true }); setActiveTask(task); + setResultHidden(false); setMobileTab("generate"); setDetailTask(null); if (notify) message.success("任务配置已载入编辑器"); @@ -725,7 +800,14 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { : 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 settingsPanel = kind === "image" && imageProfile ? ( @@ -857,7 +939,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { ) : (
{filteredTools.map((item) => ( - chooseTool(item)} /> + { chooseTool(item); setToolLibraryOpen(false); }} /> ))} {!filteredTools.length ?
没有找到匹配的创作工具
: null}
@@ -868,6 +950,26 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { return (
+
+ + + + {[ + ["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) => ( +
+ {state === "done" ? : number} + {label} + {index < 3 ? : null} +
+ ))} +
{kind === "image" ? "图片生成" : "视频生成"} - - - +
+ setToolLibraryOpen(false)} + footer={null} + width="min(1120px, calc(100vw - 32px))" + className="workbench-picker-modal" + > + {toolLibrary} + + {!tool && tools.length ? ( +
+
+
+ 01 +
+ 你现在要做什么? + 选择一个出品目标,参数和提示词会更快就位 +
+
+ +
+
+ {quickTools.map((item) => chooseTool(item)} />)} +
+
+ ) : null}
+
+ 03 +
+ 描述你想要的成品 + 说清商品、场景、构图、光线和需要保留的细节 +
+
+ {tool?.presets.length ? ( +
+ {tool.presets.slice(0, 5).map((item) => ( + + ))} +
+ ) : null}
)} -
- +
+ {estimateLabel || "预估可用积分"} + {selectedModel ? ( + 使用 {modelDisplayName(config, selectedModel)} + ) : null}
+
@@ -1143,7 +1289,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { )}
- {resultTask.model || "生成作品"} + {taskModelLabel(resultTask)} @@ -1226,7 +1372,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
{activeTasks.length ? ( - activeTasks.map((task) => void openDetail(task)} onCancel={() => void cancelTask(task)} />) + activeTasks.map((task) => void openDetail(task)} onCancel={() => void cancelTask(task)} />) ) : (

暂无进行中的任务

)} @@ -1247,7 +1393,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { historyTasks .filter((task) => !query.trim() || task.prompt.toLowerCase().includes(query.trim().toLowerCase())) .slice(0, 30) - .map((task) => void openDetail(task)} onRetry={() => void retryTask(task)} />) + .map((task) => void openDetail(task)} onRetry={() => void retryTask(task)} />) )} {!tasksLoading && !historyTasks.length ?

完成的作品会保留在这里

: null}
@@ -1283,7 +1429,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
{displayTaskState(detailTask)} -

{detailTask.model || "生成任务"}

+

{taskModelLabel(detailTask)}

{detailTask.prompt}

@@ -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 ( - + ); +} + +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 ( +