style(canvas): 全景图配置弹窗 - Prettier 格式化

This commit is contained in:
bensharp
2026-09-11 15:57:57 +08:00
committed by ddCat
parent aee082fbf7
commit 041b22aa44

View File

@@ -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<PanoramaProjection>(initialProjection);
const [sourceMode, setSourceMode] = useState<PanoramaSourceMode>(
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<string[]>([]);
const [draftSelectedIds, setDraftSelectedIds] = useState<string[]>([]);
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<PanoramaProjection>(initialProjection);
const [sourceMode, setSourceMode] = useState<PanoramaSourceMode>(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<string[]>([]);
const [draftSelectedIds, setDraftSelectedIds] = useState<string[]>([]);
const canvasReferences = useMemo<PanoramaReferenceImage[]>(() => {
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<PanoramaReferenceImage[]>(() => {
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<PanoramaReferenceImage[]>(() => {
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<PanoramaReferenceImage[]>(() => {
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 = (
<div className="grid grid-cols-2 gap-2">
<button
type="button"
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)]'}`}
>
<Globe className="h-3 w-3" /> 720°球体
</button>
<button
type="button"
onClick={() => setProjection('cylindrical')}
className={`flex items-center justify-center gap-1 rounded-md border px-2 py-1.5 text-xs transition ${projection === 'cylindrical' ? '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)]'}`}
>
<Compass className="h-3 w-3" /> 360°环绕
</button>
</div>
);
const modeButtons = (
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setSourceMode('ai')}
className={`flex h-10 items-center justify-center gap-1 rounded-md border px-2 text-xs transition ${sourceMode !== 'image' ? '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)]'}`}
>
<Type className="h-3 w-3" /> AI生成全景图
</button>
<button
type="button"
onClick={() => setSourceMode('image')}
className={`flex h-10 items-center justify-center gap-1 rounded-md border px-2 text-xs transition ${sourceMode === 'image' ? '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)]'}`}
>
<ImageIcon className="h-3 w-3" /> 图生全景图
</button>
</div>
);
const smartControl = (
<div className="relative flex items-center gap-1.5">
<button
type="button"
onClick={() => setSmartBase((current) => !current)}
className={`inline-flex h-8 items-center gap-1 whitespace-nowrap rounded-md border px-2.5 text-xs transition-colors ${
smartBase ? 'border-[var(--color-accent)]/60 bg-[var(--color-accent)]/15 text-[var(--color-accent)]' : 'border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]'
}`}
>
{smartBase && <CheckCircle2 className="h-3 w-3" />}
智能比例合成
</button>
<button
type="button"
onClick={() => setShowSmartHelp((current) => !current)}
className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
{showSmartHelp && (
<div className="absolute bottom-full right-0 z-30 mb-2 w-[340px] rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-3 text-[11px] leading-5 text-[var(--color-text-muted)] shadow-2xl">
AI生成全景图会优先提交目标比例;如果供应商没有 2:1 / 4:1,会选择最接近的宽比例生成,再裁切、羽化并归一化。
</div>
)}
</div>
);
const body = (
<div className="grid min-h-[560px] grid-cols-[270px_minmax(0,1fr)_330px]">
<aside className="flex flex-col border-r border-[var(--color-border)] bg-[var(--color-bg-subtle)]">
<div className="border-b border-[var(--color-border)] px-3 py-2">
<div className="text-xs font-semibold text-[var(--color-text)]">{sourceMode === 'image' ? '全景源图(单张)' : '全景素材'}</div>
<div className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">
{sourceMode === 'image'
? (selectedReferences.length > 0 ? '已选 1 张源图' : '请选择 1 张源图')
: `${selectedReferences.length} / ${allReferences.length} 张已加入`}
</div>
</div>
<div className="ui-scrollbar nowheel flex-1 overflow-y-auto p-2">
<div className="space-y-2">
{selectedReferences.map((image, index) => (
<div key={image.id} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-1.5">
<div className="aspect-video overflow-hidden rounded bg-[var(--color-bg-subtle)]">
<img src={image.url} alt={image.label} className="h-full w-full object-cover" draggable={false} />
</div>
<div className="mt-1 flex items-center gap-1">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: image.color }} />
<div className="min-w-0 flex-1 truncate text-[10px] text-[var(--color-text)]">{sourceMode === 'image' ? '源图' : `图${index + 1}`} · {image.label}</div>
<button
const projectionButtons = (
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setSelectedIds((current) => current.filter((id) => id !== image.id))}
className="rounded px-1 text-[10px] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"
>
×
</button>
</div>
</div>
))}
{(sourceMode !== 'image' || selectedReferences.length === 0) && (
<button
type="button"
onClick={openAssetPicker}
className="flex aspect-video w-full items-center justify-center rounded-lg border border-dashed border-[var(--color-border)] bg-[var(--color-bg-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-text-muted)] hover:text-[var(--color-text)]"
title={sourceMode === 'image' ? '选择全景源图' : '从资产加入图片'}
>
<Plus className="h-6 w-6" />
</button>
)}
{sourceMode === 'image' && selectedReferences.length > 0 && (
<button
type="button"
onClick={openAssetPicker}
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-2 text-[11px] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]"
title="替换源图"
>
替换源图
</button>
)}
</div>
</div>
</aside>
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)]"}`}
>
<Globe className="h-3 w-3" /> 720°球体
</button>
<button
type="button"
onClick={() => setProjection("cylindrical")}
className={`flex items-center justify-center gap-1 rounded-md border px-2 py-1.5 text-xs transition ${projection === "cylindrical" ? "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)]"}`}
>
<Compass className="h-3 w-3" /> 360°环绕
</button>
</div>
);
<main className="flex min-w-0 flex-col gap-4 p-6">
<div className="relative">
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={sourceMode === 'image'
? '可选备注:使用现成图片作为全景源,不会提交给 AI'
: '输入全景场景描述;文字+图片+内置全景提示词会一起提交'}
className="nodrag nowheel h-40 w-full resize-none rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-4 py-3 text-sm leading-6 text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]/70 focus:border-[var(--color-accent)]"
/>
</div>
{sourceMode === 'ai' ? (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
AI生成全景图会把文字、导入图片和内置全景提示词一起提交,目标是先得到 2:1 全景底图,再进入全景查看器。
const modeButtons = (
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setSourceMode("ai")}
className={`flex h-10 items-center justify-center gap-1 rounded-md border px-2 text-xs transition ${sourceMode !== "image" ? "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)]"}`}
>
<Type className="h-3 w-3" /> AI生成全景图
</button>
<button
type="button"
onClick={() => setSourceMode("image")}
className={`flex h-10 items-center justify-center gap-1 rounded-md border px-2 text-xs transition ${sourceMode === "image" ? "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)]"}`}
>
<ImageIcon className="h-3 w-3" /> 图生全景图
</button>
</div>
);
const smartControl = (
<div className="relative flex items-center gap-1.5">
<button
type="button"
onClick={() => setSmartBase((current) => !current)}
className={`inline-flex h-8 items-center gap-1 whitespace-nowrap rounded-md border px-2.5 text-xs transition-colors ${
smartBase ? "border-[var(--color-accent)]/60 bg-[var(--color-accent)]/15 text-[var(--color-accent)]" : "border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]"
}`}
>
{smartBase && <CheckCircle2 className="h-3 w-3" />}
智能比例合成
</button>
<button
type="button"
onClick={() => setShowSmartHelp((current) => !current)}
className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
{showSmartHelp && (
<div className="absolute bottom-full right-0 z-30 mb-2 w-[340px] rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-3 text-[11px] leading-5 text-[var(--color-text-muted)] shadow-2xl">
AI生成全景图会优先提交目标比例;如果供应商没有 2:1 / 4:1,会选择最接近的宽比例生成,再裁切、羽化并归一化。
</div>
) : (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
图生全景图只使用第一张源图作为全景底图。图片接近 {projection === 'spherical' ? '2:1' : '4:1'} 时直接进入全景查看器;不符合时在本地裁切、羽化并归一化,不会提交给 AI。
</div>
)}
{sourceMode !== 'image' && (
<details className="text-[11px] text-[var(--color-text-muted)]">
<summary className="cursor-pointer hover:text-[var(--color-text)]">查看最终提示词</summary>
<pre className="mt-2 max-h-36 overflow-y-auto whitespace-pre-wrap rounded-lg bg-[var(--color-bg-subtle)] p-2 text-[11px] text-[var(--color-text-muted)]">{composed}</pre>
</details>
)}
<div className="mt-auto flex items-center justify-between gap-3 border-t border-[var(--color-border)] pt-4">
{sourceMode !== 'image' ? (
<div className="text-[11px] leading-5 text-[var(--color-text-muted)]">
使用当前配置生成全景图。
)}
</div>
) : (
<div className="text-[11px] leading-5 text-[var(--color-text-muted)]">
使用现成图片作为全景源,比例处理在本地完成。
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={sourceMode === 'image' && !directReference}
className="flex h-8 items-center gap-1 whitespace-nowrap rounded-md bg-[var(--color-accent)] px-3 text-xs text-white hover:bg-[var(--color-accent)]/90 disabled:cursor-not-allowed disabled:opacity-40"
>
{sourceMode === 'image' ? <ImageIcon className="h-3 w-3" /> : <Sparkles className="h-3 w-3" />}
{sourceMode === 'image' ? '创建全景图' : '生成全景图'}
</button>
</div>
</div>
</main>
);
<aside className="flex flex-col gap-4 border-l border-[var(--color-border)] bg-[var(--color-bg-subtle)] p-4">
<section className="flex flex-col gap-2">
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">生成方式</div>
{modeButtons}
</section>
<section className="flex flex-col gap-2">
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">投影方式</div>
{projectionButtons}
</section>
{projection === 'cylindrical' && (
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-5 text-amber-700 dark:text-amber-200/85">
360° 环绕更适合横向街景/长廊;普通全景建议使用 720° 球体。
</div>
)}
<section className="flex flex-col gap-2">
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">比例兜底</div>
<div className="flex items-start gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span>
{sourceMode === 'image'
? `本地检查源图比例;符合 ${projection === 'spherical' ? '2:1' : '4:1'} 时直接使用,不符合时本地裁切、羽化并归一化。`
: `会优先请求 ${projection === 'spherical' ? '2:1' : '4:1'} 全景比例;如果当前供应商不支持,会自动改用最接近的宽比例,并在生成后走裁切/归一化兜底。`}
</span>
</div>
{sourceMode !== 'image' && (
<div className="flex flex-col gap-2 pt-1">
{smartControl}
<button
type="button"
onClick={() => onCopyPrompt?.(composed)}
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 text-xs text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-bg-hover)]"
title="复制完整提示词到剪贴板"
>
<Copy className="h-3 w-3" /> 复制提示词
</button>
</div>
)}
</section>
</aside>
</div>
);
return (
<>
{body}
{isAssetPickerOpen && (
<div className="absolute inset-0 z-[260] flex items-center justify-center bg-black/35 backdrop-blur-sm">
<div className="w-[560px] max-w-[calc(100%-32px)] rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] shadow-2xl">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
<div>
<div className="text-sm font-semibold text-[var(--color-text)]">{sourceMode === 'image' ? '选择要转换的全景源图' : '选择全景参考图'}</div>
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
{sourceMode === 'image'
? '图生全景图只使用一张现成图片作为全景源,不会提交给 AI'
: 'AI生成全景图支持多选,可作为风格 / 主体 / 元素参考'}
</div>
</div>
<button type="button" onClick={() => setIsAssetPickerOpen(false)} className="rounded-md p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]">
<X className="h-4 w-4" />
</button>
</div>
<div className="border-b border-[var(--color-border)] px-4 py-2">
<label className="flex h-8 items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-2">
<Search className="h-3.5 w-3.5 text-[var(--color-text-muted)]" />
<input
value={assetQuery}
onChange={(event) => setAssetQuery(event.target.value)}
placeholder="搜索图片名称"
className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]/70"
/>
</label>
</div>
<div className="ui-scrollbar max-h-[380px] overflow-y-auto p-4">
{filteredReferences.length === 0 ? (
<div className="rounded-lg border border-dashed border-[var(--color-border)] px-4 py-10 text-center text-xs text-[var(--color-text-muted)]">
没有匹配的图片资产
</div>
) : (
<div className="grid grid-cols-4 gap-2">
{filteredReferences.map((image) => {
const selected = draftSelectedIds.includes(image.id);
return (
<button
key={image.id}
type="button"
onClick={() => toggleDraftReference(image.id)}
className={`relative overflow-hidden rounded-lg border bg-[var(--color-bg)] text-left transition-colors ${
selected ? 'border-[var(--color-accent)]/80 ring-1 ring-[var(--color-accent)]/50' : 'border-[var(--color-border)] hover:border-[var(--color-text-muted)]'
}`}
>
<div className="aspect-square bg-[var(--color-bg-subtle)]">
<img src={image.url} alt={image.label} className="h-full w-full object-cover" draggable={false} />
const body = (
<div className="grid min-h-[560px] grid-cols-[270px_minmax(0,1fr)_330px]">
<aside className="flex flex-col border-r border-[var(--color-border)] bg-[var(--color-bg-subtle)]">
<div className="border-b border-[var(--color-border)] px-3 py-2">
<div className="text-xs font-semibold text-[var(--color-text)]">{sourceMode === "image" ? "全景源图(单张)" : "全景素材"}</div>
<div className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">
{sourceMode === "image" ? (selectedReferences.length > 0 ? "已选 1 张源图" : "请选择 1 张源图") : `${selectedReferences.length} / ${allReferences.length} 张已加入`}
</div>
<div className="truncate px-1.5 py-1 text-[10px] text-[var(--color-text)]">{image.label}</div>
{selected && <span className="absolute right-1 top-1 rounded bg-[var(--color-accent)] px-1 py-0.5 text-[9px] text-white">已选</span>}
</button>
);
})}
</div>
<div className="ui-scrollbar nowheel flex-1 overflow-y-auto p-2">
<div className="space-y-2">
{selectedReferences.map((image, index) => (
<div key={image.id} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-1.5">
<div className="aspect-video overflow-hidden rounded bg-[var(--color-bg-subtle)]">
<img src={image.url} alt={image.label} className="h-full w-full object-cover" draggable={false} />
</div>
<div className="mt-1 flex items-center gap-1">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: image.color }} />
<div className="min-w-0 flex-1 truncate text-[10px] text-[var(--color-text)]">
{sourceMode === "image" ? "源图" : `图${index + 1}`} · {image.label}
</div>
<button
type="button"
onClick={() => setSelectedIds((current) => current.filter((id) => id !== image.id))}
className="rounded px-1 text-[10px] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"
>
×
</button>
</div>
</div>
))}
{(sourceMode !== "image" || selectedReferences.length === 0) && (
<button
type="button"
onClick={openAssetPicker}
className="flex aspect-video w-full items-center justify-center rounded-lg border border-dashed border-[var(--color-border)] bg-[var(--color-bg-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-text-muted)] hover:text-[var(--color-text)]"
title={sourceMode === "image" ? "选择全景源图" : "从资产加入图片"}
>
<Plus className="h-6 w-6" />
</button>
)}
{sourceMode === "image" && selectedReferences.length > 0 && (
<button
type="button"
onClick={openAssetPicker}
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-2 text-[11px] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]"
title="替换源图"
>
替换源图
</button>
)}
</div>
</div>
</aside>
<main className="flex min-w-0 flex-col gap-4 p-6">
<div className="relative">
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={sourceMode === "image" ? "可选备注:使用现成图片作为全景源,不会提交给 AI" : "输入全景场景描述;文字+图片+内置全景提示词会一起提交"}
className="nodrag nowheel h-40 w-full resize-none rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-4 py-3 text-sm leading-6 text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]/70 focus:border-[var(--color-accent)]"
/>
</div>
{sourceMode === "ai" ? (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
AI生成全景图会把文字、导入图片和内置全景提示词一起提交,目标是先得到 2:1 全景底图,再进入全景查看器。
</div>
) : (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
图生全景图只使用第一张源图作为全景底图。图片接近 {projection === "spherical" ? "2:1" : "4:1"} 时直接进入全景查看器;不符合时在本地裁切、羽化并归一化,不会提交给 AI。
</div>
)}
{sourceMode !== "image" && (
<details className="text-[11px] text-[var(--color-text-muted)]">
<summary className="cursor-pointer hover:text-[var(--color-text)]">查看最终提示词</summary>
<pre className="mt-2 max-h-36 overflow-y-auto whitespace-pre-wrap rounded-lg bg-[var(--color-bg-subtle)] p-2 text-[11px] text-[var(--color-text-muted)]">{composed}</pre>
</details>
)}
<div className="mt-auto flex items-center justify-between gap-3 border-t border-[var(--color-border)] pt-4">
{sourceMode !== "image" ? (
<div className="text-[11px] leading-5 text-[var(--color-text-muted)]">使用当前配置生成全景图。</div>
) : (
<div className="text-[11px] leading-5 text-[var(--color-text-muted)]">使用现成图片作为全景源,比例处理在本地完成。</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={sourceMode === "image" && !directReference}
className="flex h-8 items-center gap-1 whitespace-nowrap rounded-md bg-[var(--color-accent)] px-3 text-xs text-white hover:bg-[var(--color-accent)]/90 disabled:cursor-not-allowed disabled:opacity-40"
>
{sourceMode === "image" ? <ImageIcon className="h-3 w-3" /> : <Sparkles className="h-3 w-3" />}
{sourceMode === "image" ? "创建全景图" : "生成全景图"}
</button>
</div>
</div>
</main>
<aside className="flex flex-col gap-4 border-l border-[var(--color-border)] bg-[var(--color-bg-subtle)] p-4">
<section className="flex flex-col gap-2">
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">生成方式</div>
{modeButtons}
</section>
<section className="flex flex-col gap-2">
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">投影方式</div>
{projectionButtons}
</section>
{projection === "cylindrical" && (
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-5 text-amber-700 dark:text-amber-200/85">360° 环绕更适合横向街景/长廊;普通全景建议使用 720° 球体。</div>
)}
<section className="flex flex-col gap-2">
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">比例兜底</div>
<div className="flex items-start gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span>
{sourceMode === "image"
? `本地检查源图比例;符合 ${projection === "spherical" ? "2:1" : "4:1"} 时直接使用,不符合时本地裁切、羽化并归一化。`
: `会优先请求 ${projection === "spherical" ? "2:1" : "4:1"} 全景比例;如果当前供应商不支持,会自动改用最接近的宽比例,并在生成后走裁切/归一化兜底。`}
</span>
</div>
{sourceMode !== "image" && (
<div className="flex flex-col gap-2 pt-1">
{smartControl}
<button
type="button"
onClick={() => onCopyPrompt?.(composed)}
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 text-xs text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-bg-hover)]"
title="复制完整提示词到剪贴板"
>
<Copy className="h-3 w-3" /> 复制提示词
</button>
</div>
)}
</section>
</aside>
</div>
);
return (
<>
{body}
{isAssetPickerOpen && (
<div className="absolute inset-0 z-[260] flex items-center justify-center bg-black/35 backdrop-blur-sm">
<div className="w-[560px] max-w-[calc(100%-32px)] rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] shadow-2xl">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
<div>
<div className="text-sm font-semibold text-[var(--color-text)]">{sourceMode === "image" ? "选择要转换的全景源图" : "选择全景参考图"}</div>
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">{sourceMode === "image" ? "图生全景图只使用一张现成图片作为全景源,不会提交给 AI" : "AI生成全景图支持多选,可作为风格 / 主体 / 元素参考"}</div>
</div>
<button type="button" onClick={() => setIsAssetPickerOpen(false)} className="rounded-md p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]">
<X className="h-4 w-4" />
</button>
</div>
<div className="border-b border-[var(--color-border)] px-4 py-2">
<label className="flex h-8 items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-2">
<Search className="h-3.5 w-3.5 text-[var(--color-text-muted)]" />
<input
value={assetQuery}
onChange={(event) => setAssetQuery(event.target.value)}
placeholder="搜索图片名称"
className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]/70"
/>
</label>
</div>
<div className="ui-scrollbar max-h-[380px] overflow-y-auto p-4">
{filteredReferences.length === 0 ? (
<div className="rounded-lg border border-dashed border-[var(--color-border)] px-4 py-10 text-center text-xs text-[var(--color-text-muted)]">没有匹配的图片资产</div>
) : (
<div className="grid grid-cols-4 gap-2">
{filteredReferences.map((image) => {
const selected = draftSelectedIds.includes(image.id);
return (
<button
key={image.id}
type="button"
onClick={() => toggleDraftReference(image.id)}
className={`relative overflow-hidden rounded-lg border bg-[var(--color-bg)] text-left transition-colors ${
selected ? "border-[var(--color-accent)]/80 ring-1 ring-[var(--color-accent)]/50" : "border-[var(--color-border)] hover:border-[var(--color-text-muted)]"
}`}
>
<div className="aspect-square bg-[var(--color-bg-subtle)]">
<img src={image.url} alt={image.label} className="h-full w-full object-cover" draggable={false} />
</div>
<div className="truncate px-1.5 py-1 text-[10px] text-[var(--color-text)]">{image.label}</div>
{selected && <span className="absolute right-1 top-1 rounded bg-[var(--color-accent)] px-1 py-0.5 text-[9px] text-white">已选</span>}
</button>
);
})}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-[var(--color-border)] px-4 py-3">
<button
type="button"
onClick={() => setIsAssetPickerOpen(false)}
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]"
>
取消
</button>
<button
type="button"
onClick={confirmAssetPicker}
disabled={sourceMode === "image" && draftSelectedIds.length === 0}
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs text-white hover:bg-[var(--color-accent)]/90 disabled:cursor-not-allowed disabled:opacity-45"
>
{sourceMode === "image" ? (draftSelectedIds.length > 0 ? "使用此图" : "请选择一张源图") : `加入 ${draftSelectedIds.length} 张`}
</button>
</div>
</div>
</div>
)}
</>
);
},
);
PanoramaSetupForm.displayName = "PanoramaSetupForm";
export function CanvasPanoramaConfigModal({ open, onCancel, onConfirm, onCopyPrompt, previewImageUrl, initialProjection, initialSourceMode, initialSmartBase, nodes }: CanvasPanoramaConfigModalProps) {
return (
<AppModal title="全景图生成" open={open} centered footer={null} width={980} flush onCancel={onCancel}>
<div className="flex flex-col">
<div className="relative">
<PanoramaSetupForm
onSubmit={onConfirm}
onCopyPrompt={onCopyPrompt}
previewImageUrl={previewImageUrl}
initialProjection={initialProjection}
initialSourceMode={initialSourceMode}
initialSmartBase={initialSmartBase}
canvasNodes={nodes}
/>
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-[var(--color-border)] px-4 py-3">
<button type="button" onClick={() => setIsAssetPickerOpen(false)} className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]">
取消
</button>
<button
type="button"
onClick={confirmAssetPicker}
disabled={sourceMode === 'image' && draftSelectedIds.length === 0}
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs text-white hover:bg-[var(--color-accent)]/90 disabled:cursor-not-allowed disabled:opacity-45"
>
{sourceMode === 'image'
? (draftSelectedIds.length > 0 ? '使用此图' : '请选择一张源图')
: `加入 ${draftSelectedIds.length} 张`}
</button>
</div>
</div>
</div>
)}
</>
);
});
PanoramaSetupForm.displayName = 'PanoramaSetupForm';
export function CanvasPanoramaConfigModal({
open,
onCancel,
onConfirm,
onCopyPrompt,
previewImageUrl,
initialProjection,
initialSourceMode,
initialSmartBase,
nodes,
}: CanvasPanoramaConfigModalProps) {
return (
<AppModal
title="全景图生成"
open={open}
centered
footer={null}
width={980}
flush
onCancel={onCancel}
>
<div className="flex flex-col">
<div className="relative">
<PanoramaSetupForm
onSubmit={onConfirm}
onCopyPrompt={onCopyPrompt}
previewImageUrl={previewImageUrl}
initialProjection={initialProjection}
initialSourceMode={initialSourceMode}
initialSmartBase={initialSmartBase}
canvasNodes={nodes}
/>
</div>
</div>
</AppModal>
);
</AppModal>
);
}