ai-chatbot/components/angle-edit-dialog.tsx
Your Name 13a4784fa5
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
style: 优化 3D 编辑器布局,实现无滚动完美视口体验
2026-01-11 11:21:01 +08:00

326 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useCallback, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} 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, 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;
onOpenChange: (open: boolean) => void;
imageUrl: string;
onComplete?: (resultUrl: string) => void;
}
// 根据角度值生成 API 所需的字符串参数
function getAzimuthString(azimuth: number): string {
const hAngle = azimuth % 360;
if (hAngle < 22.5 || hAngle >= 337.5) return "front";
if (hAngle < 67.5) return "front-right";
if (hAngle < 112.5) return "right";
if (hAngle < 157.5) return "back-right";
if (hAngle < 202.5) return "back";
if (hAngle < 247.5) return "back-left";
if (hAngle < 292.5) return "left";
return "front-left";
}
function getElevationString(elevation: number): string {
if (elevation < -15) return "low";
if (elevation < 15) return "eye-level";
if (elevation < 45) return "elevated";
return "high";
}
function getDistanceString(distance: number): string {
if (distance < 3) return "wide";
if (distance < 7) return "medium";
return "close";
}
export function AngleEditDialog({
open,
onOpenChange,
imageUrl,
}: AngleEditDialogProps) {
const [isProcessing, setIsProcessing] = useState(false);
const [resultUrl, setResultUrl] = useState<string | null>(null);
const [loraScale, setLoraScale] = useState(0.9);
const [cameraState, setCameraState] = useState<CameraState>({
azimuth: 45,
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);
// 角度变化时清除之前的结果
if (!isProcessing) {
setResultUrl(null);
}
}, [isProcessing]);
// 生成新视角
const handleGenerate = useCallback(async () => {
setIsProcessing(true);
setResultUrl(null);
setActiveTab("result"); // 切换到结果 Tab 显示加载状态
try {
const response = await fetch("/api/angle-edit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
image_url: imageUrl,
azimuth: getAzimuthString(cameraState.azimuth),
elevation: getElevationString(cameraState.elevation),
distance: getDistanceString(cameraState.distance),
lora_scale: loraScale,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "生成失败");
}
const data = await response.json();
setResultUrl(data.image_url);
toast.success("视角生成成功!");
} catch (error) {
console.error("角度编辑失败:", error);
toast.error(error instanceof Error ? error.message : "生成失败,请重试");
setActiveTab("original"); // 失败后切回原图
} finally {
setIsProcessing(false);
}
}, [imageUrl, cameraState, loraScale]);
// 下载结果图片
const handleDownload = useCallback(async () => {
if (!resultUrl) return;
try {
const response = await fetch(resultUrl);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `angle-edit-${cameraState.azimuth}-${cameraState.elevation}-${Date.now()}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("图片已下载");
} catch {
toast.error("下载失败");
}
}, [resultUrl, cameraState]);
// 使用此图片(直接发送到聊天)
const handleUseImage = useCallback(() => {
if (resultUrl) {
// 关闭对话框,图片已经通过 API 保存到数据库
onOpenChange(false);
toast.success("图片已添加到对话");
}
}, [resultUrl, onOpenChange]);
// 重置
const handleReset = useCallback(() => {
setResultUrl(null);
setCameraState({
azimuth: 45,
elevation: 0,
distance: 5,
});
setLoraScale(0.9);
setActiveTab("original");
}, []);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onOpenChange(false)}>
<X className="h-4 w-4" />
</Button>
</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"
/>
<div className="space-y-3 px-1">
<div className="flex justify-between items-center">
<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>
<Slider
value={[loraScale]}
onValueChange={([v]: number[]) => setLoraScale(v)}
min={0.5}
max={1.0}
step={0.05}
className="py-1"
/>
<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>
{/* 生成按钮区 */}
<div className="p-4 border-t bg-background">
<Button
onClick={handleGenerate}
disabled={isProcessing}
className="w-full shadow-md transition-all hover:scale-[1.02]"
size="lg"
>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
生成中...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
生成新视角
</>
)}
</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>
</Dialog>
);
}
export default AngleEditDialog;