207 lines
7.2 KiB
TypeScript
207 lines
7.2 KiB
TypeScript
import { Spin } from "antd";
|
||
import { Bell, ExternalLink } from "lucide-react";
|
||
import type { RefObject } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { useTranslation } from "react-i18next";
|
||
import { Link } from "react-router";
|
||
import type { CameraMarker, LatestCameraEvent } from "~/pages/desktop/floor_plan.types";
|
||
|
||
const CARD_WIDTH = 288;
|
||
const CARD_HEIGHT_ESTIMATE = 320;
|
||
|
||
/**
|
||
* 为什么单独做时间格式化并包 try:
|
||
* 后端或脏数据可能给出非法时间戳,直接在 UI 层抛错会让整张卡片白屏;降级为可读的字符串更利于现场对照日志排查。
|
||
*/
|
||
function formatEventTime(timestamp: number | null | undefined) {
|
||
if (!timestamp) {
|
||
return "-";
|
||
}
|
||
try {
|
||
return new Date(timestamp).toLocaleString();
|
||
} catch {
|
||
return String(timestamp);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 为什么按视口裁剪:
|
||
* 卡片用 fixed 贴在视口上,必须用 window 尺寸裁剪,避免贴边被裁切。
|
||
*/
|
||
function clampCardPositionViewport(left: number, top: number) {
|
||
const pad = 8;
|
||
const vw = typeof window !== "undefined" ? window.innerWidth : 1200;
|
||
const vh = typeof window !== "undefined" ? window.innerHeight : 800;
|
||
let x = left;
|
||
let y = top;
|
||
if (x + CARD_WIDTH > vw - pad) {
|
||
x = vw - CARD_WIDTH - pad;
|
||
}
|
||
if (y + CARD_HEIGHT_ESTIMATE > vh - pad) {
|
||
y = vh - CARD_HEIGHT_ESTIMATE - pad;
|
||
}
|
||
if (x < pad) {
|
||
x = pad;
|
||
}
|
||
if (y < pad) {
|
||
y = pad;
|
||
}
|
||
return { x, y };
|
||
}
|
||
|
||
/**
|
||
* 为什么用 Portal + fixed + 高于 FAB 的 z-index:
|
||
* 卡片若在画布容器内 absolute,层叠上下文低于右下角 fixed z-50 的 FAB,且关闭菜单的透明层仍会占位拦截;挂到 body 并 z-[100] 才能保证链接可点。
|
||
* 为什么用画布容器的 getBoundingClientRect + 锚点:
|
||
* Konva 内坐标需换算成视口像素,才能与 fixed 对齐。
|
||
*/
|
||
export function CameraHoverCard({
|
||
camera,
|
||
latestEvent,
|
||
loading,
|
||
anchorX,
|
||
anchorY,
|
||
canvasContainerRef,
|
||
channelOnline = null,
|
||
playbackTo = null,
|
||
alertsTo = null,
|
||
onCardPointerEnter,
|
||
onCardPointerLeave,
|
||
eventOccurredAgo = "",
|
||
dataFetchedAgo = "",
|
||
}: {
|
||
camera: CameraMarker;
|
||
latestEvent: LatestCameraEvent | null;
|
||
loading: boolean;
|
||
/** 相对画布容器左上角的屏幕对齐坐标(与 Stage 内 worldToScreen 一致) */
|
||
anchorX: number;
|
||
anchorY: number;
|
||
canvasContainerRef: RefObject<HTMLDivElement | null>;
|
||
channelOnline?: boolean | null | undefined;
|
||
playbackTo?: { pathname: string; search: string } | null;
|
||
alertsTo?: { pathname: string; search: string } | null;
|
||
/** 鼠标移入卡片时取消「离开摄像头」的延时清除,否则移向按钮途中卡片会消失 */
|
||
onCardPointerEnter?: () => void;
|
||
onCardPointerLeave?: () => void;
|
||
/** 由父组件用当前时间与事件 startedAt 算出,避免卡片内再挂定时器 */
|
||
eventOccurredAgo?: string;
|
||
/** 本次卡片数据完成请求的时间,用于提示「缓存可能滞后」 */
|
||
dataFetchedAgo?: string;
|
||
}) {
|
||
const { t } = useTranslation("desktop");
|
||
|
||
let left = 0;
|
||
let top = 0;
|
||
const el = canvasContainerRef.current;
|
||
if (el && typeof window !== "undefined") {
|
||
const rect = el.getBoundingClientRect();
|
||
const clamped = clampCardPositionViewport(rect.left + anchorX + 18, rect.top + anchorY + 18);
|
||
left = clamped.x;
|
||
top = clamped.y;
|
||
}
|
||
|
||
const canPlayback = Boolean(camera.channelId && playbackTo);
|
||
const canAlerts = Boolean(camera.channelId && alertsTo);
|
||
|
||
const node = (
|
||
<div
|
||
className="pointer-events-none fixed z-[100] w-72 rounded-xl border border-gray-200 bg-white/95 p-3 shadow-xl backdrop-blur"
|
||
style={{ left, top }}
|
||
onMouseEnter={onCardPointerEnter}
|
||
onMouseLeave={onCardPointerLeave}
|
||
>
|
||
<div className="mb-2 flex items-start justify-between gap-2">
|
||
<div className="min-w-0 flex-1 text-sm font-semibold text-gray-900">
|
||
{camera.channelName || t("camera_unbound")}
|
||
</div>
|
||
<div className="flex shrink-0 flex-wrap justify-end gap-1">
|
||
{canAlerts && alertsTo ? (
|
||
<Link
|
||
to={alertsTo}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="pointer-events-auto 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}
|
||
{canPlayback && playbackTo ? (
|
||
<Link
|
||
to={playbackTo}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="pointer-events-auto 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>
|
||
{camera.channelId != null ? (
|
||
<div className="mb-2 text-xs text-gray-600">
|
||
<span className="font-medium text-gray-900">{t("channel_status")}: </span>
|
||
{channelOnline === undefined
|
||
? t("channel_online_unknown")
|
||
: channelOnline
|
||
? t("channel_online")
|
||
: t("channel_offline")}
|
||
</div>
|
||
) : null}
|
||
{loading ? (
|
||
<div className="flex min-h-24 items-center justify-center">
|
||
<Spin size="small" />
|
||
</div>
|
||
) : latestEvent ? (
|
||
<>
|
||
{latestEvent.imageSrc ? (
|
||
<img
|
||
src={latestEvent.imageSrc}
|
||
alt={latestEvent.label}
|
||
className="mb-3 h-36 w-full rounded-lg border border-gray-200 object-cover"
|
||
onError={() => {
|
||
console.warn("[floor-plan] failed to load hover event image", {
|
||
channelId: latestEvent.channelId,
|
||
imageSrc: latestEvent.imageSrc,
|
||
});
|
||
}}
|
||
/>
|
||
) : null}
|
||
<div className="space-y-1 text-xs text-gray-600">
|
||
<div>
|
||
<span className="font-medium text-gray-900">{t("latest_ai_event")}: </span>
|
||
{latestEvent.label}
|
||
</div>
|
||
<div>
|
||
<span className="font-medium text-gray-900">{t("score")}: </span>
|
||
{(latestEvent.score * 100).toFixed(1)}%
|
||
</div>
|
||
<div>
|
||
<span className="font-medium text-gray-900">{t("event_time")}: </span>
|
||
{formatEventTime(latestEvent.startedAt)}
|
||
</div>
|
||
{eventOccurredAgo ? (
|
||
<div className="text-[11px] text-gray-500">{t("event_occurred_ago", { ago: eventOccurredAgo })}</div>
|
||
) : null}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="rounded-lg bg-gray-50 px-3 py-4 text-center text-sm text-gray-500">
|
||
{t("no_ai_event")}
|
||
</div>
|
||
)}
|
||
{!loading && dataFetchedAgo ? (
|
||
<div
|
||
className={`text-[11px] text-gray-400 ${latestEvent || !camera.channelId ? "mt-2 border-t border-gray-100 pt-2" : "mt-2 text-center"}`}
|
||
>
|
||
{t("data_fetched_ago", { ago: dataFetchedAgo })}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
|
||
if (typeof document === "undefined") {
|
||
return null;
|
||
}
|
||
return createPortal(node, document.body);
|
||
}
|