411 lines
15 KiB
TypeScript
411 lines
15 KiB
TypeScript
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 (
|
|
<div className="space-y-4">
|
|
{onChannelFilterChange ? (
|
|
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
|
<div className="mb-2 text-sm font-medium text-gray-900">
|
|
{t("filter_cameras")}
|
|
</div>
|
|
<Input
|
|
allowClear
|
|
value={channelFilter}
|
|
placeholder={t("filter_cameras_placeholder")}
|
|
onChange={(e) => onChannelFilterChange(e.target.value)}
|
|
/>
|
|
<div className="mt-2 text-xs text-gray-500">
|
|
{t("filter_cameras_hint")}
|
|
</div>
|
|
{showFilterNav ? (
|
|
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-gray-100 pt-3">
|
|
{filterMatchCount > 0 ? (
|
|
<>
|
|
<span className="text-xs text-gray-600">
|
|
{t("filter_match_position", {
|
|
current: filterMatchActiveIndex + 1,
|
|
total: filterMatchCount,
|
|
})}
|
|
</span>
|
|
<Button
|
|
type="default"
|
|
size="small"
|
|
icon={<ChevronLeft className="h-3.5 w-3.5" />}
|
|
onClick={onFilterPrev}
|
|
title={t("filter_prev_match")}
|
|
/>
|
|
<Button
|
|
type="default"
|
|
size="small"
|
|
icon={<ChevronRight className="h-3.5 w-3.5" />}
|
|
onClick={onFilterNext}
|
|
title={t("filter_next_match")}
|
|
/>
|
|
<Button
|
|
type="default"
|
|
size="small"
|
|
icon={<ScanSearch className="h-3.5 w-3.5" />}
|
|
onClick={onFilterFrameAll}
|
|
>
|
|
{t("filter_frame_all")}
|
|
</Button>
|
|
<span className="w-full text-[11px] text-gray-400">
|
|
{t("filter_keyboard_hint")}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span className="text-xs text-amber-700">
|
|
{t("filter_no_matches")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{camera ? (
|
|
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
<div className="text-sm font-semibold text-gray-900">
|
|
{t("panel_latest_ai_event")}
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-end gap-1">
|
|
{camera.channelId && onRefreshEvent ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
|
loading={selectedEventLoading}
|
|
onClick={onRefreshEvent}
|
|
title={t("refresh_event_tooltip")}
|
|
/>
|
|
) : null}
|
|
{camera.channelId && alertsTo ? (
|
|
<Link
|
|
to={alertsTo}
|
|
className="inline-flex items-center gap-1 rounded-lg bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700"
|
|
>
|
|
<Bell className="h-3.5 w-3.5" />
|
|
{t("open_alerts")}
|
|
</Link>
|
|
) : null}
|
|
{camera.channelId && playbackTo ? (
|
|
<Link
|
|
to={playbackTo}
|
|
className="inline-flex items-center gap-1 rounded-lg bg-gray-900 px-2 py-1 text-xs font-medium text-white hover:bg-gray-800"
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
{t("open_playback")}
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{selectedEventLoading ? (
|
|
<div className="flex min-h-20 items-center justify-center">
|
|
<Spin size="small" />
|
|
</div>
|
|
) : selectedLatestEvent ? (
|
|
<div className="space-y-2 text-xs text-gray-600">
|
|
{selectedLatestEvent.imageSrc ? (
|
|
<img
|
|
src={selectedLatestEvent.imageSrc}
|
|
alt={selectedLatestEvent.label}
|
|
className="h-32 w-full rounded-lg border border-gray-200 object-cover"
|
|
onError={() => {
|
|
console.warn("[floor-plan] panel event image failed", {
|
|
channelId: selectedLatestEvent.channelId,
|
|
});
|
|
}}
|
|
/>
|
|
) : null}
|
|
<div>
|
|
<span className="font-medium text-gray-900">
|
|
{t("latest_ai_event")}:{" "}
|
|
</span>
|
|
{selectedLatestEvent.label}
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-gray-900">
|
|
{t("event_time")}:{" "}
|
|
</span>
|
|
{formatEventTimeAbsolute(selectedLatestEvent.startedAt)}
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-gray-900">
|
|
{t("score")}:{" "}
|
|
</span>
|
|
{(selectedLatestEvent.score * 100).toFixed(1)}%
|
|
</div>
|
|
{eventOccurredAgo ? (
|
|
<div className="text-[11px] text-gray-500">
|
|
{t("event_occurred_ago", { ago: eventOccurredAgo })}
|
|
</div>
|
|
) : null}
|
|
{dataFetchedAgo ? (
|
|
<div className="text-[11px] text-gray-400">
|
|
{t("data_fetched_ago", { ago: dataFetchedAgo })}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<div className="rounded-lg bg-gray-50 px-3 py-3 text-center text-sm text-gray-500">
|
|
{camera.channelId ? t("no_ai_event") : t("camera_unbound")}
|
|
</div>
|
|
{camera.channelId && dataFetchedAgo ? (
|
|
<div className="text-center text-[11px] text-gray-400">
|
|
{t("data_fetched_ago", { ago: dataFetchedAgo })}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{!camera ? (
|
|
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 p-4 text-sm text-gray-500">
|
|
{t("no_camera_selected")}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
|
<div className="mb-1 text-sm font-semibold text-gray-900">
|
|
{t("camera_settings")}
|
|
</div>
|
|
<div className="text-xs text-gray-500">
|
|
{t("position")}: {Math.round(camera.x)}, {Math.round(camera.y)}
|
|
</div>
|
|
{camera.channelId != null ? (
|
|
<div className="mt-1 text-xs text-gray-600">
|
|
<span className="font-medium text-gray-900">
|
|
{t("channel_status")}:{" "}
|
|
</span>
|
|
{channelOnline === null
|
|
? t("channel_online_unknown")
|
|
: channelOnline
|
|
? t("channel_online")
|
|
: t("channel_offline")}
|
|
</div>
|
|
) : null}
|
|
{editLocked ? (
|
|
<div className="mt-2 text-xs leading-5 text-gray-500">
|
|
{t("browse_camera_panel_edit_hidden_hint")}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{editLocked ? null : (
|
|
<>
|
|
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
|
<div className="mb-2 text-sm font-medium text-gray-900">
|
|
{t("bind_channel")}
|
|
</div>
|
|
{channelsLoading ? (
|
|
<div className="flex min-h-14 items-center justify-center">
|
|
<Spin size="small" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Select
|
|
className="w-full"
|
|
showSearch
|
|
allowClear
|
|
placeholder={t("bind_channel_placeholder")}
|
|
optionFilterProp="label"
|
|
value={camera.channelId ?? undefined}
|
|
options={groupedChannelOptions}
|
|
onChange={(value) => onBindChannel(value ?? null)}
|
|
/>
|
|
<div className="mt-2 text-xs text-gray-500">
|
|
{t("channel_count_loaded", {
|
|
count: channelOptions.length,
|
|
})}
|
|
</div>
|
|
{channelsError ? (
|
|
<div className="mt-1 text-xs text-amber-600">
|
|
{t("channel_load_warning")}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)}
|
|
<div className="mt-2 text-xs text-gray-500">
|
|
{camera.channelName || t("camera_unbound")}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
|
<div className="mb-3 text-sm font-medium text-gray-900">
|
|
{t("direction")}
|
|
</div>
|
|
<Slider
|
|
min={0}
|
|
max={359}
|
|
value={camera.angle}
|
|
onChange={(value) => onAngleChange(Number(value))}
|
|
/>
|
|
|
|
<div className="mb-3 mt-4 text-sm font-medium text-gray-900">
|
|
{t("fov")}
|
|
</div>
|
|
<Slider
|
|
min={20}
|
|
max={160}
|
|
value={camera.fov}
|
|
onChange={(value) => onFovChange(Number(value))}
|
|
/>
|
|
|
|
<div className="mb-3 mt-4 text-sm font-medium text-gray-900">
|
|
{t("range")}
|
|
</div>
|
|
<Slider
|
|
min={80}
|
|
max={800}
|
|
step={10}
|
|
value={camera.range}
|
|
onChange={(value) => onRangeChange(Number(value))}
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onDelete}
|
|
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-600"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
{t("delete_camera")}
|
|
</button>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|