From 64f32d3607c7563eed0f0c19db28b08488137d2a Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sun, 2 Aug 2026 14:52:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(platform):=20=E5=A4=9A=E6=A8=A1=E6=80=81?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=20-=20=E6=96=B0=E5=A2=9E=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=E9=A1=B5=E3=80=81=E5=8E=86=E5=8F=B2=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E4=B8=8E=E8=A7=86=E9=A2=91=E5=8F=82=E6=95=B0=E6=8E=A7?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/provider.go | 16 +- web/src/components/layout/app-top-nav.tsx | 43 +- web/src/components/model-picker.tsx | 23 +- web/src/constant/navigation-tools.ts | 8 +- web/src/lib/seedance-video.ts | 5 +- web/src/lib/video-generation-options.ts | 3 +- web/src/pages/create/index.tsx | 450 ++++ web/src/router.tsx | 5 +- web/src/services/api/video.ts | 13 +- web/src/styles/globals.css | 2830 +++++++++++++++++++++ 10 files changed, 3348 insertions(+), 48 deletions(-) create mode 100644 web/src/pages/create/index.tsx diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 62e8ba579..6f891db51 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -1278,6 +1278,10 @@ func normalizeNewAPIChannel2Resolution(value string, modelName string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "480", "480p", "low": return "480p" + case "1080", "1080p": + return "1080p" + case "2160", "2160p", "4k": + return "2160p" default: return "720p" } @@ -2313,6 +2317,9 @@ func normalizeVideoResolution(value string) string { if value == "low" { return "480p" } + if strings.EqualFold(value, "4k") { + return "2160p" + } if strings.HasSuffix(value, "p") { return value } @@ -2333,6 +2340,8 @@ func normalizeXAIVideoResolution(value string) string { return "480p" case "1080", "1080p": return "1080p" + case "2160", "2160p", "4k": + return "2160p" default: return "720p" } @@ -2422,8 +2431,11 @@ func normalizeSeedanceVideosRatio(value string) string { func normalizeSeedanceResolution(value string, model string) string { resolution := strings.TrimSuffix(strings.TrimSpace(value), "p") + if strings.EqualFold(resolution, "4k") { + resolution = "2160" + } switch resolution { - case "480", "720", "1080": + case "480", "720", "1080", "2160": default: if value == "low" { resolution = "480" @@ -2431,7 +2443,7 @@ func normalizeSeedanceResolution(value string, model string) string { resolution = "720" } } - if strings.Contains(strings.ToLower(model), "fast") && resolution == "1080" { + if strings.Contains(strings.ToLower(model), "fast") && (resolution == "1080" || resolution == "2160") { resolution = "720" } return resolution + "p" diff --git a/web/src/components/layout/app-top-nav.tsx b/web/src/components/layout/app-top-nav.tsx index 3e9fc2690..13595fe48 100644 --- a/web/src/components/layout/app-top-nav.tsx +++ b/web/src/components/layout/app-top-nav.tsx @@ -1,9 +1,8 @@ -import { Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { Infinity as InfinityIcon } from "lucide-react"; import { Fragment, useEffect, useState, type ReactNode } from "react"; import { Link, useLocation, useNavigate } from "react-router"; import { ModelSetupGuide } from "@/components/layout/model-setup-guide"; -import { WORKSPACE_SIDEBAR_STORAGE_KEY } from "@/components/layout/workspace-sidebar-state"; import { WorkspaceSidebarFooter } from "@/components/layout/workspace-sidebar-footer"; import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools"; import { cn } from "@/lib/utils"; @@ -13,18 +12,13 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) { const { pathname } = useLocation(); const navigate = useNavigate(); const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false); - const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem(WORKSPACE_SIDEBAR_STORAGE_KEY) === "1"); const hideChrome = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname); const spatialWorkbench = isSpatialWorkbenchPath(pathname); - const compactSidebar = sidebarCollapsed; + const creationWorkspace = pathname === "/create"; const slug = pathname.split("/").filter(Boolean)[0]; const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined; const visibleNavigationTools = spatialWorkbench ? navigationTools : navigationTools.filter((tool) => tool.section === "创作空间"); - useEffect(() => { - window.localStorage.setItem(WORKSPACE_SIDEBAR_STORAGE_KEY, sidebarCollapsed ? "1" : "0"); - }, [sidebarCollapsed]); - useEffect(() => { const handleWorkspaceNavigation = (rawEvent: Event) => { const event = rawEvent as CustomEvent<{ to?: string }>; @@ -38,34 +32,21 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) { return ( <> -
+
{spatialWorkbench && mobileSidebarExpanded ?
diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index cabdc8125..c07f13852 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -17,9 +17,10 @@ type ModelPickerProps = { placeholder?: string; onMissingConfig?: () => void; showSelectedPrice?: boolean; + variant?: "default" | "creation"; }; -export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true }: ModelPickerProps) { +export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default" }: ModelPickerProps) { const pickerId = useId(); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [open, setOpen] = useState(false); @@ -44,6 +45,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu }, [config, options]); const current = value || ""; const currentPrice = modelMenuPrice(config, current); + const creationVariant = variant === "creation"; useEffect(() => { const closeOtherPicker = (event: Event) => { @@ -90,7 +92,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > + {creationVariant ?
选择模型{current ? {modelDisplayName(config, current)} : null}
: null} {optionGroups.length ? optionGroups.map((group) => (
@@ -121,7 +124,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu window.requestAnimationFrame(() => triggerRef.current?.focus()); }} > - + {selected ? : null} ); @@ -145,7 +148,11 @@ export function ModelPicker({ config, value, onChange, capability, className, fu placement="bottomLeft" arrow={false} content={content} - classNames={{ root: "canvas-model-picker-popover", container: "canvas-composer-popover-surface", content: "canvas-composer-popover-content" }} + classNames={{ + root: cn("canvas-model-picker-popover", creationVariant && "creation-model-picker-popover"), + container: cn("canvas-composer-popover-surface", creationVariant && "creation-model-picker-surface"), + content: "canvas-composer-popover-content", + }} > : null} + +
+ {isEmpty ?
+ +
+
: <> +
{activeConversation.messages.map((item, index) => retryMessage(item, index)} />)}
+
+ +
+ } + + setHistoryOpen(false)} onSelect={selectConversation} /> + ; +} + +function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void }) { + return } className="creation-history-drawer" rootClassName="creation-history-drawer-root" styles={{ body: { padding: 0 } }} title={
历史对话{conversations.length} 个对话
}> +
    + {conversations.map((conversation) => { + const latest = conversationPreviewMessage(conversation); + const active = conversation.id === activeId; + return
  1. +
  2. ; + })} +
+
; +} + +function CreationMessageView({ item, onRetry }: { item: CreationMessage; onRetry: () => void }) { + if (item.role === "user") return
{item.content}
{item.attachments?.length ?
{item.attachments.map((attachment) => {attachment.name})}
: null}
; + const mode = item.mode || "text"; + const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : ""; + return
{modeLabels[mode]}{item.model ? {modelOptionName(item.model)} : null}{stateLabel ? {stateLabel} : null}
{mode === "text" ?
{item.content ? {item.content} : 正在生成…}
: }{item.error ?
{item.error}
: null}
; +} + +function MediaResult({ item, onRetry }: { item: CreationMessage; onRetry: () => void }) { + const [previewUrl, setPreviewUrl] = useState(""); + const [previewType, setPreviewType] = useState<"image" | "video">("image"); + const openPreview = (url: string, type: "image" | "video") => { setPreviewType(type); setPreviewUrl(url); }; + if (item.status === "pending") return
正在生成{item.mode === "video" ? "视频" : "图片"}…
; + if (!item.resultUrls?.length) return
没有返回可预览结果
; + return
{item.mode === "video" ? :
{item.resultUrls.map((url) => )}
}
{item.mode === "video" ? "视频结果" : `${item.resultUrls.length} 张图片`}添加到画布{item.resultUrls.map((url, index) => {item.resultUrls.length > 1 ? `下载 ${index + 1}` : <>下载})}
setPreviewUrl("")} className="creation-media-preview-modal" styles={{ body: { padding: 0 } }}>{previewUrl ? previewType === "video" ?
; +} + +type ComposerProps = { + variant: "empty" | "thread"; + mode: CreationMode; + prompt: string; + setPrompt: (value: string) => void; + busy: boolean; + attachments: CreationAttachment[]; + onRemoveAttachment: (id: string) => void; + fileInputRef: RefObject; + onFileChange: (event: ChangeEvent) => void; + onModeChange: (mode: CreationMode) => void; + model: string; + config: ReturnType; + onModelChange: (value: string) => void; + ratio: string; + setRatio: (value: string) => void; + seconds: string; + setSeconds: (value: string) => void; + quality: string; + setQuality: (value: string) => void; + videoQuality: string; + setVideoQuality: (value: string) => void; + count: string; + setCount: (value: string) => void; + onSubmit: () => void; + onStop: () => void; +}; + +function CreationComposer(props: ComposerProps) { + const canSubmit = Boolean(props.prompt.trim()) && !props.busy; + const placeholder = props.mode === "text" + ? "描述你的故事、角色或想继续讨论的创意" + : props.mode === "image" + ? "描述画面、人物、场景、构图与风格" + : "描述镜头内容、运动、光线与节奏"; + const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作"; + return
+
+ + +
+