feat: 替换为专业的 Qwen 3D 相机控制器
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

- 新增 qwen-camera-widget.tsx:基于 Three.js 的专业 3D 相机控制组件
- 彩色拖拽手柄:粉色(方位角)、青色(仰角)、金色(距离)
- 下拉快速选择预设角度
- 实时数值显示和提示词预览
- 暗色主题 UI,符合 ComfyUI-qwenmultiangle 设计风格
- 更新 angle-edit-dialog 使用新组件
This commit is contained in:
Your Name 2026-01-11 11:08:00 +08:00
parent 604d7425ad
commit 87698afee8
2 changed files with 812 additions and 51 deletions

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import {
Dialog,
DialogContent,
@ -13,7 +13,7 @@ import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { Loader2, Download, Check, RotateCcw, Move3D, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { AngleController3D } from "./angle-controller-3d";
import { QwenCameraWidget, type CameraState } from "./qwen-camera-widget";
import { cn } from "@/lib/utils";
interface AngleEditDialogProps {
@ -23,37 +23,58 @@ interface AngleEditDialogProps {
onComplete?: (resultUrl: string) => void;
}
interface AngleParams {
azimuth: string;
elevation: string;
distance: string;
azimuthDegree: number;
elevationDegree: number;
distanceFactor: number;
// 根据角度值生成 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,
onComplete,
}: AngleEditDialogProps) {
const [isProcessing, setIsProcessing] = useState(false);
const [resultUrl, setResultUrl] = useState<string | null>(null);
const [loraScale, setLoraScale] = useState(0.9);
const [currentAngles, setCurrentAngles] = useState<AngleParams>({
azimuth: "front-right",
elevation: "eye-level",
distance: "medium",
azimuthDegree: 45,
elevationDegree: 0,
distanceFactor: 1.0,
const [cameraState, setCameraState] = useState<CameraState>({
azimuth: 45,
elevation: 0,
distance: 5,
});
// 处理角度变化
const handleAngleChange = useCallback((angles: AngleParams) => {
setCurrentAngles(angles);
// 清除之前的结果,因为角度变了
// 当对话框关闭时重置状态
useEffect(() => {
if (!open) {
setResultUrl(null);
setIsProcessing(false);
}
}, [open]);
// 处理相机状态变化
const handleCameraStateChange = useCallback((state: CameraState) => {
setCameraState(state);
// 角度变化时清除之前的结果
setResultUrl(null);
}, []);
@ -68,9 +89,9 @@ export function AngleEditDialog({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
image_url: imageUrl,
azimuth: currentAngles.azimuth,
elevation: currentAngles.elevation,
distance: currentAngles.distance,
azimuth: getAzimuthString(cameraState.azimuth),
elevation: getElevationString(cameraState.elevation),
distance: getDistanceString(cameraState.distance),
lora_scale: loraScale,
}),
});
@ -89,7 +110,7 @@ export function AngleEditDialog({
} finally {
setIsProcessing(false);
}
}, [imageUrl, currentAngles, loraScale]);
}, [imageUrl, cameraState, loraScale]);
// 下载结果图片
const handleDownload = useCallback(async () => {
@ -101,7 +122,7 @@ export function AngleEditDialog({
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `angle-edit-${currentAngles.azimuth}-${currentAngles.elevation}-${Date.now()}.png`;
a.download = `angle-edit-${cameraState.azimuth}-${cameraState.elevation}-${Date.now()}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@ -110,33 +131,31 @@ export function AngleEditDialog({
} catch {
toast.error("下载失败");
}
}, [resultUrl, currentAngles]);
}, [resultUrl, cameraState]);
// 使用此图片
// 使用此图片(直接发送到聊天)
const handleUseImage = useCallback(() => {
if (resultUrl) {
onComplete?.(resultUrl);
// 关闭对话框,图片已经通过 API 保存到数据库
onOpenChange(false);
toast.success("图片已添加到对话");
}
}, [resultUrl, onComplete, onOpenChange]);
}, [resultUrl, onOpenChange]);
// 重置
const handleReset = useCallback(() => {
setResultUrl(null);
setCurrentAngles({
azimuth: "front-right",
elevation: "eye-level",
distance: "medium",
azimuthDegree: 45,
elevationDegree: 0,
distanceFactor: 1.0,
setCameraState({
azimuth: 45,
elevation: 0,
distance: 5,
});
setLoraScale(0.9);
}, []);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col p-0 gap-0">
<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">
@ -146,7 +165,7 @@ export function AngleEditDialog({
<div>
<DialogTitle>多角度编辑</DialogTitle>
<DialogDescription className="mt-1">
拖拽 3D 控制器选择相机角度,AI 将生成对应视角的图像
拖拽 3D 控制器中的彩色手柄调整相机角度,AI 将生成对应视角的图像
</DialogDescription>
</div>
</div>
@ -154,20 +173,20 @@ export function AngleEditDialog({
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 h-full">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 h-full">
{/* 左侧:3D 控制器 (占 5 列) */}
<div className="lg:col-span-5 space-y-6 flex flex-col">
<div className="flex-1">
<AngleController3D
onChange={handleAngleChange}
initialAzimuth={currentAngles.azimuthDegree}
initialElevation={currentAngles.elevationDegree}
initialDistance={currentAngles.distanceFactor}
<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>
{/* LoRA 强度控制 - 放入卡片 */}
<div className="rounded-lg border bg-card p-4 space-y-4 shadow-sm">
{/* LoRA 强度控制 */}
<div className="rounded-lg border bg-card p-4 space-y-3 shadow-sm">
<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">
@ -248,7 +267,7 @@ export function AngleEditDialog({
<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>
<p className="text-xs mt-1">拖拽左侧彩色手柄调整角度后点击生成</p>
</div>
)}
</div>
@ -309,4 +328,3 @@ export function AngleEditDialog({
}
export default AngleEditDialog;

View File

@ -0,0 +1,743 @@
"use client";
import { useEffect, useRef, useCallback, useState } from "react";
import * as THREE from "three";
// 相机状态接口
export interface CameraState {
azimuth: number; // 方位角 0-360
elevation: number; // 仰角 -30 到 60
distance: number; // 距离 0-10
}
interface QwenCameraWidgetProps {
initialState?: Partial<CameraState>;
imageUrl?: string | null;
onStateChange?: (state: CameraState) => void;
className?: string;
}
// 下拉选项定义
const AZIMUTH_OPTIONS = [
{ label: "正前方", value: 0 },
{ label: "右前方", value: 45 },
{ label: "正右方", value: 90 },
{ label: "右后方", value: 135 },
{ label: "正后方", value: 180 },
{ label: "左后方", value: 225 },
{ label: "正左方", value: 270 },
{ label: "左前方", value: 315 },
];
const ELEVATION_OPTIONS = [
{ label: "低角度", value: -30 },
{ label: "平视", value: 0 },
{ label: "微俯视", value: 30 },
{ label: "高角度", value: 60 },
];
const DISTANCE_OPTIONS = [
{ label: "远景", value: 1 },
{ label: "中景", value: 5 },
{ label: "近景", value: 8 },
];
export function QwenCameraWidget({
initialState,
imageUrl,
onStateChange,
className,
}: QwenCameraWidgetProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const animationIdRef = useRef<number | null>(null);
// 控制对象引用
const azimuthHandleRef = useRef<THREE.Mesh | null>(null);
const elevationHandleRef = useRef<THREE.Mesh | null>(null);
const distanceHandleRef = useRef<THREE.Mesh | null>(null);
const cameraIndicatorRef = useRef<THREE.Mesh | null>(null);
const azimuthRingRef = useRef<THREE.Mesh | null>(null);
const elevationArcRef = useRef<THREE.Mesh | null>(null);
const imageCardRef = useRef<THREE.Mesh | null>(null);
// 状态
const [state, setState] = useState<CameraState>({
azimuth: initialState?.azimuth ?? 45,
elevation: initialState?.elevation ?? 0,
distance: initialState?.distance ?? 5,
});
// 交互状态
const isDraggingRef = useRef(false);
const dragTargetRef = useRef<string | null>(null);
const raycasterRef = useRef(new THREE.Raycaster());
const mouseRef = useRef(new THREE.Vector2());
// 常量
const CENTER = new THREE.Vector3(0, 0.5, 0);
const AZIMUTH_RADIUS = 1.8;
const ELEVATION_RADIUS = 1.4;
const ELEV_ARC_X = -0.8;
// 生成提示词
const generatePrompt = useCallback((s: CameraState) => {
const hAngle = s.azimuth % 360;
let hDirection: string;
if (hAngle < 22.5 || hAngle >= 337.5) hDirection = "front view";
else if (hAngle < 67.5) hDirection = "front-right quarter view";
else if (hAngle < 112.5) hDirection = "right side view";
else if (hAngle < 157.5) hDirection = "back-right quarter view";
else if (hAngle < 202.5) hDirection = "back view";
else if (hAngle < 247.5) hDirection = "back-left quarter view";
else if (hAngle < 292.5) hDirection = "left side view";
else hDirection = "front-left quarter view";
let vDirection: string;
if (s.elevation < -15) vDirection = "low-angle shot";
else if (s.elevation < 15) vDirection = "eye-level shot";
else if (s.elevation < 45) vDirection = "elevated shot";
else vDirection = "high-angle shot";
let distance: string;
if (s.distance < 3) distance = "wide shot";
else if (s.distance < 7) distance = "medium shot";
else distance = "close-up";
return `<sks> ${hDirection} ${vDirection} ${distance}`;
}, []);
// 更新场景中的对象位置
const updateVisuals = useCallback((s: CameraState) => {
const azRad = (s.azimuth * Math.PI) / 180;
const elRad = (s.elevation * Math.PI) / 180;
const visualDist = 2.6 - (s.distance / 10) * 2.0;
// 相机指示器位置
if (cameraIndicatorRef.current) {
const camX = visualDist * Math.sin(azRad) * Math.cos(elRad);
const camY = CENTER.y + visualDist * Math.sin(elRad);
const camZ = visualDist * Math.cos(azRad) * Math.cos(elRad);
cameraIndicatorRef.current.position.set(camX, camY, camZ);
cameraIndicatorRef.current.lookAt(CENTER);
cameraIndicatorRef.current.rotateX(Math.PI / 2);
}
// 方位角手柄位置
if (azimuthHandleRef.current) {
const azX = AZIMUTH_RADIUS * Math.sin(azRad);
const azZ = AZIMUTH_RADIUS * Math.cos(azRad);
azimuthHandleRef.current.position.set(azX, 0.16, azZ);
}
// 仰角手柄位置
if (elevationHandleRef.current) {
const elY = CENTER.y + ELEVATION_RADIUS * Math.sin(elRad);
const elZ = ELEVATION_RADIUS * Math.cos(elRad);
elevationHandleRef.current.position.set(ELEV_ARC_X, elY, elZ);
}
// 距离手柄位置
if (distanceHandleRef.current && cameraIndicatorRef.current) {
const distT = 0.15 + ((10 - s.distance) / 10) * 0.7;
distanceHandleRef.current.position.lerpVectors(
CENTER,
cameraIndicatorRef.current.position,
distT
);
}
}, []);
// 初始化 Three.js 场景
useEffect(() => {
if (!canvasRef.current || !containerRef.current) return;
const container = containerRef.current;
const width = container.clientWidth || 400;
const height = container.clientHeight || 400;
// 场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0f);
sceneRef.current = scene;
// 相机
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
camera.position.set(4, 3.5, 4);
camera.lookAt(0, 0.3, 0);
cameraRef.current = camera;
// 渲染器
const renderer = new THREE.WebGLRenderer({
canvas: canvasRef.current,
antialias: true,
alpha: true,
});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
rendererRef.current = renderer;
// 光照
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8);
mainLight.position.set(5, 10, 5);
scene.add(mainLight);
const fillLight = new THREE.DirectionalLight(0xE93D82, 0.3);
fillLight.position.set(-5, 5, -5);
scene.add(fillLight);
// 网格
const gridHelper = new THREE.GridHelper(5, 20, 0x1a1a2e, 0x12121a);
gridHelper.position.y = -0.01;
scene.add(gridHelper);
// 创建图片卡片
const cardGeo = new THREE.BoxGeometry(1.2, 1.2, 0.02);
const cardMat = new THREE.MeshBasicMaterial({ color: 0x3a3a4a });
const imageCard = new THREE.Mesh(cardGeo, cardMat);
imageCard.position.copy(CENTER);
scene.add(imageCard);
imageCardRef.current = imageCard;
// 卡片边框
const frameGeo = new THREE.EdgesGeometry(cardGeo);
const frameMat = new THREE.LineBasicMaterial({ color: 0xE93D82 });
const frame = new THREE.LineSegments(frameGeo, frameMat);
frame.position.copy(CENTER);
scene.add(frame);
// 方位角圆环 (粉色)
const azRingGeo = new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.04, 16, 100);
const azRingMat = new THREE.MeshBasicMaterial({
color: 0xE93D82,
transparent: true,
opacity: 0.7,
});
const azimuthRing = new THREE.Mesh(azRingGeo, azRingMat);
azimuthRing.rotation.x = Math.PI / 2;
azimuthRing.position.y = 0.02;
scene.add(azimuthRing);
azimuthRingRef.current = azimuthRing;
// 方位角手柄 (粉色球)
const azHandleGeo = new THREE.SphereGeometry(0.16, 32, 32);
const azHandleMat = new THREE.MeshStandardMaterial({
color: 0xE93D82,
emissive: 0xE93D82,
emissiveIntensity: 0.6,
metalness: 0.3,
roughness: 0.4,
});
const azimuthHandle = new THREE.Mesh(azHandleGeo, azHandleMat);
azimuthHandle.userData.type = "azimuth";
scene.add(azimuthHandle);
azimuthHandleRef.current = azimuthHandle;
// 仰角弧线 (青色)
const arcPoints: THREE.Vector3[] = [];
for (let i = 0; i <= 32; i++) {
const angle = (-30 + (90 * i / 32)) * Math.PI / 180;
arcPoints.push(new THREE.Vector3(
ELEV_ARC_X,
ELEVATION_RADIUS * Math.sin(angle) + CENTER.y,
ELEVATION_RADIUS * Math.cos(angle)
));
}
const arcCurve = new THREE.CatmullRomCurve3(arcPoints);
const elArcGeo = new THREE.TubeGeometry(arcCurve, 32, 0.04, 8, false);
const elArcMat = new THREE.MeshBasicMaterial({
color: 0x00FFD0,
transparent: true,
opacity: 0.8,
});
const elevationArc = new THREE.Mesh(elArcGeo, elArcMat);
scene.add(elevationArc);
elevationArcRef.current = elevationArc;
// 仰角手柄 (青色球)
const elHandleGeo = new THREE.SphereGeometry(0.16, 32, 32);
const elHandleMat = new THREE.MeshStandardMaterial({
color: 0x00FFD0,
emissive: 0x00FFD0,
emissiveIntensity: 0.6,
metalness: 0.3,
roughness: 0.4,
});
const elevationHandle = new THREE.Mesh(elHandleGeo, elHandleMat);
elevationHandle.userData.type = "elevation";
scene.add(elevationHandle);
elevationHandleRef.current = elevationHandle;
// 距离手柄 (金色球)
const distHandleGeo = new THREE.SphereGeometry(0.15, 32, 32);
const distHandleMat = new THREE.MeshStandardMaterial({
color: 0xFFB800,
emissive: 0xFFB800,
emissiveIntensity: 0.7,
metalness: 0.5,
roughness: 0.3,
});
const distanceHandle = new THREE.Mesh(distHandleGeo, distHandleMat);
distanceHandle.userData.type = "distance";
scene.add(distanceHandle);
distanceHandleRef.current = distanceHandle;
// 相机指示器 (粉色锥体)
const camGeo = new THREE.ConeGeometry(0.15, 0.4, 4);
const camMat = new THREE.MeshStandardMaterial({
color: 0xE93D82,
emissive: 0xE93D82,
emissiveIntensity: 0.5,
metalness: 0.8,
roughness: 0.2,
});
const cameraIndicator = new THREE.Mesh(camGeo, camMat);
scene.add(cameraIndicator);
cameraIndicatorRef.current = cameraIndicator;
// 初始化位置
updateVisuals(state);
// 动画循环
let time = 0;
const animate = () => {
animationIdRef.current = requestAnimationFrame(animate);
time += 0.01;
renderer.render(scene, camera);
};
animate();
// 清理
return () => {
if (animationIdRef.current) {
cancelAnimationFrame(animationIdRef.current);
}
renderer.dispose();
scene.clear();
};
}, []);
// 更新图片
useEffect(() => {
if (!imageCardRef.current || !imageUrl) return;
const loader = new THREE.TextureLoader();
loader.load(
imageUrl,
(texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
const mat = imageCardRef.current!.material as THREE.MeshBasicMaterial;
mat.map = texture;
mat.color.set(0xffffff);
mat.needsUpdate = true;
// 调整卡片比例
const img = texture.image as HTMLImageElement;
if (img) {
const ar = img.width / img.height;
const maxSize = 1.5;
let scaleX: number, scaleY: number;
if (ar > 1) {
scaleX = maxSize;
scaleY = maxSize / ar;
} else {
scaleY = maxSize;
scaleX = maxSize * ar;
}
imageCardRef.current!.scale.set(scaleX, scaleY, 1);
}
},
undefined,
() => {
console.error("Failed to load image texture");
}
);
}, [imageUrl]);
// 更新视觉效果当状态变化
useEffect(() => {
updateVisuals(state);
}, [state, updateVisuals]);
// 鼠标/触摸事件处理
const getMousePos = useCallback((event: MouseEvent | Touch, canvas: HTMLCanvasElement) => {
const rect = canvas.getBoundingClientRect();
mouseRef.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouseRef.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}, []);
const handlePointerDown = useCallback((event: React.MouseEvent | React.TouchEvent) => {
if (!canvasRef.current || !cameraRef.current) return;
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
const clientY = "touches" in event ? event.touches[0].clientY : event.clientY;
getMousePos({ clientX, clientY } as MouseEvent, canvasRef.current);
raycasterRef.current.setFromCamera(mouseRef.current, cameraRef.current);
const handles = [
azimuthHandleRef.current,
elevationHandleRef.current,
distanceHandleRef.current,
].filter(Boolean) as THREE.Mesh[];
const intersects = raycasterRef.current.intersectObjects(handles);
if (intersects.length > 0) {
isDraggingRef.current = true;
dragTargetRef.current = intersects[0].object.userData.type;
if (canvasRef.current) {
canvasRef.current.style.cursor = "grabbing";
}
}
}, [getMousePos]);
const handlePointerMove = useCallback((event: React.MouseEvent | React.TouchEvent) => {
if (!canvasRef.current || !cameraRef.current || !isDraggingRef.current) return;
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
const clientY = "touches" in event ? event.touches[0].clientY : event.clientY;
getMousePos({ clientX, clientY } as MouseEvent, canvasRef.current);
raycasterRef.current.setFromCamera(mouseRef.current, cameraRef.current);
const plane = new THREE.Plane();
const intersect = new THREE.Vector3();
if (dragTargetRef.current === "azimuth") {
plane.setFromNormalAndCoplanarPoint(
new THREE.Vector3(0, 1, 0),
new THREE.Vector3(0, 0, 0)
);
if (raycasterRef.current.ray.intersectPlane(plane, intersect)) {
let angle = Math.atan2(intersect.x, intersect.z) * (180 / Math.PI);
if (angle < 0) angle += 360;
const newAzimuth = Math.max(0, Math.min(360, angle));
setState(prev => {
const newState = { ...prev, azimuth: Math.round(newAzimuth) };
onStateChange?.(newState);
return newState;
});
}
} else if (dragTargetRef.current === "elevation") {
const elevPlane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -ELEV_ARC_X);
if (raycasterRef.current.ray.intersectPlane(elevPlane, intersect)) {
const relY = intersect.y - CENTER.y;
const relZ = intersect.z;
let angle = Math.atan2(relY, relZ) * (180 / Math.PI);
angle = Math.max(-30, Math.min(60, angle));
setState(prev => {
const newState = { ...prev, elevation: Math.round(angle) };
onStateChange?.(newState);
return newState;
});
}
} else if (dragTargetRef.current === "distance") {
const newDist = 5 - mouseRef.current.y * 5;
const clampedDist = Math.max(0, Math.min(10, newDist));
setState(prev => {
const newState = { ...prev, distance: Math.round(clampedDist * 10) / 10 };
onStateChange?.(newState);
return newState;
});
}
}, [getMousePos, onStateChange]);
const handlePointerUp = useCallback(() => {
isDraggingRef.current = false;
dragTargetRef.current = null;
if (canvasRef.current) {
canvasRef.current.style.cursor = "default";
}
}, []);
// 下拉选择器变化处理
const handleAzimuthChange = useCallback((value: number) => {
setState(prev => {
const newState = { ...prev, azimuth: value };
onStateChange?.(newState);
return newState;
});
}, [onStateChange]);
const handleElevationChange = useCallback((value: number) => {
setState(prev => {
const newState = { ...prev, elevation: value };
onStateChange?.(newState);
return newState;
});
}, [onStateChange]);
const handleDistanceChange = useCallback((value: number) => {
setState(prev => {
const newState = { ...prev, distance: value };
onStateChange?.(newState);
return newState;
});
}, [onStateChange]);
// 重置
const handleReset = useCallback(() => {
const defaultState = { azimuth: 0, elevation: 0, distance: 5 };
setState(defaultState);
onStateChange?.(defaultState);
}, [onStateChange]);
// 找到最接近的选项
const findClosestAzimuth = (value: number) => {
let closest = AZIMUTH_OPTIONS[0].value;
let minDiff = 360;
for (const opt of AZIMUTH_OPTIONS) {
const diff = Math.min(
Math.abs(value - opt.value),
Math.abs(value - opt.value - 360),
Math.abs(value - opt.value + 360)
);
if (diff < minDiff) {
minDiff = diff;
closest = opt.value;
}
}
return closest;
};
const findClosestElevation = (value: number) => {
let closest = ELEVATION_OPTIONS[0].value;
let minDiff = 100;
for (const opt of ELEVATION_OPTIONS) {
const diff = Math.abs(value - opt.value);
if (diff < minDiff) {
minDiff = diff;
closest = opt.value;
}
}
return closest;
};
const findClosestDistance = (value: number) => {
if (value < 3) return 1;
if (value < 7) return 5;
return 8;
};
return (
<div className={`qwen-camera-widget ${className || ""}`} ref={containerRef}>
{/* 3D 画布 */}
<div className="qwen-canvas-container">
<canvas
ref={canvasRef}
onMouseDown={handlePointerDown}
onMouseMove={handlePointerMove}
onMouseUp={handlePointerUp}
onMouseLeave={handlePointerUp}
onTouchStart={handlePointerDown}
onTouchMove={handlePointerMove}
onTouchEnd={handlePointerUp}
/>
</div>
{/* 提示词显示 */}
<div className="qwen-prompt">
{generatePrompt(state)}
</div>
{/* 下拉选择器 */}
<div className="qwen-dropdowns">
<div className="qwen-dropdown">
<span className="qwen-label azimuth">水平</span>
<select
value={findClosestAzimuth(state.azimuth)}
onChange={(e) => handleAzimuthChange(Number(e.target.value))}
>
{AZIMUTH_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="qwen-dropdown">
<span className="qwen-label elevation">垂直</span>
<select
value={findClosestElevation(state.elevation)}
onChange={(e) => handleElevationChange(Number(e.target.value))}
>
{ELEVATION_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="qwen-dropdown">
<span className="qwen-label distance">距离</span>
<select
value={findClosestDistance(state.distance)}
onChange={(e) => handleDistanceChange(Number(e.target.value))}
>
{DISTANCE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* 数值显示和重置 */}
<div className="qwen-info">
<div className="qwen-param">
<span className="qwen-param-label">水平角度</span>
<span className="qwen-param-value azimuth">{Math.round(state.azimuth)}°</span>
</div>
<div className="qwen-param">
<span className="qwen-param-label">垂直角度</span>
<span className="qwen-param-value elevation">{Math.round(state.elevation)}°</span>
</div>
<div className="qwen-param">
<span className="qwen-param-label">距离</span>
<span className="qwen-param-value distance">{state.distance.toFixed(1)}</span>
</div>
<button className="qwen-reset" onClick={handleReset} title="重置">
↺
</button>
</div>
{/* 内联样式 */}
<style jsx>{`
.qwen-camera-widget {
display: flex;
flex-direction: column;
gap: 12px;
background: #0a0a0f;
border-radius: 12px;
padding: 16px;
font-family: system-ui, -apple-system, sans-serif;
}
.qwen-canvas-container {
width: 100%;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
}
.qwen-canvas-container canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
.qwen-prompt {
background: #1a1a2e;
color: #E93D82;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
font-family: monospace;
text-align: center;
border: 1px solid #2a2a3e;
}
.qwen-dropdowns {
display: flex;
gap: 8px;
}
.qwen-dropdown {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.qwen-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.qwen-label.azimuth { color: #E93D82; }
.qwen-label.elevation { color: #00FFD0; }
.qwen-label.distance { color: #FFB800; }
.qwen-dropdown select {
background: #1a1a2e;
color: #fff;
border: 1px solid #2a2a3e;
border-radius: 6px;
padding: 8px 10px;
font-size: 12px;
cursor: pointer;
outline: none;
transition: border-color 0.2s;
}
.qwen-dropdown select:hover {
border-color: #3a3a4e;
}
.qwen-dropdown select:focus {
border-color: #E93D82;
}
.qwen-info {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 12px;
background: #12121a;
border-radius: 6px;
}
.qwen-param {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.qwen-param-label {
font-size: 10px;
color: #666;
text-transform: uppercase;
}
.qwen-param-value {
font-size: 14px;
font-weight: 600;
font-family: monospace;
}
.qwen-param-value.azimuth { color: #E93D82; }
.qwen-param-value.elevation { color: #00FFD0; }
.qwen-param-value.distance { color: #FFB800; }
.qwen-reset {
width: 32px;
height: 32px;
background: #2a2a3e;
border: none;
border-radius: 6px;
color: #888;
font-size: 16px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.qwen-reset:hover {
background: #3a3a4e;
color: #fff;
}
`}</style>
</div>
);
}
export default QwenCameraWidget;