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 (
{onChannelFilterChange ? (
{t("filter_cameras")}
onChannelFilterChange(e.target.value)} />
{t("filter_cameras_hint")}
{showFilterNav ? (
{filterMatchCount > 0 ? ( <> {t("filter_match_position", { current: filterMatchActiveIndex + 1, total: filterMatchCount, })} {t("filter_keyboard_hint")} ) : ( {t("filter_no_matches")} )}
) : null}
) : null} {camera ? (
{t("panel_latest_ai_event")}
{camera.channelId && onRefreshEvent ? (
{selectedEventLoading ? (
) : selectedLatestEvent ? (
{selectedLatestEvent.imageSrc ? ( {selectedLatestEvent.label} { console.warn("[floor-plan] panel event image failed", { channelId: selectedLatestEvent.channelId, }); }} /> ) : null}
{t("latest_ai_event")}:{" "} {selectedLatestEvent.label}
{t("event_time")}:{" "} {formatEventTimeAbsolute(selectedLatestEvent.startedAt)}
{t("score")}:{" "} {(selectedLatestEvent.score * 100).toFixed(1)}%
{eventOccurredAgo ? (
{t("event_occurred_ago", { ago: eventOccurredAgo })}
) : null} {dataFetchedAgo ? (
{t("data_fetched_ago", { ago: dataFetchedAgo })}
) : null}
) : (
{camera.channelId ? t("no_ai_event") : t("camera_unbound")}
{camera.channelId && dataFetchedAgo ? (
{t("data_fetched_ago", { ago: dataFetchedAgo })}
) : null}
)}
) : null} {!camera ? (
{t("no_camera_selected")}
) : ( <>
{t("camera_settings")}
{t("position")}: {Math.round(camera.x)}, {Math.round(camera.y)}
{camera.channelId != null ? (
{t("channel_status")}:{" "} {channelOnline === null ? t("channel_online_unknown") : channelOnline ? t("channel_online") : t("channel_offline")}
) : null} {editLocked ? (
{t("browse_camera_panel_edit_hidden_hint")}
) : null}
{editLocked ? null : ( <>
{t("bind_channel")}
{channelsLoading ? (
) : ( <>