261 lines
9.0 KiB
TypeScript
261 lines
9.0 KiB
TypeScript
import { useMutation } from "@tanstack/react-query";
|
|
import {
|
|
ArrowDown,
|
|
ArrowDownLeft,
|
|
ArrowDownRight,
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
ArrowUp,
|
|
ArrowUpLeft,
|
|
ArrowUpRight,
|
|
Minus,
|
|
Plus,
|
|
Square,
|
|
} from "lucide-react";
|
|
import { memo, useCallback, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
import { Slider } from "~/components/ui/slider";
|
|
import { PTZControl, type PTZDirection } from "~/service/api/channel/channel";
|
|
|
|
interface PTZPanelProps {
|
|
channelId: string;
|
|
deviceType?: string;
|
|
/** 云台类型 (0=无云台/未知, >0=有云台) - 来自后端通道 ptztype 字段 */
|
|
ptztype?: number;
|
|
}
|
|
|
|
// 为什么: 开发/联调期 ptztype 尚未稳定返回, 允许面板始终显示, 生产期关闭即可。
|
|
const TEST_MODE = true;
|
|
|
|
type PtrEvt = React.MouseEvent | React.TouchEvent;
|
|
|
|
interface DirectionButtonProps {
|
|
direction: PTZDirection;
|
|
activeDirection: PTZDirection | null;
|
|
onStart: (d: PTZDirection, e?: PtrEvt) => void;
|
|
onStop: (e?: PtrEvt) => void;
|
|
icon: React.ReactNode;
|
|
ariaLabel: string;
|
|
}
|
|
|
|
// 为什么: 用 memo + 稳定回调引用避免速度滑块变动时全盘重渲染, 保障拖动手感。
|
|
const DirectionButton = memo(function DirectionButton({
|
|
direction,
|
|
activeDirection,
|
|
onStart,
|
|
onStop,
|
|
icon,
|
|
ariaLabel,
|
|
}: DirectionButtonProps) {
|
|
const isActive = activeDirection === direction;
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={ariaLabel}
|
|
onMouseDown={(e) => onStart(direction, e)}
|
|
onMouseUp={onStop}
|
|
onMouseLeave={onStop}
|
|
onTouchStart={(e) => onStart(direction, e)}
|
|
onTouchEnd={onStop}
|
|
className={`
|
|
relative flex items-center justify-center
|
|
h-11 w-11 sm:h-12 sm:w-12 rounded-xl select-none
|
|
transition-all duration-150 active:scale-95
|
|
border shadow-sm
|
|
${
|
|
isActive
|
|
? "bg-primary text-primary-foreground border-primary shadow-md shadow-primary/30"
|
|
: "bg-background text-foreground/80 border-border hover:bg-accent hover:text-foreground hover:-translate-y-[1px]"
|
|
}
|
|
`}
|
|
>
|
|
{icon}
|
|
</button>
|
|
);
|
|
});
|
|
|
|
interface ZoomButtonProps {
|
|
direction: "zoomin" | "zoomout";
|
|
activeDirection: PTZDirection | null;
|
|
onStart: (d: PTZDirection, e?: PtrEvt) => void;
|
|
onStop: (e?: PtrEvt) => void;
|
|
icon: React.ReactNode;
|
|
}
|
|
|
|
const ZoomButton = memo(function ZoomButton({
|
|
direction,
|
|
activeDirection,
|
|
onStart,
|
|
onStop,
|
|
icon,
|
|
}: ZoomButtonProps) {
|
|
const isActive = activeDirection === direction;
|
|
return (
|
|
<button
|
|
type="button"
|
|
onMouseDown={(e) => onStart(direction, e)}
|
|
onMouseUp={onStop}
|
|
onMouseLeave={onStop}
|
|
onTouchStart={(e) => onStart(direction, e)}
|
|
onTouchEnd={onStop}
|
|
className={`
|
|
flex items-center justify-center
|
|
h-9 rounded-lg border text-xs font-medium
|
|
transition-all duration-150 active:scale-95 select-none
|
|
${
|
|
isActive
|
|
? "bg-primary text-primary-foreground border-primary shadow-sm"
|
|
: "bg-background text-foreground/80 border-border hover:bg-accent"
|
|
}
|
|
`}
|
|
>
|
|
{icon}
|
|
</button>
|
|
);
|
|
});
|
|
|
|
export function PTZPanel({ channelId, deviceType, ptztype }: PTZPanelProps) {
|
|
const [speed, setSpeed] = useState(0.5);
|
|
const [activeDirection, setActiveDirection] = useState<PTZDirection | null>(
|
|
null,
|
|
);
|
|
|
|
const ptzMutation = useMutation({
|
|
mutationFn: (data: Parameters<typeof PTZControl>[1]) =>
|
|
PTZControl(channelId, data),
|
|
onError: (error: any) => {
|
|
toast.error(error?.message || "云台控制失败");
|
|
},
|
|
});
|
|
|
|
// 为什么: mousedown 立即发起 continuous, mouseup/leave/touchend 发 stop,
|
|
// 同方向去重避免连发; speedRef 让回调引用保持稳定, 防止速度滑块变动时子组件重渲染。
|
|
const speedRef = useRef(speed);
|
|
speedRef.current = speed;
|
|
|
|
const handleStart = useCallback(
|
|
(direction: PTZDirection, e?: PtrEvt) => {
|
|
e?.preventDefault?.();
|
|
e?.stopPropagation?.();
|
|
setActiveDirection((prev) => {
|
|
if (prev === direction) return prev;
|
|
ptzMutation.mutate({
|
|
action: "continuous",
|
|
direction,
|
|
speed: speedRef.current,
|
|
});
|
|
return direction;
|
|
});
|
|
},
|
|
[ptzMutation],
|
|
);
|
|
|
|
const handleStop = useCallback(
|
|
(e?: PtrEvt) => {
|
|
e?.preventDefault?.();
|
|
e?.stopPropagation?.();
|
|
setActiveDirection((prev) => {
|
|
if (prev) {
|
|
ptzMutation.mutate({ action: "stop" });
|
|
}
|
|
return null;
|
|
});
|
|
},
|
|
[ptzMutation],
|
|
);
|
|
|
|
const isSupportedProtocol =
|
|
deviceType === "GB28181" || deviceType === "ONVIF";
|
|
const supportsPTZ = TEST_MODE
|
|
? isSupportedProtocol
|
|
: isSupportedProtocol && (ptztype ?? 0) > 0;
|
|
|
|
if (!supportsPTZ) {
|
|
return null;
|
|
}
|
|
|
|
const directionPad = (
|
|
<div className="grid grid-cols-3 gap-1.5 w-fit mx-auto">
|
|
<DirectionButton direction="upleft" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowUpLeft className="h-4 w-4" />} ariaLabel="左上" />
|
|
<DirectionButton direction="up" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowUp className="h-4 w-4" />} ariaLabel="上" />
|
|
<DirectionButton direction="upright" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowUpRight className="h-4 w-4" />} ariaLabel="右上" />
|
|
<DirectionButton direction="left" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowLeft className="h-4 w-4" />} ariaLabel="左" />
|
|
<button
|
|
type="button"
|
|
onClick={(e) => handleStop(e)}
|
|
aria-label="停止"
|
|
className="flex items-center justify-center h-11 w-11 sm:h-12 sm:w-12 rounded-xl select-none bg-destructive/10 text-destructive border border-destructive/30 hover:bg-destructive hover:text-destructive-foreground transition-all duration-150 active:scale-95"
|
|
>
|
|
<Square className="h-3.5 w-3.5 fill-current" />
|
|
</button>
|
|
<DirectionButton direction="right" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowRight className="h-4 w-4" />} ariaLabel="右" />
|
|
<DirectionButton direction="downleft" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowDownLeft className="h-4 w-4" />} ariaLabel="左下" />
|
|
<DirectionButton direction="down" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowDown className="h-4 w-4" />} ariaLabel="下" />
|
|
<DirectionButton direction="downright" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<ArrowDownRight className="h-4 w-4" />} ariaLabel="右下" />
|
|
</div>
|
|
);
|
|
|
|
const controlPanel = (
|
|
<div className="space-y-3 flex-1 min-w-0">
|
|
{/* 速度控制 */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between text-[11px]">
|
|
<span className="text-muted-foreground">速度</span>
|
|
<span className="font-mono tabular-nums text-foreground/80">
|
|
{Math.round(speed * 100)}%
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
value={[speed]}
|
|
onValueChange={(v) => setSpeed(v[0])}
|
|
min={0.1}
|
|
max={1}
|
|
step={0.1}
|
|
disabled={activeDirection !== null}
|
|
className="cursor-pointer"
|
|
/>
|
|
</div>
|
|
|
|
{/* 变焦控制 */}
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
<ZoomButton direction="zoomin" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<Plus className="h-4 w-4" />} />
|
|
<ZoomButton direction="zoomout" activeDirection={activeDirection} onStart={handleStart} onStop={handleStop} icon={<Minus className="h-4 w-4" />} />
|
|
</div>
|
|
|
|
<div className="text-[10px] text-muted-foreground/70 text-center leading-relaxed">
|
|
按住移动 · 松开停止
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<Card className="border-primary/15 sm:border bg-gradient-to-b from-background to-muted/30 shadow-sm sm:shadow-sm border-0 sm:border-primary/15">
|
|
<CardHeader className="pb-2 pt-2 sm:pt-3 px-1 sm:px-3">
|
|
<CardTitle className="text-xs font-semibold flex items-center justify-between text-foreground/70">
|
|
<span>云台控制</span>
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px] px-1.5 py-0 h-5 font-normal"
|
|
>
|
|
{deviceType || "PTZ"}
|
|
</Badge>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="px-1 sm:px-3 pb-2 sm:pb-3">
|
|
{/* 移动端左右布局,PC端上下布局 */}
|
|
<div className="flex gap-3 sm:hidden mx-auto max-w-[350px]">
|
|
<div className="shrink-0">{directionPad}</div>
|
|
{controlPanel}
|
|
</div>
|
|
<div className="hidden sm:block space-y-3">
|
|
{directionPad}
|
|
{controlPanel}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|