From 67074bfaf68cb548395d916b13ebee3414228e4e Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Mon, 10 Aug 2026 01:25:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E5=88=9B=E4=BD=9C=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E4=B8=8E=E4=B8=8A=E4=BC=A0=20-=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E9=A2=84=E8=A7=88=E5=B9=B6=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E7=8A=B6=E6=80=81=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../canvas/canvas-node-prompt-panel.tsx | 77 +++++++++++++------ web/src/pages/create/index.tsx | 21 ++++- web/src/styles/globals.css | 18 +++-- web/test/create-library-button.test.ts | 14 ++++ 4 files changed, 98 insertions(+), 32 deletions(-) diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 0b03f78fa..038b78260 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, Square, UserRound, Video } from "lucide-react"; -import { Button, Modal, Tooltip } from "antd"; +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"; @@ -372,30 +372,63 @@ function modeDisplayName(mode: CanvasNodeGenerationMode) { function ConnectedReferenceShelf({ references, theme, onInsert }: { references: CanvasResourceReference[]; theme: CanvasTheme; onInsert: (reference: CanvasResourceReference) => void }) { const activeReferences = references.filter((item) => item.active && item.kind !== "skill"); + const [imagePreview, setImagePreview] = useState(null); if (!activeReferences.length) return null; return ( -
- {activeReferences.map((reference, index) => ( - - ))} -
+ <> +
+ {activeReferences.map((reference, index) => { + const canPreview = (reference.kind === "image" || reference.kind === "character") && Boolean(reference.previewUrl); + return ( + + + {canPreview ? ( + + ) : null} + + ); + })} +
+ {imagePreview?.previewUrl ? ( + !open && setImagePreview(null), + }} + /> + ) : null} + ); } diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index a876fcb61..c275f86d0 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode, type RefObject } from "react"; import localforage from "localforage"; import { App, Drawer, Modal, Popover, Spin, Tooltip } from "antd"; -import { ArrowUp, Check, ChevronDown, Clock3, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Upload, X } from "lucide-react"; +import { ArrowUp, Check, ChevronDown, Clock3, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Upload, X } from "lucide-react"; import ReactMarkdown from "react-markdown"; import { Link } from "react-router"; @@ -299,6 +299,7 @@ export default function CreatePage() { })); const assetIds = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []); const failed = settled.filter((entry) => entry.status === "rejected"); + if (assetIds.length) toast.success(`${assetIds.length} 个素材已上传到素材库并自动选中`); if (failed.length) toast.error(`${failed.length} 个素材上传失败,请重试`); return assetIds; }; @@ -575,6 +576,7 @@ function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, o const [keyword, setKeyword] = useState(""); const [selected, setSelected] = useState>(new Set()); const [uploading, setUploading] = useState(false); + const [uploadingCount, setUploadingCount] = useState(0); const uploadInputRef = useRef(null); const mediaAssets = useMemo(() => assets.filter((asset): asset is Extract => asset.kind === "image" || asset.kind === "video"), [assets]); const categories = useMemo(() => ["all", ...Array.from(new Set(mediaAssets.map((asset) => asset.category || "other")))], [mediaAssets]); @@ -600,6 +602,9 @@ function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, o }; const handleUpload = async (event: ChangeEvent) => { if (!event.target.files || uploading) return; + const fileCount = event.target.files.length; + if (!fileCount) return; + setUploadingCount(fileCount); setUploading(true); try { const assetIds = await onUpload(event.target.files); @@ -607,19 +612,20 @@ function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, o } finally { event.target.value = ""; setUploading(false); + setUploadingCount(0); } }; const selectedAssets = mediaAssets.filter((asset) => selected.has(asset.id) && (mode === "video" || asset.kind === "image")); const count = category === "all" ? mediaAssets.length : mediaAssets.filter((asset) => (asset.category || "other") === category).length; - return + return { if (!uploading) onClose(); }} width="min(980px, calc(100vw - 24px))" className="creation-asset-library-modal" styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
参考内容素材库
setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" />
已选 {selectedAssets.length} · {count} 个素材
{visibleAssets.length ? visibleAssets.map((asset) => toggle(asset)} />) :
这个分类还没有素材换个分类,或从底部上传一份新素材。
}
-
+
; } @@ -748,6 +754,8 @@ type ComposerProps = { }; function CreationComposer(props: ComposerProps) { + const [previewUrl, setPreviewUrl] = useState(""); + const [previewType, setPreviewType] = useState<"image" | "video">("image"); const canSubmit = Boolean(props.prompt.trim()) && !props.busy; const placeholder = props.mode === "text" ? "描述你的故事、角色或想继续讨论的创意" @@ -764,7 +772,11 @@ function CreationComposer(props: ComposerProps) {
- {props.attachments.length ?
{props.attachments.map((item) =>
{isVideoAttachment(item) ?
)}
: null} + {props.attachments.length ?
{props.attachments.map((item) => { + const isVideo = isVideoAttachment(item); + const url = isVideo ? item.url : item.previewUrl; + return
; + })}
: null}
@@ -781,6 +793,7 @@ function CreationComposer(props: ComposerProps) { {props.busy ? : }
+ setPreviewUrl("")} /> ; } diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index cfab9ffbc..bac5cebee 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -2809,9 +2809,14 @@ .creation-chat-attachment-strip { display: flex; gap: 7px; padding-top: 6px; overflow-x: auto; } .creation-chat-attachment { position: relative; width: 42px; height: 42px; flex: 0 0 auto; overflow: hidden; border: 1px solid var(--creation-border); border-radius: 8px; } - .creation-chat-attachment img, .creation-chat-attachment video { width: 100%; height: 100%; object-fit: cover; } - .creation-chat-attachment button { position: absolute; top: 2px; right: 2px; display: grid; width: 16px; height: 16px; place-items: center; border-radius: 50%; background: rgba(0, 0, 0, .7); color: white; opacity: 0; transition: opacity 140ms ease; } - .creation-chat-attachment:hover button, .creation-chat-attachment button:focus-visible { opacity: 1; } + .creation-chat-attachment-preview { display: block; width: 100%; height: 100%; border: 0; background: transparent; padding: 0; cursor: zoom-in; } + .creation-chat-attachment-preview img, .creation-chat-attachment-preview video { width: 100%; height: 100%; object-fit: cover; transition: transform var(--motion-instant) var(--motion-ease-out); } + .creation-chat-attachment-preview > span { position: absolute; bottom: 2px; left: 2px; display: grid; width: 16px; height: 16px; place-items: center; border-radius: 50%; background: rgba(0, 0, 0, .7); color: white; opacity: 0; transition: opacity var(--motion-instant) ease; } + .creation-chat-attachment-preview:hover img, .creation-chat-attachment-preview:hover video { transform: scale(1.025); } + .creation-chat-attachment-preview:hover > span, .creation-chat-attachment-preview:focus-visible > span { opacity: 1; } + .creation-chat-attachment-preview:focus-visible { outline: 2px solid var(--creation-accent); outline-offset: -2px; } + .creation-chat-attachment-remove { position: absolute; top: 2px; right: 2px; display: grid; width: 16px; height: 16px; place-items: center; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .7); color: white; opacity: 0; transition: opacity var(--motion-instant) ease; } + .creation-chat-attachment:hover .creation-chat-attachment-remove, .creation-chat-attachment-remove:focus-visible { opacity: 1; } .creation-chat-attachment button svg { width: 10px; height: 10px; } .creation-chat-dock { @@ -6573,7 +6578,7 @@ html:not(.dark) .creation-user-message-reference { background: rgba(28, 32, 37, .creation-media-actions a:hover, .creation-media-actions button:hover { color: var(--creation-text); } .creation-media-actions svg { width: 13px; height: 13px; } -.creation-media-preview-modal .ant-modal-content { overflow: hidden; border: 0; border-radius: 12px; background: #111215; padding: 0; } +.creation-media-preview-modal .ant-modal-container { overflow: hidden; border: 0; border-radius: 12px; background: #111215; padding: 0; } .creation-media-preview-modal .ant-modal-close { color: #fff; } .creation-media-preview-image, .creation-media-preview-video { display: block; max-width: 100%; max-height: 82vh; margin: 0 auto; object-fit: contain; } .creation-media-preview-video { width: 100%; background: #050505; } @@ -6581,7 +6586,7 @@ html:not(.dark) .creation-user-message-reference { background: rgba(28, 32, 37, html:not(.dark) .creation-user-message { background: rgba(28, 32, 37, .07); } html:not(.dark) .creation-message-content pre, html:not(.dark) .creation-video-result, -html:not(.dark) .creation-media-preview-modal .ant-modal-content { background: #1f2227; } +html:not(.dark) .creation-media-preview-modal .ant-modal-container { background: #1f2227; } html:not(.dark) .creation-message-content code { background: rgba(28, 32, 37, .08); } @keyframes creation-intro-emphasis { @@ -7214,7 +7219,8 @@ body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) { .creation-library-empty span { font-size: var(--fs-micro); } .creation-library-footer { display: flex; align-items: center; gap: 16px; border-top: 1px solid var(--border); padding: 14px 18px; } .creation-library-upload { display: flex; min-height: 58px; flex: 1; align-items: center; gap: 12px; border: 1px dashed color-mix(in srgb, var(--workspace-accent) 55%, var(--border)); border-radius: var(--r-md); background: color-mix(in srgb, var(--workspace-accent) 7%, transparent); padding: 0 16px; color: var(--foreground); text-align: left; transition: background-color 140ms ease, border-color 140ms ease; } -.creation-library-upload:hover { border-color: var(--workspace-accent); background: color-mix(in srgb, var(--workspace-accent) 12%, transparent); } +.creation-library-upload:hover:not(:disabled) { border-color: var(--workspace-accent); background: color-mix(in srgb, var(--workspace-accent) 12%, transparent); } +.creation-library-upload:disabled { cursor: wait; opacity: .72; } .creation-library-upload > svg { width: 21px; height: 21px; color: var(--workspace-accent); } .creation-library-upload span { display: flex; flex-direction: column; gap: 3px; } .creation-library-upload strong { font-size: var(--fs-label); font-weight: 650; } diff --git a/web/test/create-library-button.test.ts b/web/test/create-library-button.test.ts index 90164b37e..304ea7df4 100644 --- a/web/test/create-library-button.test.ts +++ b/web/test/create-library-button.test.ts @@ -24,5 +24,19 @@ describe("creation library button", () => { expect(source.slice(uploadStart, uploadEnd)).not.toContain("setAttachments"); expect(source).toContain("onUpload={uploadLibraryAssets}"); expect(source).not.toContain("onUpload={() => fileInputRef.current?.click()}"); + expect(source).toContain("上传后保存到素材库"); + expect(source).toContain("正在保存到素材库,完成后会自动选中"); + expect(source).toContain("个素材已上传到素材库并自动选中"); + }); + + test("previews prompt reference images without removing them", () => { + const createSource = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8"); + const canvasSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-node-prompt-panel.tsx"), "utf8"); + + expect(createSource).toContain('className="creation-chat-attachment-preview"'); + expect(createSource).toContain(" onInsert(reference)}"); }); });