style: 深度优化 3D 控制器 UI,适配 Shadcn 风格
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-01-11 11:15:43 +08:00
parent 87698afee8
commit 7d17ee1872

View File

@ -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;