feat: app 端 ui 设计完成
This commit is contained in:
331
wei-ai-demo/pages/ManualControl.tsx
Normal file
331
wei-ai-demo/pages/ManualControl.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
RotateCw,
|
||||
Bluetooth,
|
||||
Battery,
|
||||
ChevronLeft,
|
||||
Zap,
|
||||
Sliders,
|
||||
Waves,
|
||||
Power
|
||||
} from 'lucide-react';
|
||||
import { AreaChart, Area, ResponsiveContainer } from 'recharts';
|
||||
|
||||
type ViewMode = 'hub' | 'free' | 'pattern';
|
||||
type DeviceState = 'disconnected' | 'connecting' | 'connected';
|
||||
|
||||
interface Pattern {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const PATTERNS: Pattern[] = [
|
||||
{ id: 'pulse', name: '脉冲跳动', icon: <Activity size={18} />, color: '#C084FC' },
|
||||
{ id: 'wave', name: '深海潮汐', icon: <Waves size={18} />, color: '#60A5FA' },
|
||||
{ id: 'climb', name: '登峰造极', icon: <RotateCw size={18} />, color: '#34D399' },
|
||||
{ id: 'storm', name: '雷雨风暴', icon: <Zap size={18} />, color: '#FBBF24' },
|
||||
{ id: 'chaos', name: '随机漫步', icon: <Sliders size={18} />, color: '#F472B6' },
|
||||
{ id: 'sos', name: 'SOS', icon: <Power size={18} />, color: '#F87171' },
|
||||
];
|
||||
|
||||
const DeviceCard: React.FC<{
|
||||
status: DeviceState;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
}> = ({ status, onConnect, onDisconnect }) => {
|
||||
return (
|
||||
<div className="w-full glass-panel p-5 rounded-3xl mb-6 bg-white/10 border border-white/20 relative overflow-hidden shadow-lg">
|
||||
<div className="absolute -right-6 -top-6 text-white/5">
|
||||
<Bluetooth size={120} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-start relative z-10">
|
||||
<div>
|
||||
<h3 className="text-white font-bold text-lg flex items-center gap-2">
|
||||
Link-X Pro
|
||||
{status === 'connected' && (
|
||||
<span className="text-[10px] font-bold bg-[#34D399]/20 text-[#34D399] px-2 py-0.5 rounded-full border border-[#34D399]/30 leading-none mt-0.5">
|
||||
ONLINE
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-white/60 text-xs font-mono mt-1">
|
||||
{status === 'disconnected' ? '设备未连接' : status === 'connecting' ? '正在搜索信号...' : 'ID: 884-X9-01'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={status === 'connected' ? onDisconnect : onConnect}
|
||||
className={`px-5 py-2 rounded-full text-xs font-bold transition-all ${
|
||||
status === 'connected'
|
||||
? 'bg-white/10 text-white/70 border border-white/10'
|
||||
: status === 'connecting'
|
||||
? 'bg-white/20 text-white animate-pulse'
|
||||
: 'bg-white text-[#2e1065] shadow-lg hover:shadow-xl'
|
||||
}`}
|
||||
>
|
||||
{status === 'connected' ? '断开' : status === 'connecting' ? '连接中' : '连接设备'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === 'connected' && (
|
||||
<div className="mt-6 flex items-center gap-6 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Battery size={16} className="text-[#34D399]" />
|
||||
<span className="text-sm font-mono text-white">85%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FreeControlView: React.FC<{ onBack: () => void }> = ({ onBack }) => {
|
||||
const [intensity, setIntensity] = useState(0);
|
||||
const [isClimax, setIsClimax] = useState(false);
|
||||
const controlRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleInteraction = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (!controlRef.current) return;
|
||||
const rect = controlRef.current.getBoundingClientRect();
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY;
|
||||
const relativeY = Math.max(0, Math.min(1, (rect.bottom - clientY) / rect.height));
|
||||
setIntensity(Math.round(relativeY * 100));
|
||||
};
|
||||
|
||||
const handleClimax = () => {
|
||||
setIsClimax(true);
|
||||
setIntensity(100);
|
||||
if (window.navigator.vibrate) window.navigator.vibrate(500);
|
||||
setTimeout(() => {
|
||||
setIsClimax(false);
|
||||
setIntensity(20);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center mb-6">
|
||||
<button onClick={onBack} className="p-2 -ml-2 text-white/70 hover:text-white">
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
<h2 className="text-lg font-bold text-white ml-2">自由操控</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<div
|
||||
ref={controlRef}
|
||||
className="relative w-48 h-[60vh] rounded-[32px] border border-white/20 bg-white/5 shadow-2xl overflow-hidden touch-none backdrop-blur-xl"
|
||||
onTouchMove={handleInteraction}
|
||||
onMouseDown={(e) => e.buttons === 1 && handleInteraction(e)}
|
||||
onMouseMove={(e) => e.buttons === 1 && handleInteraction(e)}
|
||||
>
|
||||
<div
|
||||
className={`absolute bottom-0 w-full transition-all duration-75 ease-linear backdrop-blur-md flex items-start justify-center pt-2 ${isClimax ? 'bg-red-500/50' : 'bg-[#C084FC]/50'}`}
|
||||
style={{ height: `${intensity}%` }}
|
||||
>
|
||||
<div className={`w-full h-[3px] ${isClimax ? 'bg-red-500 shadow-[0_0_20px_white]' : 'bg-white shadow-[0_0_15px_white]'}`}></div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col justify-between p-6 pointer-events-none">
|
||||
<span className="text-xs text-white/40 font-mono mx-auto">MAX</span>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className={`text-5xl font-bold font-mono tracking-tighter transition-colors drop-shadow-lg ${isClimax ? 'text-red-200' : 'text-white'}`}>
|
||||
{intensity}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-white/40 font-mono mx-auto">OFF</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-white/60 text-xs font-mono tracking-wider">上下滑动触控板以控制强度</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleClimax}
|
||||
disabled={isClimax}
|
||||
className={`w-full py-4 mt-6 rounded-2xl font-bold tracking-widest text-white transition-all active:scale-95 shadow-lg ${
|
||||
isClimax
|
||||
? 'bg-red-500 animate-pulse cursor-not-allowed'
|
||||
: 'bg-white/10 border border-red-400/50 text-red-300 hover:bg-red-500/20'
|
||||
}`}
|
||||
>
|
||||
{isClimax ? 'MAX OUTPUT...' : '一键爆发'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PatternControlView: React.FC<{ onBack: () => void }> = ({ onBack }) => {
|
||||
const [activePattern, setActivePattern] = useState<string | null>(null);
|
||||
const [globalIntensity, setGlobalIntensity] = useState(50);
|
||||
|
||||
const generateWaveData = (patternId: string | null) => {
|
||||
if (!patternId) return Array(20).fill({ v: 10 });
|
||||
return Array.from({ length: 20 }, (_, i) => ({
|
||||
v: patternId === 'pulse' ? (i % 5 === 0 ? 90 : 20) :
|
||||
patternId === 'wave' ? 40 + Math.sin(i) * 30 :
|
||||
Math.random() * 80 + 10
|
||||
}));
|
||||
};
|
||||
const [waveData, setWaveData] = useState(generateWaveData(null));
|
||||
|
||||
useEffect(() => {
|
||||
if (activePattern) {
|
||||
const interval = setInterval(() => {
|
||||
setWaveData(prev => {
|
||||
const next = [...prev.slice(1), { v: Math.random() * (globalIntensity) + 20 }];
|
||||
return next;
|
||||
});
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
} else {
|
||||
setWaveData(Array(20).fill({ v: 10 }));
|
||||
}
|
||||
}, [activePattern, globalIntensity]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center mb-6">
|
||||
<button onClick={onBack} className="p-2 -ml-2 text-white/70 hover:text-white">
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
<h2 className="text-lg font-bold text-white ml-2">波形控制</h2>
|
||||
</div>
|
||||
|
||||
<div className="h-40 w-full glass-panel rounded-3xl mb-8 p-4 flex items-center justify-center relative overflow-hidden bg-white/5 border border-white/10">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={waveData}>
|
||||
<defs>
|
||||
<linearGradient id="waveGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#C084FC" stopOpacity={0.8}/>
|
||||
<stop offset="95%" stopColor="#C084FC" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="v"
|
||||
stroke="#E9D5FF"
|
||||
strokeWidth={3}
|
||||
fill="url(#waveGradient)"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between text-xs text-white/60 mb-2 uppercase tracking-wider">
|
||||
<span>强度</span>
|
||||
<span>{globalIntensity}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={globalIntensity}
|
||||
onChange={(e) => setGlobalIntensity(parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-white/20 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 overflow-y-auto no-scrollbar pb-6">
|
||||
{PATTERNS.map(p => {
|
||||
const isActive = activePattern === p.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setActivePattern(isActive ? null : p.id)}
|
||||
className={`p-4 rounded-2xl border transition-all duration-300 flex flex-col items-center gap-2 ${
|
||||
isActive
|
||||
? 'bg-white text-[#2e1065] border-white shadow-lg scale-[1.02]'
|
||||
: 'bg-white/5 border-white/10 text-white/60 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className={`p-2 rounded-full ${isActive ? 'bg-[#2e1065]/10 text-[#2e1065]' : 'bg-white/5'}`}>
|
||||
{p.icon}
|
||||
</div>
|
||||
<span className="text-sm font-bold">
|
||||
{p.name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ManualControl: React.FC = () => {
|
||||
const [view, setView] = useState<ViewMode>('hub');
|
||||
const [deviceStatus, setDeviceStatus] = useState<DeviceState>('disconnected');
|
||||
|
||||
const connectDevice = () => {
|
||||
setDeviceStatus('connecting');
|
||||
setTimeout(() => setDeviceStatus('connected'), 1500);
|
||||
};
|
||||
|
||||
const disconnectDevice = () => {
|
||||
setDeviceStatus('disconnected');
|
||||
};
|
||||
|
||||
if (view === 'free') return <div className="pt-4 px-6 h-full"><FreeControlView onBack={() => setView('hub')} /></div>;
|
||||
if (view === 'pattern') return <div className="pt-4 px-6 h-full"><PatternControlView onBack={() => setView('hub')} /></div>;
|
||||
|
||||
return (
|
||||
<div className="pt-4 px-6 h-full flex flex-col">
|
||||
<div className="mb-2">
|
||||
<DeviceCard
|
||||
status={deviceStatus}
|
||||
onConnect={connectDevice}
|
||||
onDisconnect={disconnectDevice}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xs font-bold text-white/40 uppercase tracking-widest mb-3">操控模式</h2>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
|
||||
<button
|
||||
onClick={() => setView('free')}
|
||||
disabled={deviceStatus !== 'connected'}
|
||||
className={`group relative overflow-hidden h-36 rounded-3xl flex items-center px-8 transition-all ${
|
||||
deviceStatus === 'connected'
|
||||
? 'bg-white/10 border border-white/20 active:scale-[0.98] backdrop-blur-md'
|
||||
: 'bg-white/5 border border-white/5 opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-[#C084FC]/20 flex items-center justify-center text-[#C084FC] group-hover:scale-110 transition-transform">
|
||||
<Sliders size={28} />
|
||||
</div>
|
||||
<div className="ml-5 text-left">
|
||||
<h3 className="text-xl font-bold text-white group-hover:text-[#C084FC] transition-colors">自由操控</h3>
|
||||
<p className="text-xs text-white/50 mt-1">指尖滑动控制 • 实时反馈</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setView('pattern')}
|
||||
disabled={deviceStatus !== 'connected'}
|
||||
className={`group relative overflow-hidden h-36 rounded-3xl flex items-center px-8 transition-all ${
|
||||
deviceStatus === 'connected'
|
||||
? 'bg-white/10 border border-white/20 active:scale-[0.98] backdrop-blur-md'
|
||||
: 'bg-white/5 border border-white/5 opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-[#60A5FA]/20 flex items-center justify-center text-[#60A5FA] group-hover:scale-110 transition-transform">
|
||||
<Waves size={28} />
|
||||
</div>
|
||||
<div className="ml-5 text-left">
|
||||
<h3 className="text-xl font-bold text-white group-hover:text-[#60A5FA] transition-colors">波形模式</h3>
|
||||
<p className="text-xs text-white/50 mt-1">9种预设震动韵律</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManualControl;
|
||||
Reference in New Issue
Block a user