style: 优化 3D 编辑器布局,实现无滚动完美视口体验
This commit is contained in:
parent
7d17ee1872
commit
13a4784fa5
@ -6,15 +6,15 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Download, Check, RotateCcw, Move3D, Sparkles } from "lucide-react";
|
||||
import { Loader2, Download, Check, RotateCcw, Move3D, Sparkles, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { QwenCameraWidget, type CameraState } from "./qwen-camera-widget";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface AngleEditDialogProps {
|
||||
open: boolean;
|
||||
@ -62,26 +62,39 @@ export function AngleEditDialog({
|
||||
elevation: 0,
|
||||
distance: 5,
|
||||
});
|
||||
// 预览 Tab 状态:如果生成了结果,默认显示结果;否则显示原图
|
||||
const [activeTab, setActiveTab] = useState<"original" | "result">("original");
|
||||
|
||||
// 当对话框关闭时重置状态
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setResultUrl(null);
|
||||
setIsProcessing(false);
|
||||
setActiveTab("original");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 当生成结果时,自动切换到结果 Tab
|
||||
useEffect(() => {
|
||||
if (resultUrl) {
|
||||
setActiveTab("result");
|
||||
}
|
||||
}, [resultUrl]);
|
||||
|
||||
// 处理相机状态变化
|
||||
const handleCameraStateChange = useCallback((state: CameraState) => {
|
||||
setCameraState(state);
|
||||
// 角度变化时清除之前的结果
|
||||
setResultUrl(null);
|
||||
}, []);
|
||||
if (!isProcessing) {
|
||||
setResultUrl(null);
|
||||
}
|
||||
}, [isProcessing]);
|
||||
|
||||
// 生成新视角
|
||||
const handleGenerate = useCallback(async () => {
|
||||
setIsProcessing(true);
|
||||
setResultUrl(null);
|
||||
setActiveTab("result"); // 切换到结果 Tab 显示加载状态
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/angle-edit", {
|
||||
@ -107,6 +120,7 @@ export function AngleEditDialog({
|
||||
} catch (error) {
|
||||
console.error("角度编辑失败:", error);
|
||||
toast.error(error instanceof Error ? error.message : "生成失败,请重试");
|
||||
setActiveTab("original"); // 失败后切回原图
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@ -151,45 +165,40 @@ export function AngleEditDialog({
|
||||
distance: 5,
|
||||
});
|
||||
setLoraScale(0.9);
|
||||
setActiveTab("original");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-6 py-4 border-b bg-muted/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Move3D className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>多角度编辑</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
拖拽 3D 控制器中的彩色手柄调整相机角度,AI 将生成对应视角的图像
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-none shadow-2xl bg-background/95 backdrop-blur-xl">
|
||||
{/* 紧凑标题栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-primary/10 rounded-md">
|
||||
<Move3D className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="font-semibold text-sm">3D 视角编辑器</span>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onOpenChange(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 h-full">
|
||||
{/* 左侧:3D 控制器 (占 5 列) */}
|
||||
<div className="lg:col-span-5 space-y-4 flex flex-col">
|
||||
<div className="flex-1 min-h-[400px]">
|
||||
<QwenCameraWidget
|
||||
initialState={cameraState}
|
||||
imageUrl={imageUrl}
|
||||
onStateChange={handleCameraStateChange}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-[500px]">
|
||||
{/* 左侧:3D 控制区 (固定宽度 320px) */}
|
||||
<div className="w-[320px] flex flex-col border-r bg-muted/10">
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
<QwenCameraWidget
|
||||
initialState={cameraState}
|
||||
imageUrl={imageUrl}
|
||||
onStateChange={handleCameraStateChange}
|
||||
className="h-[300px] mb-4"
|
||||
/>
|
||||
|
||||
{/* LoRA 强度控制 */}
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3 shadow-sm">
|
||||
<div className="space-y-3 px-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-sm font-medium">变换强度</Label>
|
||||
<span className="text-xs font-mono bg-muted px-2 py-1 rounded">
|
||||
<Label className="text-xs font-medium text-muted-foreground">变换强度</Label>
|
||||
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
|
||||
{(loraScale * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
@ -199,113 +208,29 @@ export function AngleEditDialog({
|
||||
min={0.5}
|
||||
max={1.0}
|
||||
step={0.05}
|
||||
className="py-2"
|
||||
className="py-1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
强度越高角度变化越明显,越低则保留更多原图特征。建议 85-95%。
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isProcessing}
|
||||
className="w-full h-8 text-xs mt-2"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-2" />
|
||||
重置参数
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:图片预览 (占 7 列) */}
|
||||
<div className="lg:col-span-7 flex flex-col gap-4 min-h-[500px]">
|
||||
<div className="grid grid-rows-2 gap-4 flex-1 h-full">
|
||||
{/* 原图 */}
|
||||
<div className="relative rounded-xl border bg-muted/30 overflow-hidden group">
|
||||
<div className="absolute top-3 left-3 z-10 bg-background/80 backdrop-blur-sm px-2 py-1 rounded text-xs font-medium border shadow-sm">
|
||||
原图
|
||||
</div>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="原图"
|
||||
className="w-full h-full object-contain p-4 transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 结果图 */}
|
||||
<div className={cn(
|
||||
"relative rounded-xl border overflow-hidden transition-all duration-300",
|
||||
resultUrl ? "bg-muted/30 border-primary/20 shadow-sm" : "bg-muted/10 border-dashed"
|
||||
)}>
|
||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"px-2 py-1 rounded text-xs font-medium border shadow-sm backdrop-blur-sm",
|
||||
resultUrl ? "bg-primary/10 text-primary border-primary/20" : "bg-background/80 text-muted-foreground"
|
||||
)}>
|
||||
生成结果
|
||||
</div>
|
||||
{resultUrl && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-green-600 bg-green-50 px-1.5 py-0.5 rounded-full border border-green-100">
|
||||
<Check className="h-3 w-3" /> 完成
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
{isProcessing ? (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="relative">
|
||||
<div className="h-12 w-12 rounded-full border-4 border-primary/20 border-t-primary animate-spin mx-auto" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Sparkles className="h-5 w-5 text-primary/50 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">正在生成新视角...</p>
|
||||
<p className="text-xs text-muted-foreground">预计需要 10-30 秒</p>
|
||||
</div>
|
||||
</div>
|
||||
) : resultUrl ? (
|
||||
<img
|
||||
src={resultUrl}
|
||||
alt="生成结果"
|
||||
className="w-full h-full object-contain p-4 animate-in fade-in zoom-in-95 duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground p-8">
|
||||
<div className="bg-muted rounded-full p-4 w-fit mx-auto mb-4">
|
||||
<Move3D className="h-8 w-8 opacity-50" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">准备就绪</p>
|
||||
<p className="text-xs mt-1">拖拽左侧彩色手柄调整角度后点击生成</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className="px-6 py-4 border-t bg-muted/30 flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleReset}
|
||||
disabled={isProcessing}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
重置参数
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{resultUrl ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载
|
||||
</Button>
|
||||
<Button onClick={handleUseImage} className="min-w-[120px]">
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
使用此图
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
{/* 生成按钮区 */}
|
||||
<div className="p-4 border-t bg-background">
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={isProcessing}
|
||||
className="min-w-[140px]"
|
||||
className="w-full shadow-md transition-all hover:scale-[1.02]"
|
||||
size="lg"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
@ -319,7 +244,76 @@ export function AngleEditDialog({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:预览区 (自适应) */}
|
||||
<div className="flex-1 flex flex-col bg-muted/5 p-4">
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="original" className="text-xs h-7 px-3">原图</TabsTrigger>
|
||||
<TabsTrigger value="result" className="text-xs h-7 px-3" disabled={!resultUrl && !isProcessing}>
|
||||
生成结果
|
||||
{resultUrl && <span className="ml-1.5 w-1.5 h-1.5 rounded-full bg-green-500" />}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{resultUrl && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleDownload} className="h-8 text-xs">
|
||||
<Download className="h-3 w-3 mr-1.5" />
|
||||
下载
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleUseImage} className="h-8 text-xs">
|
||||
<Check className="h-3 w-3 mr-1.5" />
|
||||
使用
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative rounded-lg border bg-background overflow-hidden shadow-inner">
|
||||
{/* 原图 Tab */}
|
||||
<TabsContent value="original" className="absolute inset-0 m-0 h-full w-full p-0">
|
||||
<div className="w-full h-full flex items-center justify-center p-4">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="原图"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 结果 Tab */}
|
||||
<TabsContent value="result" className="absolute inset-0 m-0 h-full w-full p-0">
|
||||
<div className="w-full h-full flex items-center justify-center bg-checkered p-4">
|
||||
{isProcessing ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className="h-10 w-10 rounded-full border-2 border-primary/20 border-t-primary animate-spin" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Sparkles className="h-4 w-4 text-primary/50 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground animate-pulse">正在渲染新视角...</p>
|
||||
</div>
|
||||
) : resultUrl ? (
|
||||
<img
|
||||
src={resultUrl}
|
||||
alt="生成结果"
|
||||
className="max-w-full max-h-full object-contain animate-in fade-in zoom-in-95 duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Move3D className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
||||
<p className="text-xs">暂无结果</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@ -328,3 +322,4 @@ export function AngleEditDialog({
|
||||
}
|
||||
|
||||
export default AngleEditDialog;
|
||||
|
||||
|
||||
@ -546,9 +546,9 @@ export function QwenCameraWidget({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden", className)} ref={containerRef}>
|
||||
<div className={cn("flex flex-col bg-transparent", className)} ref={containerRef}>
|
||||
{/* 3D 画布 */}
|
||||
<div className="relative w-full aspect-square bg-muted/30">
|
||||
<div className="relative w-full aspect-square bg-muted/20 rounded-lg overflow-hidden border">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full block cursor-grab active:cursor-grabbing"
|
||||
@ -562,87 +562,71 @@ export function QwenCameraWidget({
|
||||
/>
|
||||
|
||||
{/* 提示词浮层 */}
|
||||
<div className="absolute top-4 left-4 right-4 bg-background/80 backdrop-blur-md border rounded-md px-3 py-2 text-xs font-mono text-center shadow-sm select-all">
|
||||
<div className="absolute top-2 left-2 right-2 bg-background/80 backdrop-blur-md border rounded px-2 py-1.5 text-[10px] font-mono text-center shadow-sm select-all opacity-70 hover:opacity-100 transition-opacity truncate">
|
||||
{generatePrompt(state)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 控制面板 */}
|
||||
<div className="p-4 space-y-4 bg-card">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider pl-1">水平角度</label>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="grid grid-cols-1 gap-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground w-8">水平</label>
|
||||
<Select value={findClosestAzimuth(state.azimuth)} onValueChange={handleAzimuthChange}>
|
||||
<SelectTrigger className="h-8 text-xs border-primary/20 focus:ring-primary/20">
|
||||
<SelectTrigger className="h-6 text-[10px] border-primary/20 focus:ring-primary/20 min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AZIMUTH_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value.toString()} className="text-xs">
|
||||
<SelectItem key={opt.value} value={opt.value.toString()} className="text-[10px]">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-center">
|
||||
<Badge variant="outline" className="text-[10px] font-mono text-primary border-primary/30 bg-primary/5">
|
||||
{Math.round(state.azimuth)}°
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className="h-6 px-1.5 text-[10px] font-mono text-primary border-primary/30 bg-primary/5 min-w-[36px] justify-center">
|
||||
{Math.round(state.azimuth)}°
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider pl-1">垂直角度</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground w-8">垂直</label>
|
||||
<Select value={findClosestElevation(state.elevation)} onValueChange={handleElevationChange}>
|
||||
<SelectTrigger className="h-8 text-xs border-[#00FFD0]/30 focus:ring-[#00FFD0]/20">
|
||||
<SelectTrigger className="h-6 text-[10px] border-[#00FFD0]/30 focus:ring-[#00FFD0]/20 min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ELEVATION_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value.toString()} className="text-xs">
|
||||
<SelectItem key={opt.value} value={opt.value.toString()} className="text-[10px]">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-center">
|
||||
<Badge variant="outline" className="text-[10px] font-mono text-[#00b894] border-[#00b894]/30 bg-[#00b894]/5">
|
||||
{Math.round(state.elevation)}°
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className="h-6 px-1.5 text-[10px] font-mono text-[#00b894] border-[#00b894]/30 bg-[#00b894]/5 min-w-[36px] justify-center">
|
||||
{Math.round(state.elevation)}°
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider pl-1">距离</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground w-8">距离</label>
|
||||
<Select value={findClosestDistance(state.distance)} onValueChange={handleDistanceChange}>
|
||||
<SelectTrigger className="h-8 text-xs border-[#FFB800]/30 focus:ring-[#FFB800]/20">
|
||||
<SelectTrigger className="h-6 text-[10px] border-[#FFB800]/30 focus:ring-[#FFB800]/20 min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISTANCE_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value.toString()} className="text-xs">
|
||||
<SelectItem key={opt.value} value={opt.value.toString()} className="text-[10px]">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-center">
|
||||
<Badge variant="outline" className="text-[10px] font-mono text-[#d49a00] border-[#d49a00]/30 bg-[#d49a00]/5">
|
||||
{state.distance.toFixed(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className="h-6 px-1.5 text-[10px] font-mono text-[#d49a00] border-[#d49a00]/30 bg-[#d49a00]/5 min-w-[36px] justify-center">
|
||||
{state.distance.toFixed(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full h-8 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-2" />
|
||||
重置视角
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user