651 lines
22 KiB
TypeScript
651 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useCallback, useState } from "react";
|
|
import * as THREE from "three";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { RotateCcw } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// 相机状态接口
|
|
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(50, width / height, 0.1, 1000);
|
|
camera.position.set(4.5, 4, 4.5);
|
|
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.6);
|
|
scene.add(ambientLight);
|
|
|
|
const mainLight = new THREE.DirectionalLight(0xffffff, 1.0);
|
|
mainLight.position.set(5, 10, 5);
|
|
scene.add(mainLight);
|
|
|
|
const fillLight = new THREE.DirectionalLight(0xE93D82, 0.4);
|
|
fillLight.position.set(-5, 5, -5);
|
|
scene.add(fillLight);
|
|
|
|
// 网格 - 调整颜色以适应浅色/深色主题
|
|
// 使用更中性的颜色
|
|
const gridHelper = new THREE.GridHelper(5, 20, 0x888888, 0x444444);
|
|
gridHelper.position.y = -0.01;
|
|
(gridHelper.material as THREE.Material).transparent = true;
|
|
(gridHelper.material as THREE.Material).opacity = 0.2;
|
|
scene.add(gridHelper);
|
|
|
|
// 创建图片卡片
|
|
const cardGeo = new THREE.BoxGeometry(1.2, 1.2, 0.02);
|
|
const cardMat = new THREE.MeshBasicMaterial({ color: 0xcccccc });
|
|
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();
|
|
|
|
// 监听容器大小变化
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const { width, height } = entry.contentRect;
|
|
if (width && height && cameraRef.current && rendererRef.current) {
|
|
cameraRef.current.aspect = width / height;
|
|
cameraRef.current.updateProjectionMatrix();
|
|
rendererRef.current.setSize(width, height);
|
|
}
|
|
}
|
|
});
|
|
|
|
resizeObserver.observe(container);
|
|
|
|
// 清理
|
|
return () => {
|
|
if (animationIdRef.current) {
|
|
cancelAnimationFrame(animationIdRef.current);
|
|
}
|
|
resizeObserver.disconnect();
|
|
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: string) => {
|
|
const val = Number(value);
|
|
setState(prev => {
|
|
const newState = { ...prev, azimuth: val };
|
|
onStateChange?.(newState);
|
|
return newState;
|
|
});
|
|
}, [onStateChange]);
|
|
|
|
const handleElevationChange = useCallback((value: string) => {
|
|
const val = Number(value);
|
|
setState(prev => {
|
|
const newState = { ...prev, elevation: val };
|
|
onStateChange?.(newState);
|
|
return newState;
|
|
});
|
|
}, [onStateChange]);
|
|
|
|
const handleDistanceChange = useCallback((value: string) => {
|
|
const val = Number(value);
|
|
setState(prev => {
|
|
const newState = { ...prev, distance: val };
|
|
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.toString();
|
|
};
|
|
|
|
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.toString();
|
|
};
|
|
|
|
const findClosestDistance = (value: number) => {
|
|
if (value < 3) return "1";
|
|
if (value < 7) return "5";
|
|
return "8";
|
|
};
|
|
|
|
return (
|
|
<div className={cn("flex flex-col bg-transparent", className)} ref={containerRef}>
|
|
{/* 3D 画布 */}
|
|
<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"
|
|
onMouseDown={handlePointerDown}
|
|
onMouseMove={handlePointerMove}
|
|
onMouseUp={handlePointerUp}
|
|
onMouseLeave={handlePointerUp}
|
|
onTouchStart={handlePointerDown}
|
|
onTouchMove={handlePointerMove}
|
|
onTouchEnd={handlePointerUp}
|
|
/>
|
|
|
|
{/* 提示词浮层 */}
|
|
<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="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-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-[10px]">
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<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="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-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-[10px]">
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<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="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-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-[10px]">
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default QwenCameraWidget;
|