ai-chatbot/components/angle-edit-dialog.tsx

378 lines
14 KiB
TypeScript
Raw Permalink 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;
}
export function AngleEditDialog({
open,
onOpenChange,
imageUrl,
onComplete,
}: AngleEditDialogProps) {
const [isProcessing, setIsProcessing] = useState(false);
const [resultUrl, setResultUrl] = useState<string | null>(null);
const [loraScale, setLoraScale] = useState(1.0); // 官方默认值是 1
const [cameraState, setCameraState] = useState<CameraState>({
azimuth: 45,
elevation: 0,
distance: 5,
});
// 预览 Tab 状态:如果生成了结果,默认显示结果;否则显示原图
const [activeTab, setActiveTab] = useState<"original" | "result">("original");
const stopDialogEventPropagation = useCallback((event: React.SyntheticEvent) => {
event.stopPropagation();
}, []);
const stopDialogEventCapture = useCallback((event: React.SyntheticEvent) => {
event.stopPropagation();
}, []);
const stopDialogWheelPropagation = useCallback((event: React.WheelEvent) => {
event.stopPropagation();
}, []);
const stopDialogWheelCapture = useCallback((event: React.WheelEvent) => {
event.preventDefault();
event.stopPropagation();
}, []);
// 当对话框关闭时重置状态
useEffect(() => {
if (!open) {
setResultUrl(null);
setIsProcessing(false);
setActiveTab("original");
}
}, [open]);
// 当生成结果时,自动切换到结果 Tab
useEffect(() => {
if (resultUrl) {
setActiveTab("result");
}
}, [resultUrl]);
// 处理对话框关闭(防止意外触发导航)
const handleOpenChange = useCallback((newOpen: boolean) => {
// 只有在明确关闭时才调用 onOpenChange
// 避免在处理过程中意外关闭
if (!newOpen && !isProcessing) {
onOpenChange(newOpen);
}
}, [isProcessing, onOpenChange]);
// 处理相机状态变化
const handleCameraStateChange = useCallback((state: CameraState) => {
setCameraState(state);
// 角度变化时清除之前的结果
if (!isProcessing) {
setResultUrl(null);
}
}, [isProcessing]);
// 生成新视角
const handleGenerate = useCallback(async (e?: React.MouseEvent) => {
if (e) {
e.stopPropagation(); // 阻止事件冒泡,避免触发外层的 onCancel
}
setIsProcessing(true);
setResultUrl(null);
setActiveTab("result"); // 切换到结果 Tab 显示加载状态
try {
// 使用官方 API 的数值参数格式
const response = await fetch("/api/angle-edit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
image_url: imageUrl,
horizontal_angle: cameraState.azimuth, // 0-360 度
vertical_angle: cameraState.elevation, // -30 到 90 度
zoom: cameraState.distance, // 0-10
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((e?: React.MouseEvent) => {
if (e) {
e.stopPropagation(); // 阻止事件冒泡,避免触发外层的 onCancel
e.preventDefault(); // 阻止默认行为
}
if (resultUrl) {
// 如果提供了 onComplete 回调,调用它
if (onComplete) {
onComplete(resultUrl);
}
// 关闭对话框,图片已经通过 API 保存到数据库
// 使用 setTimeout 确保回调先执行,避免事件冒泡
setTimeout(() => {
onOpenChange(false);
}, 100);
toast.success("图片已添加到对话");
}
}, [resultUrl, onOpenChange, onComplete]);
// 重置
const handleReset = useCallback(() => {
setResultUrl(null);
setCameraState({
azimuth: 45,
elevation: 0,
distance: 5,
});
setLoraScale(0.9);
setActiveTab("original");
}, []);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
overlayClassName="bg-black"
className="max-w-4xl gap-0 overflow-hidden border border-border !bg-white p-0 shadow-2xl sm:rounded-[var(--ui-radius-panel)] dark:!bg-zinc-950"
onPointerDownCapture={stopDialogEventCapture}
onPointerMoveCapture={stopDialogEventCapture}
onPointerUpCapture={stopDialogEventCapture}
onMouseDown={stopDialogEventPropagation}
onMouseMove={stopDialogEventPropagation}
onMouseUp={stopDialogEventPropagation}
onTouchStart={stopDialogEventPropagation}
onTouchMove={stopDialogEventPropagation}
onTouchEnd={stopDialogEventPropagation}
onWheelCapture={stopDialogWheelCapture}
onWheel={stopDialogWheelPropagation}
onInteractOutside={(e) => {
// 阻止点击遮罩层关闭对话框(防止意外触发导航)
if (isProcessing) {
e.preventDefault();
}
}}
onEscapeKeyDown={(e) => {
// 处理过程中阻止 ESC 关闭
if (isProcessing) {
e.preventDefault();
}
}}
>
{/* 紧凑标题栏 */}
<div className="flex items-center justify-between border-b border-border bg-white px-6 py-4 dark:bg-zinc-950">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Move3D className="h-4 w-4 text-primary" />
</div>
<span className="font-semibold text-base">3D 视角编辑器</span>
</div>
</div>
<div className="flex h-[550px] bg-white dark:bg-zinc-950">
{/* 左侧:3D 控制区 (固定宽度 340px) */}
<div className="flex w-[340px] flex-col border-r border-border bg-white dark:bg-zinc-950">
{/* 上部内容区域 - 可伸缩 */}
<div className="flex-1 flex flex-col p-4 overflow-hidden">
{/* QwenCameraWidget - 包含 Canvas 和 3个角度选择器 */}
<QwenCameraWidget
initialState={cameraState}
imageUrl={imageUrl}
onStateChange={handleCameraStateChange}
className="flex-1 min-h-0 mb-3"
/>
{/* 变换强度 - 调整样式使其更紧凑 */}
<div className="space-y-2 px-1 flex-shrink-0">
<div className="flex items-center gap-2">
<label className="text-[10px] font-semibold text-muted-foreground w-8">强度</label>
<div className="flex-1 flex items-center gap-2">
<Slider
value={[loraScale]}
onValueChange={([v]: number[]) => setLoraScale(v)}
min={0.5}
max={1.0}
step={0.05}
className="flex-1 py-1"
/>
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground min-w-[36px] text-center">
{(loraScale * 100).toFixed(0)}%
</span>
</div>
</div>
</div>
</div>
{/* 底部按钮区域 - 包含重置和生成 */}
<div className="flex shrink-0 gap-2 border-t bg-white p-4 dark:bg-zinc-950">
<Button
variant="outline"
size="lg"
onClick={handleReset}
disabled={isProcessing}
className="h-10 w-10 p-0 flex-shrink-0"
title="重置参数"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
onClick={handleGenerate}
disabled={isProcessing}
className="flex-1 shadow-md transition-all hover:scale-[1.02] h-10"
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 flex-1 flex-col bg-white p-4 dark:bg-zinc-950">
<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="relative flex-1 overflow-hidden rounded-lg border bg-white shadow-inner dark:bg-zinc-950">
{/* 原图 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 ? (
<div className="relative w-full h-full flex items-center justify-center group">
<img
src={resultUrl}
alt="生成结果"
className="max-w-full max-h-full object-contain animate-in fade-in zoom-in-95 duration-300"
/>
<div className="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<Button onClick={handleDownload} size="sm" variant="secondary" className="shadow-lg bg-background hover:bg-muted">
<Download className="h-4 w-4 mr-2" />
下载图片
</Button>
</div>
</div>
) : (
<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;