style: 深度优化 3D 控制器 UI,适配 Shadcn 风格
This commit is contained in:
parent
87698afee8
commit
7d17ee1872
@ -2,6 +2,17 @@
|
||||
|
||||
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 {
|
||||
@ -162,7 +173,8 @@ export function QwenCameraWidget({
|
||||
|
||||
// 场景
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0a0f);
|
||||
// 移除背景色,使用透明背景
|
||||
// scene.background = new THREE.Color(0x0a0a0f);
|
||||
sceneRef.current = scene;
|
||||
|
||||
// 相机
|
||||
@ -175,7 +187,7 @@ export function QwenCameraWidget({
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas: canvasRef.current,
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
alpha: true, // 启用透明
|
||||
});
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
@ -183,25 +195,28 @@ export function QwenCameraWidget({
|
||||
rendererRef.current = renderer;
|
||||
|
||||
// 光照
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
const mainLight = new THREE.DirectionalLight(0xffffff, 1.0);
|
||||
mainLight.position.set(5, 10, 5);
|
||||
scene.add(mainLight);
|
||||
|
||||
const fillLight = new THREE.DirectionalLight(0xE93D82, 0.3);
|
||||
const fillLight = new THREE.DirectionalLight(0xE93D82, 0.4);
|
||||
fillLight.position.set(-5, 5, -5);
|
||||
scene.add(fillLight);
|
||||
|
||||
// 网格
|
||||
const gridHelper = new THREE.GridHelper(5, 20, 0x1a1a2e, 0x12121a);
|
||||
// 网格 - 调整颜色以适应浅色/深色主题
|
||||
// 使用更中性的颜色
|
||||
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: 0x3a3a4a });
|
||||
const cardMat = new THREE.MeshBasicMaterial({ color: 0xcccccc });
|
||||
const imageCard = new THREE.Mesh(cardGeo, cardMat);
|
||||
imageCard.position.copy(CENTER);
|
||||
scene.add(imageCard);
|
||||
@ -459,25 +474,28 @@ export function QwenCameraWidget({
|
||||
}, []);
|
||||
|
||||
// 下拉选择器变化处理
|
||||
const handleAzimuthChange = useCallback((value: number) => {
|
||||
const handleAzimuthChange = useCallback((value: string) => {
|
||||
const val = Number(value);
|
||||
setState(prev => {
|
||||
const newState = { ...prev, azimuth: value };
|
||||
const newState = { ...prev, azimuth: val };
|
||||
onStateChange?.(newState);
|
||||
return newState;
|
||||
});
|
||||
}, [onStateChange]);
|
||||
|
||||
const handleElevationChange = useCallback((value: number) => {
|
||||
const handleElevationChange = useCallback((value: string) => {
|
||||
const val = Number(value);
|
||||
setState(prev => {
|
||||
const newState = { ...prev, elevation: value };
|
||||
const newState = { ...prev, elevation: val };
|
||||
onStateChange?.(newState);
|
||||
return newState;
|
||||
});
|
||||
}, [onStateChange]);
|
||||
|
||||
const handleDistanceChange = useCallback((value: number) => {
|
||||
const handleDistanceChange = useCallback((value: string) => {
|
||||
const val = Number(value);
|
||||
setState(prev => {
|
||||
const newState = { ...prev, distance: value };
|
||||
const newState = { ...prev, distance: val };
|
||||
onStateChange?.(newState);
|
||||
return newState;
|
||||
});
|
||||
@ -505,7 +523,7 @@ export function QwenCameraWidget({
|
||||
closest = opt.value;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
return closest.toString();
|
||||
};
|
||||
|
||||
const findClosestElevation = (value: number) => {
|
||||
@ -518,21 +536,22 @@ export function QwenCameraWidget({
|
||||
closest = opt.value;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
return closest.toString();
|
||||
};
|
||||
|
||||
const findClosestDistance = (value: number) => {
|
||||
if (value < 3) return 1;
|
||||
if (value < 7) return 5;
|
||||
return 8;
|
||||
if (value < 3) return "1";
|
||||
if (value < 7) return "5";
|
||||
return "8";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`qwen-camera-widget ${className || ""}`} ref={containerRef}>
|
||||
<div className={cn("flex flex-col rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden", className)} ref={containerRef}>
|
||||
{/* 3D 画布 */}
|
||||
<div className="qwen-canvas-container">
|
||||
<div className="relative w-full aspect-square bg-muted/30">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full block cursor-grab active:cursor-grabbing"
|
||||
onMouseDown={handlePointerDown}
|
||||
onMouseMove={handlePointerMove}
|
||||
onMouseUp={handlePointerUp}
|
||||
@ -541,203 +560,92 @@ export function QwenCameraWidget({
|
||||
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 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">
|
||||
{generatePrompt(state)}
|
||||
</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 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>
|
||||
<Select value={findClosestAzimuth(state.azimuth)} onValueChange={handleAzimuthChange}>
|
||||
<SelectTrigger className="h-8 text-xs border-primary/20 focus:ring-primary/20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AZIMUTH_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value.toString()} className="text-xs">
|
||||
{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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider pl-1">垂直角度</label>
|
||||
<Select value={findClosestElevation(state.elevation)} onValueChange={handleElevationChange}>
|
||||
<SelectTrigger className="h-8 text-xs border-[#00FFD0]/30 focus:ring-[#00FFD0]/20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ELEVATION_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value.toString()} className="text-xs">
|
||||
{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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider pl-1">距离</label>
|
||||
<Select value={findClosestDistance(state.distance)} onValueChange={handleDistanceChange}>
|
||||
<SelectTrigger className="h-8 text-xs border-[#FFB800]/30 focus:ring-[#FFB800]/20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISTANCE_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value.toString()} className="text-xs">
|
||||
{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>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 内联样式 */}
|
||||
<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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user