import { Button, Input, Select, Slider, Spin } from "antd"; import { Bell, ChevronLeft, ChevronRight, ExternalLink, RefreshCw, ScanSearch, Trash2, } from "lucide-react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import type { CameraMarker, LatestCameraEvent, } from "~/pages/desktop/floor_plan.types"; import type { FlatDeviceChannelOption } from "~/service/api/device/device"; import type { FloorPlanInteractionMode } from "~/pages/desktop/floor_plan.storage"; import { formatEventTimeAbsolute } from "~/pages/desktop/floor_plan.relative-time"; /** * 为什么要用“设备名称”而不是 device_id 做分组主键: * 现场绑点时,用户识别的是“卖场1、卖场2”这类业务名称,而不是长串国标 ID。 * 把分组口径改成设备名称,能让多设备场景下的查找路径和用户心智一致,减少误绑和反复展开分组的成本。 */ function buildGroupedChannelOptions(channelOptions: FlatDeviceChannelOption[]) { const grouped = new Map< string, { label: string; options: Array<{ value: string; label: string }> } >(); for (const item of channelOptions) { const groupKey = item.deviceName; const group = grouped.get(groupKey) ?? { label: item.deviceName, options: [], }; group.options.push({ value: item.value, label: `${item.deviceName} / ${item.channelName}`, }); grouped.set(groupKey, group); } return Array.from(grouped.values()) .map((group) => ({ ...group, options: group.options.sort((left, right) => left.label.localeCompare(right.label, "zh-CN"), ), })) .sort((left, right) => left.label.localeCompare(right.label, "zh-CN")); } /** * 为什么摄像头绑定面板要直接暴露加载状态和异常状态: * 绑定失败往往不是交互问题,而是后端分页、网络或数据规模导致的数据不完整。 * 把这些状态留在面板里直接提示,能让现场排查更快定位到“是没加载全”,而不是误判成摄像头或布局有问题。 */ export function CameraBindingPanel({ camera, channelOptions, channelsLoading, channelsError, onBindChannel, onAngleChange, onFovChange, onRangeChange, onDelete, interactionMode = "edit", channelFilter = "", onChannelFilterChange, selectedLatestEvent = null, selectedEventLoading = false, channelOnline = null, playbackTo = null, alertsTo = null, eventOccurredAgo = "", dataFetchedAgo = "", onRefreshEvent, filterMatchCount = 0, filterMatchActiveIndex = 0, onFilterPrev, onFilterNext, onFilterFrameAll, }: { camera: CameraMarker | null; channelOptions: FlatDeviceChannelOption[]; channelsLoading: boolean; channelsError: string | null; onBindChannel: (value: string | null) => void; onAngleChange: (value: number) => void; onFovChange: (value: number) => void; onRangeChange: (value: number) => void; onDelete: () => void; interactionMode?: FloorPlanInteractionMode; channelFilter?: string; onChannelFilterChange?: (value: string) => void; selectedLatestEvent?: LatestCameraEvent | null; selectedEventLoading?: boolean; channelOnline?: boolean | null; playbackTo?: { pathname: string; search: string } | null; alertsTo?: { pathname: string; search: string } | null; eventOccurredAgo?: string; dataFetchedAgo?: string; onRefreshEvent?: () => void; filterMatchCount?: number; filterMatchActiveIndex?: number; onFilterPrev?: () => void; onFilterNext?: () => void; onFilterFrameAll?: () => void; }) { const { t } = useTranslation("desktop"); const groupedChannelOptions = useMemo( () => buildGroupedChannelOptions(channelOptions), [channelOptions], ); const editLocked = interactionMode === "browse"; const filterTrimmed = channelFilter.trim(); const showFilterNav = Boolean(onChannelFilterChange) && Boolean(filterTrimmed) && Boolean(onFilterPrev && onFilterNext && onFilterFrameAll); return (