From 041b22aa442db78fc88a4fca56ac6878837c3877 Mon Sep 17 00:00:00 2001 From: bensharp Date: Fri, 11 Sep 2026 15:57:57 +0800 Subject: [PATCH] =?UTF-8?q?style(canvas):=20=E5=85=A8=E6=99=AF=E5=9B=BE?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=BC=B9=E7=AA=97=20-=20Prettier=20=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../canvas/canvas-panorama-config-modal.tsx | 918 +++++++++--------- 1 file changed, 434 insertions(+), 484 deletions(-) diff --git a/web/src/components/canvas/canvas-panorama-config-modal.tsx b/web/src/components/canvas/canvas-panorama-config-modal.tsx index 96922b203..10d3945a7 100644 --- a/web/src/components/canvas/canvas-panorama-config-modal.tsx +++ b/web/src/components/canvas/canvas-panorama-config-modal.tsx @@ -1,24 +1,11 @@ -import { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { - CheckCircle2, - Compass, - Copy, - Globe, - HelpCircle, - Image as ImageIcon, - Info, - Plus, - Search, - Sparkles, - Type, - X, -} from 'lucide-react'; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { CheckCircle2, Compass, Copy, Globe, HelpCircle, Image as ImageIcon, Info, Plus, Search, Sparkles, Type, X } from "lucide-react"; -import { AppModal } from '@/components/ui/product/app-modal'; -import { CanvasNodeType, type CanvasNodeData } from '@/types/canvas'; +import { AppModal } from "@/components/ui/product/app-modal"; +import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; -export type PanoramaProjection = 'spherical' | 'cylindrical'; -export type PanoramaSourceMode = 'ai' | 'image'; +export type PanoramaProjection = "spherical" | "cylindrical"; +export type PanoramaSourceMode = "ai" | "image"; export interface PanoramaReferenceImage { id: string; @@ -47,498 +34,461 @@ interface CanvasPanoramaConfigModalProps { nodes: CanvasNodeData[]; } -const PANORAMA_COLORS = ['#60a5fa', '#34d399', '#fbbf24', '#f87171', '#a78bfa', '#fb7185', '#38bdf8', '#4ade80']; +const PANORAMA_COLORS = ["#60a5fa", "#34d399", "#fbbf24", "#f87171", "#a78bfa", "#fb7185", "#38bdf8", "#4ade80"]; function normalizeInitialSourceMode(mode: PanoramaSourceMode | undefined, hasPreview: boolean): PanoramaSourceMode { - if (mode === 'image') return 'image'; - if (mode === 'ai') return 'ai'; - return hasPreview ? 'image' : 'ai'; + if (mode === "image") return "image"; + if (mode === "ai") return "ai"; + return hasPreview ? "image" : "ai"; } -function buildPanoramaPrompt( - sourceMode: PanoramaSourceMode, - projection: PanoramaProjection, - userPrompt: string -): string { - const base = userPrompt.trim(); +function buildPanoramaPrompt(sourceMode: PanoramaSourceMode, projection: PanoramaProjection, userPrompt: string): string { + const base = userPrompt.trim(); - const common = [ - '最终图像必须适合导入全景查看器,作为包裹场景的环境球内壁或水平环绕背景使用', - '画面中不要出现摄影师、相机、镜头、三脚架、头显或任何拍摄设备', - '不要分屏拼贴,不要多宫格,不要画中画,不要插入小图,只保留一个连续环境', - '不要水印,不要文字,不要界面元素,不要边框,不要明显拼接线或接缝', - '除非用户另有说明,保持真实摄影质感、电影级光影和自然空间纵深', - ].join(', '); + const common = [ + "最终图像必须适合导入全景查看器,作为包裹场景的环境球内壁或水平环绕背景使用", + "画面中不要出现摄影师、相机、镜头、三脚架、头显或任何拍摄设备", + "不要分屏拼贴,不要多宫格,不要画中画,不要插入小图,只保留一个连续环境", + "不要水印,不要文字,不要界面元素,不要边框,不要明显拼接线或接缝", + "除非用户另有说明,保持真实摄影质感、电影级光影和自然空间纵深", + ].join(", "); - const projectionLabel = projection === 'spherical' ? '720度球形' : '360度环绕'; - const ratio = projection === 'spherical' ? '2:1' : '4:1'; - - const fallbackText = `生成一张可用于全景查看器的完整沉浸式全景图,画面必须是比例${ratio}的等距柱状投影结构,宽度是高度的${projection === 'spherical' ? '2倍' : '4倍'},结合导入图片的主体、材质、色彩和风格,并根据文字描述补全四周环境、天空或天花板、地面或地板,让左右边缘自然衔接。${common}`; - - if (!base) { - return fallbackText; - } - - return `${base}。${fallbackText}`; + const projectionLabel = projection === "spherical" ? "720度球形" : "360度环绕"; + const ratio = projection === "spherical" ? "2:1" : "4:1"; + + const fallbackText = `生成一张可用于全景查看器的完整沉浸式全景图,画面必须是比例${ratio}的等距柱状投影结构,宽度是高度的${projection === "spherical" ? "2倍" : "4倍"},结合导入图片的主体、材质、色彩和风格,并根据文字描述补全四周环境、天空或天花板、地面或地板,让左右边缘自然衔接。${common}`; + + if (!base) { + return fallbackText; + } + + return `${base}。${fallbackText}`; } -const PanoramaSetupForm = memo(({ - onSubmit, - onCopyPrompt, - previewImageUrl, - initialProjection = 'spherical', - initialSourceMode, - initialSmartBase = true, - canvasNodes, -}: { - onSubmit: (composedPrompt: string, config: PanoramaGenerateConfig) => void; - onCopyPrompt?: (prompt: string) => void; - previewImageUrl?: string | null; - initialProjection?: PanoramaProjection; - initialSourceMode?: PanoramaSourceMode; - initialSmartBase?: boolean; - canvasNodes: CanvasNodeData[]; -}) => { - const [projection, setProjection] = useState(initialProjection); - const [sourceMode, setSourceMode] = useState( - normalizeInitialSourceMode(initialSourceMode, Boolean(previewImageUrl)) - ); - const [smartBase, setSmartBase] = useState(initialSmartBase); - const [prompt, setPrompt] = useState(''); - const [showSmartHelp, setShowSmartHelp] = useState(false); - const [assetQuery, setAssetQuery] = useState(''); - const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); - const [selectedIds, setSelectedIds] = useState([]); - const [draftSelectedIds, setDraftSelectedIds] = useState([]); +const PanoramaSetupForm = memo( + ({ + onSubmit, + onCopyPrompt, + previewImageUrl, + initialProjection = "spherical", + initialSourceMode, + initialSmartBase = true, + canvasNodes, + }: { + onSubmit: (composedPrompt: string, config: PanoramaGenerateConfig) => void; + onCopyPrompt?: (prompt: string) => void; + previewImageUrl?: string | null; + initialProjection?: PanoramaProjection; + initialSourceMode?: PanoramaSourceMode; + initialSmartBase?: boolean; + canvasNodes: CanvasNodeData[]; + }) => { + const [projection, setProjection] = useState(initialProjection); + const [sourceMode, setSourceMode] = useState(normalizeInitialSourceMode(initialSourceMode, Boolean(previewImageUrl))); + const [smartBase, setSmartBase] = useState(initialSmartBase); + const [prompt, setPrompt] = useState(""); + const [showSmartHelp, setShowSmartHelp] = useState(false); + const [assetQuery, setAssetQuery] = useState(""); + const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); + const [selectedIds, setSelectedIds] = useState([]); + const [draftSelectedIds, setDraftSelectedIds] = useState([]); - const canvasReferences = useMemo(() => { - const list: PanoramaReferenceImage[] = []; - canvasNodes.forEach((node) => { - if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return; - const raw = node.metadata?.content; - if (!raw) return; - const label = (node.title || '').trim() || `图${list.length + 1}`; - list.push({ - id: node.id, - url: raw, - label, - color: PANORAMA_COLORS[list.length % PANORAMA_COLORS.length], + const canvasReferences = useMemo(() => { + const list: PanoramaReferenceImage[] = []; + canvasNodes.forEach((node) => { + if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return; + const raw = node.metadata?.content; + if (!raw) return; + const label = (node.title || "").trim() || `图${list.length + 1}`; + list.push({ + id: node.id, + url: raw, + label, + color: PANORAMA_COLORS[list.length % PANORAMA_COLORS.length], + }); }); - }); - return list; - }, [canvasNodes]); + return list; + }, [canvasNodes]); - const allReferences = useMemo(() => { - if (!previewImageUrl) return canvasReferences; - const exists = canvasReferences.some((item) => item.url === previewImageUrl); - if (exists) return canvasReferences; - return [{ - id: 'selected-preview', - url: previewImageUrl, - label: '当前图片', - color: PANORAMA_COLORS[0], - }, ...canvasReferences]; - }, [canvasReferences, previewImageUrl]); + const allReferences = useMemo(() => { + if (!previewImageUrl) return canvasReferences; + const exists = canvasReferences.some((item) => item.url === previewImageUrl); + if (exists) return canvasReferences; + return [ + { + id: "selected-preview", + url: previewImageUrl, + label: "当前图片", + color: PANORAMA_COLORS[0], + }, + ...canvasReferences, + ]; + }, [canvasReferences, previewImageUrl]); - useEffect(() => { - if (selectedIds.length > 0) { - const available = new Set(allReferences.map((item) => item.id)); - setSelectedIds((current) => current.filter((id) => available.has(id))); - return; - } - if (previewImageUrl) { - const first = allReferences.find((item) => item.url === previewImageUrl) ?? allReferences[0]; - if (first) setSelectedIds([first.id]); - } - }, [allReferences, previewImageUrl, selectedIds.length]); + useEffect(() => { + if (selectedIds.length > 0) { + const available = new Set(allReferences.map((item) => item.id)); + setSelectedIds((current) => current.filter((id) => available.has(id))); + return; + } + if (previewImageUrl) { + const first = allReferences.find((item) => item.url === previewImageUrl) ?? allReferences[0]; + if (first) setSelectedIds([first.id]); + } + }, [allReferences, previewImageUrl, selectedIds.length]); - useEffect(() => { - if (sourceMode !== 'image') return; - setSelectedIds((current) => (current.length > 1 ? current.slice(0, 1) : current)); - setDraftSelectedIds((current) => (current.length > 1 ? current.slice(0, 1) : current)); - }, [sourceMode]); + useEffect(() => { + if (sourceMode !== "image") return; + setSelectedIds((current) => (current.length > 1 ? current.slice(0, 1) : current)); + setDraftSelectedIds((current) => (current.length > 1 ? current.slice(0, 1) : current)); + }, [sourceMode]); - const selectedReferences = useMemo(() => { - const byId = new Map(allReferences.map((item) => [item.id, item])); - return selectedIds - .map((id) => byId.get(id)) - .filter((item): item is PanoramaReferenceImage => Boolean(item)); - }, [allReferences, selectedIds]); + const selectedReferences = useMemo(() => { + const byId = new Map(allReferences.map((item) => [item.id, item])); + return selectedIds.map((id) => byId.get(id)).filter((item): item is PanoramaReferenceImage => Boolean(item)); + }, [allReferences, selectedIds]); - const filteredReferences = useMemo(() => { - const query = assetQuery.trim().toLowerCase(); - if (!query) return allReferences; - return allReferences.filter((item) => `${item.label} ${item.id}`.toLowerCase().includes(query)); - }, [allReferences, assetQuery]); + const filteredReferences = useMemo(() => { + const query = assetQuery.trim().toLowerCase(); + if (!query) return allReferences; + return allReferences.filter((item) => `${item.label} ${item.id}`.toLowerCase().includes(query)); + }, [allReferences, assetQuery]); - const defaultAiPrompt = '生成一张可用于全景查看器的完整沉浸式全景图,画面必须是比例2比1的等距柱状投影结构,宽度是高度的2倍,结合导入图片的主体、材质、色彩和风格,并根据文字描述补全四周环境、天空或天花板、地面或地板,让左右边缘自然衔接'; - const effectivePrompt = sourceMode === 'image' - ? prompt.trim() - : (prompt.trim() || defaultAiPrompt); - const composed = sourceMode === 'image' - ? effectivePrompt - : buildPanoramaPrompt(sourceMode, projection, effectivePrompt); - const directReference = selectedReferences[0] ?? null; + const defaultAiPrompt = "生成一张可用于全景查看器的完整沉浸式全景图,画面必须是比例2比1的等距柱状投影结构,宽度是高度的2倍,结合导入图片的主体、材质、色彩和风格,并根据文字描述补全四周环境、天空或天花板、地面或地板,让左右边缘自然衔接"; + const effectivePrompt = sourceMode === "image" ? prompt.trim() : prompt.trim() || defaultAiPrompt; + const composed = sourceMode === "image" ? effectivePrompt : buildPanoramaPrompt(sourceMode, projection, effectivePrompt); + const directReference = selectedReferences[0] ?? null; - const handleSubmit = useCallback(() => { - if (sourceMode === 'image' && !directReference) return; - onSubmit(composed, { - projection, - sourceMode, - smartBase, - referenceImages: selectedReferences, - directImageUrl: sourceMode === 'image' ? directReference?.url ?? null : null, - }); - }, [composed, directReference, onSubmit, projection, selectedReferences, smartBase, sourceMode]); + const handleSubmit = useCallback(() => { + if (sourceMode === "image" && !directReference) return; + onSubmit(composed, { + projection, + sourceMode, + smartBase, + referenceImages: selectedReferences, + directImageUrl: sourceMode === "image" ? (directReference?.url ?? null) : null, + }); + }, [composed, directReference, onSubmit, projection, selectedReferences, smartBase, sourceMode]); - const toggleDraftReference = (id: string) => { - setDraftSelectedIds((current) => { - if (sourceMode === 'image') { - return current.includes(id) ? [] : [id]; - } - return current.includes(id) ? current.filter((item) => item !== id) : [...current, id]; - }); - }; + const toggleDraftReference = (id: string) => { + setDraftSelectedIds((current) => { + if (sourceMode === "image") { + return current.includes(id) ? [] : [id]; + } + return current.includes(id) ? current.filter((item) => item !== id) : [...current, id]; + }); + }; - const openAssetPicker = () => { - setDraftSelectedIds(selectedIds); - setIsAssetPickerOpen(true); - }; + const openAssetPicker = () => { + setDraftSelectedIds(selectedIds); + setIsAssetPickerOpen(true); + }; - const confirmAssetPicker = () => { - setSelectedIds(draftSelectedIds); - setIsAssetPickerOpen(false); - }; + const confirmAssetPicker = () => { + setSelectedIds(draftSelectedIds); + setIsAssetPickerOpen(false); + }; - const projectionButtons = ( -
- - -
- ); - - const modeButtons = ( -
- - -
- ); - - const smartControl = ( -
- - - {showSmartHelp && ( -
- AI生成全景图会优先提交目标比例;如果供应商没有 2:1 / 4:1,会选择最接近的宽比例生成,再裁切、羽化并归一化。 -
- )} -
- ); - - const body = ( -
- + onClick={() => setProjection("spherical")} + className={`flex items-center justify-center gap-1 rounded-md border px-2 py-1.5 text-xs transition ${projection === "spherical" ? "border-[var(--color-accent)] bg-[var(--color-accent)] text-white" : "border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text)] hover:bg-[var(--color-bg-hover)]"}`} + > + 720°球体 + + +
+ ); -
-
-