From cfdf08d05fab0c93736f977e61cce02b3ca19f06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=88=B1=E7=AC=91=E7=9A=84=E6=AF=9B=E6=AF=9B=E8=99=AB?= <315515767@qq.com> Date: Fri, 14 Aug 2026 12:01:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E5=89=8D=E7=AB=AF=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E4=B8=8E=E6=B8=A0=E9=81=93=E5=85=BC=E5=AE=B9=20-=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=B7=A5=E4=BD=9C=E5=8C=BA=E3=80=81=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E4=B8=AD=E5=BF=83=E7=AD=89=E5=A4=9A=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=99=BA=E8=83=BD=E4=BD=93=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E4=B8=8E=E8=8A=82=E7=82=B9=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: sikongyue <3155157671@qq.com> Original-PR: https://github.com/ddcat-ai/open-ai-canvas/pull/217 --- backend/internal/handler/custom_proxy.go | 7 +- backend/internal/service/resource.go | 2 +- web/src/components/auth/require-feature.tsx | 12 +- .../canvas/canvas-assistant-panel.tsx | 2 +- .../canvas/canvas-local-agent-panel.tsx | 6 +- .../canvas/canvas-node-prompt-panel.tsx | 6 +- .../canvas/canvas-project-asset-modal.tsx | 2 +- .../canvas/canvas-project-sidebar.tsx | 20 +- .../components/canvas/canvas-script-node.tsx | 7 +- web/src/components/layout/app-top-nav.tsx | 177 +- web/src/components/layout/workspace-page.tsx | 12 +- .../layout/workspace-sidebar-footer.tsx | 23 +- .../components/layout/workspace-top-bar.tsx | 36 + .../lib/canvas/canvas-project-generation.ts | 13 +- .../pages/admin/components/admin-shell.tsx | 10 +- web/src/pages/assets/index.tsx | 376 ++- .../pages/canvas/canvas-project-top-bar.tsx | 2 +- web/src/pages/canvas/index.tsx | 9 +- web/src/pages/create/index.tsx | 364 +- web/src/pages/home/index.tsx | 195 +- web/src/pages/projects/detail.tsx | 10 +- web/src/pages/projects/detail/assets.tsx | 6 +- web/src/pages/projects/detail/canvases.tsx | 2 +- web/src/pages/projects/detail/chapters.tsx | 14 +- web/src/pages/projects/index.tsx | 329 +- web/src/pages/settings/index.tsx | 68 +- web/src/pages/skills/index.tsx | 4 +- web/src/pages/skills/skill-detail-drawer.tsx | 2 +- web/src/pages/tasks/index.tsx | 374 ++- web/src/pages/tasks/task-grid-card.tsx | 67 + web/src/pages/tasks/task-group-header.tsx | 43 + web/src/pages/tasks/task-list-row.tsx | 128 + web/src/pages/tasks/task-shared.tsx | 90 + web/src/pages/tasks/task-stat-pills.tsx | 36 + web/src/pages/wallet/index.tsx | 65 +- web/src/services/api/image.ts | 45 +- web/src/styles/globals.css | 2947 +++++++++++++++-- 37 files changed, 4599 insertions(+), 912 deletions(-) create mode 100644 web/src/components/layout/workspace-top-bar.tsx create mode 100644 web/src/pages/tasks/task-grid-card.tsx create mode 100644 web/src/pages/tasks/task-group-header.tsx create mode 100644 web/src/pages/tasks/task-list-row.tsx create mode 100644 web/src/pages/tasks/task-shared.tsx create mode 100644 web/src/pages/tasks/task-stat-pills.tsx diff --git a/backend/internal/handler/custom_proxy.go b/backend/internal/handler/custom_proxy.go index 3dcfe6221..115876316 100644 --- a/backend/internal/handler/custom_proxy.go +++ b/backend/internal/handler/custom_proxy.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io" "mime" "net/http" @@ -170,7 +171,11 @@ func writeCustomRelayError(c *gin.Context, resp *http.Response, apiKey string, m c.Data(resp.StatusCode, "application/json; charset=utf-8", body) return } - fail(c, resp.StatusCode, errors.New("自定义渠道上游请求失败")) + snippet := strings.TrimSpace(string(body)) + if len(snippet) > 200 { + snippet = snippet[:200] + "..." + } + fail(c, resp.StatusCode, fmt.Errorf("自定义渠道上游请求失败(%s)%s", resp.Status, snippet)) } func copyCustomRelayStream(c *gin.Context, source io.Reader, apiKey string, maxBytes int64) { diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go index a770c63e9..9f782abf6 100644 --- a/backend/internal/service/resource.go +++ b/backend/internal/service/resource.go @@ -143,7 +143,7 @@ func (s *Service) publicResourceBaseURL() (*url.URL, error) { } raw := firstNonEmpty(setting.PublicBaseURL, os.Getenv("CANVAS_PUBLIC_BASE_URL")) if raw == "" { - return nil, BadAuthRequest("服务器本地存储尚未配置服务器访问地址") + return nil, BadAuthRequest("服务器本地存储尚未配置服务器访问地址,请设置 CANVAS_PUBLIC_BASE_URL 或在存储设置中配置公网访问地址(或改用 OSS 存储)") } return validatePublicResourceBaseURL(raw) } diff --git a/web/src/components/auth/require-feature.tsx b/web/src/components/auth/require-feature.tsx index 9537b04d0..58d93166a 100644 --- a/web/src/components/auth/require-feature.tsx +++ b/web/src/components/auth/require-feature.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, type ReactNode } from "react"; import { Button } from "antd"; -import { useLocation, useNavigate } from "react-router"; +import { useNavigate } from "react-router"; import { WorkspacePage } from "@/components/layout/workspace-page"; import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state"; @@ -15,16 +15,18 @@ const featureNames: Record = { creditsEnabled: "积分中心", }; +let featureAvailabilityCheckedOnce = false; + export function RequireFeature({ feature, children }: { feature: FeatureKey; children: ReactNode }) { - const location = useLocation(); const navigate = useNavigate(); const features = useUserStore((state) => state.features); - const [checking, setChecking] = useState(true); + const [checking, setChecking] = useState(() => !useUserStore.getState().features[feature]); const [error, setError] = useState(""); useEffect(() => { + if (featureAvailabilityCheckedOnce) return; + featureAvailabilityCheckedOnce = true; let cancelled = false; - setChecking(true); setError(""); refreshFeatureAvailability() .catch((reason) => { @@ -36,7 +38,7 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi return () => { cancelled = true; }; - }, [location.pathname]); + }, []); if (checking) return ; if (error) return navigate("/create", { replace: true })} />; diff --git a/web/src/components/canvas/canvas-assistant-panel.tsx b/web/src/components/canvas/canvas-assistant-panel.tsx index 7a16c8ba8..11530fa7e 100644 --- a/web/src/components/canvas/canvas-assistant-panel.tsx +++ b/web/src/components/canvas/canvas-assistant-panel.tsx @@ -750,7 +750,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project {agentBusy ? : null} ) : ( - + { setPrompt(text); void sendMessage(text, messages); }} /> )} )} diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index 18fc8521d..84e36b1da 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -172,8 +172,8 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps return () => clearTimeout(timer); }, [connected, endpoint, snapshot, token]); - const sendPrompt = async () => { - const text = prompt.trim(); + const sendPrompt = async (overrideText?: string) => { + const text = (overrideText ?? prompt).trim(); const files = attachments; const requestPrompt = promptWithAttachments(text, files); if (!connected || !requestPrompt || sending || waiting) return; @@ -545,7 +545,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps ) : ( <>
- {!messages.length && !pendingTool && !waiting ? setAgentState({ prompt })} /> : null} + {!messages.length && !pendingTool && !waiting ? { setAgentState({ prompt: text }); void sendPrompt(text); }} /> : null} {messages.map((item) => ( ))} diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 4803d31b3..e158542fc 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -3,7 +3,8 @@ import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Ma import { Button, Image as AntImage, Modal, Tooltip } from "antd"; import { ModelPicker } from "@/components/model-picker"; -import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; +import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; +import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation"; import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { canvasThemes } from "@/lib/canvas-theme"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; @@ -475,8 +476,7 @@ function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode { function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig { const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel; const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel; - const storedModel = node.metadata?.model; - const model = storedModel && configuredModelMatchesCapability(globalConfig, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(globalConfig, defaultModel, mode) ? defaultModel : fallbackModel; + const model = resolveCanvasGenerationModel(globalConfig, node.metadata?.model, mode) || resolveCanvasGenerationModel(globalConfig, defaultModel, mode) || fallbackModel; return { ...globalConfig, model, diff --git a/web/src/components/canvas/canvas-project-asset-modal.tsx b/web/src/components/canvas/canvas-project-asset-modal.tsx index e3e38b8ae..9302c196c 100644 --- a/web/src/components/canvas/canvas-project-asset-modal.tsx +++ b/web/src/components/canvas/canvas-project-asset-modal.tsx @@ -51,7 +51,7 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",

引用项目角色与资产

已选 {selectedIds.size} 项
- +
{visible.length ?
{visible.map((item) => toggle(item.id)} />)}
: }
角色引用会在生成时解析当前角色版本
diff --git a/web/src/components/canvas/canvas-project-sidebar.tsx b/web/src/components/canvas/canvas-project-sidebar.tsx index b7d73f590..4ad72af7b 100644 --- a/web/src/components/canvas/canvas-project-sidebar.tsx +++ b/web/src/components/canvas/canvas-project-sidebar.tsx @@ -103,10 +103,10 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate if (collapsed) { return ( @@ -120,7 +120,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate {projectDetail?.project.name || "项目空间"} - @@ -131,7 +131,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate 项目画风 - +
@@ -139,7 +139,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate type="button" disabled={!style} onClick={onLocateStyle} - className="group flex h-11 w-full items-center gap-2 rounded-md px-1.5 text-left hover:bg-foreground/[.045] disabled:cursor-default disabled:hover:bg-transparent" + className="group flex h-11 w-full items-center gap-2 rounded-md px-1.5 text-left hover:bg-surface-hover disabled:cursor-default disabled:hover:bg-transparent" title={style ? "定位画布中的项目画风节点" : "项目尚未设置画风"} > {style ? ( @@ -164,7 +164,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate 剧情章节 {orderedUnits.length.toLocaleString("zh-CN")} {selectedId ? ( - ) : null} @@ -183,7 +183,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate aria-label="搜索项目章节" /> {query ? ( - ) : null} @@ -208,7 +208,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate event.dataTransfer.setData(CANVAS_PROJECT_CHAPTER_DND_TYPE, JSON.stringify(payload)); event.dataTransfer.effectAllowed = "copy"; }} - className={`group flex h-9 cursor-grab items-center gap-1 rounded-md px-1 text-xs active:cursor-grabbing ${active ? "bg-foreground/[.08]" : "hover:bg-foreground/[.045]"}`} + className={`group flex h-9 cursor-grab items-center gap-1 rounded-md px-1 text-xs active:cursor-grabbing ${active ? "bg-surface-active" : "hover:bg-surface-hover"}`} >

{unit.title}

- @@ -312,7 +312,7 @@ function ChapterPreview({ > {adding ? : }添加到画布 - + 打开编辑器 diff --git a/web/src/components/canvas/canvas-script-node.tsx b/web/src/components/canvas/canvas-script-node.tsx index 5d06f541e..121f312c3 100644 --- a/web/src/components/canvas/canvas-script-node.tsx +++ b/web/src/components/canvas/canvas-script-node.tsx @@ -115,6 +115,7 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR ? `${node.metadata.taskStage || "正在创建任务"}${typeof node.metadata.taskProgress === "number" ? ` · ${node.metadata.taskProgress}%` : ""}` : node.metadata?.status === "error" ? generationErrorMessage(node.metadata.errorDetails) : ""; const [batchDetailsOpen, setBatchDetailsOpen] = useState(false); + const [moreMenuOpen, setMoreMenuOpen] = useState(false); const pipelineDisabled = !rows.length || node.metadata?.status === "loading" || hasActiveBatchItems; const missingImages = Math.max(0, pipeline.images.total - pipeline.images.created); const missingVideos = Math.max(0, pipeline.videos.total - pipeline.videos.created); @@ -164,9 +165,9 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR {node.title || "分镜脚本"} {batchSummary ? {batchSummary} : taskFeedback ? {taskFeedback} : null} {rows.length} 镜 · {totalDuration}s - - - + + setMoreMenuOpen(false) }} trigger={["click"]} placement="bottomRight"> + {batch ? setBatchDetailsOpen(false)} footer={null} width={560} centered destroyOnHidden> diff --git a/web/src/components/layout/app-top-nav.tsx b/web/src/components/layout/app-top-nav.tsx index 15f086465..d3f773687 100644 --- a/web/src/components/layout/app-top-nav.tsx +++ b/web/src/components/layout/app-top-nav.tsx @@ -1,9 +1,10 @@ -import { Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen } from "lucide-react"; -import { Fragment, useEffect, useRef, useState, type ReactNode } from "react"; +import { Home, Infinity as InfinityIcon } from "lucide-react"; +import { Fragment, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { Link, useLocation, useNavigate } from "react-router"; import { ModelSetupGuide } from "@/components/layout/model-setup-guide"; import { WorkspaceSidebarFooter } from "@/components/layout/workspace-sidebar-footer"; +import { WorkspaceTopBar } from "@/components/layout/workspace-top-bar"; import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools"; import { cn } from "@/lib/utils"; import { refreshFeatureAvailability } from "@/lib/user-session"; @@ -16,12 +17,14 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) { const user = useUserStore((state) => state.user); const features = useUserStore((state) => state.features); const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false); + const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = useState(false); const scrollRef = useRef(null); const [scrollState, setScrollState] = useState({ hasTopFade: false, hasBottomFade: false, }); const hideChrome = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname); + const showGlobalTopBar = !hideChrome; const spatialWorkbench = isSpatialWorkbenchPath(pathname); const creationWorkspace = pathname === "/create"; const slug = pathname.split("/").filter(Boolean)[0]; @@ -33,6 +36,39 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) { if (tool.slug === "wallet") return features.creditsEnabled; return true; }); + const topRailNavTools = visibleNavigationTools.filter((tool) => tool.section === "创作空间" || tool.slug === "skills"); + const bottomRailNavTools = visibleNavigationTools.filter((tool) => tool.slug === "wallet"); + + const toggleSidebar = () => { + if (window.innerWidth < 1024) { + setMobileSidebarExpanded((current) => !current); + return; + } + setDesktopSidebarCollapsed((current) => !current); + }; + + const handleNavClick = () => { + if (window.innerWidth < 1024) setMobileSidebarExpanded(false); + }; + + const renderSidebarLink = (tool: (typeof navigationTools)[number]) => { + const Icon = tool.icon; + const active = tool.slug === activeToolSlug; + return ( + + + + ); + }; const handleScroll = () => { const element = scrollRef.current; @@ -72,84 +108,89 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) { return ( <> -
+
{!hideChrome && mobileSidebarExpanded ? - ) : null} {children}
+