1429442dbd
- Rename customs-tablet-frontend/ to public-frontend/ for broader scope - Add new pages: customs, inspection with camera integration - Add new services: apiClient.ts, backendApi.ts, normalizers.ts - Add CameraFrame component for real-time video streaming - Add scan_fixer module with clock_publisher and timestamp fix utilities - Update startup scripts to support new frontend structure - Update arm_server configuration and service files Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 lines
3.3 KiB
TypeScript
92 lines
3.3 KiB
TypeScript
'use client';
|
|
|
|
import React, { useMemo, useState } from 'react';
|
|
import { Button, Empty, Flex, Typography } from 'antd';
|
|
import { CaretRightOutlined, ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
|
import type { CameraInfo } from '@/types';
|
|
|
|
const { Text } = Typography;
|
|
|
|
interface CameraFrameProps {
|
|
camera?: CameraInfo;
|
|
active?: boolean;
|
|
height?: number | string;
|
|
aspectRatio?: string;
|
|
}
|
|
|
|
export const CameraFrame: React.FC<CameraFrameProps> = ({ camera, active = true, height, aspectRatio }) => {
|
|
const [reloadKey, setReloadKey] = useState(Date.now());
|
|
const streamUrl = useMemo(() => {
|
|
if (!camera?.streamUrl) {
|
|
return '';
|
|
}
|
|
return `${camera.streamUrl}${camera.streamUrl.includes('?') ? '&' : '?'}t=${reloadKey}`;
|
|
}, [camera?.streamUrl, reloadKey]);
|
|
|
|
const offline = !camera || camera.status !== 'online' || camera.placeholder;
|
|
const isPollingJpeg = camera?.streamUrl === '/api/camera/refresh';
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
position: 'relative',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
borderRadius: 8,
|
|
background: '#141414',
|
|
height: height || '100%',
|
|
width: '100%',
|
|
flex: 1,
|
|
aspectRatio,
|
|
boxShadow: 'inset 0 0 20px rgba(0,0,0,0.5)',
|
|
}}
|
|
>
|
|
{!offline && active && streamUrl ? (
|
|
<>
|
|
{/* 后端的 AGV 接口是单帧 JPEG,机械臂接口是 MJPEG;img 可同时承载两者。 */}
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
key={isPollingJpeg ? reloadKey : camera.id}
|
|
className="cameraFrameImage"
|
|
src={streamUrl}
|
|
alt={camera.name}
|
|
onLoad={() => {
|
|
if (isPollingJpeg && active) {
|
|
window.setTimeout(() => setReloadKey(Date.now()), 1500);
|
|
}
|
|
}}
|
|
/>
|
|
<div style={{ position: 'absolute', bottom: 16, left: 16, zIndex: 10, padding: '4px 10px', background: 'rgba(0,0,0,0.6)', borderRadius: 6 }}>
|
|
<Text style={{ color: '#ffffff', fontSize: 12 }}>{camera.name} / 实时画面</Text>
|
|
</div>
|
|
</>
|
|
) : offline ? (
|
|
<Empty
|
|
image={<VideoCameraOutlined style={{ fontSize: 64, color: '#ff4d4f', opacity: 0.9 }} />}
|
|
imageStyle={{ height: 64, marginBottom: 16 }}
|
|
description={
|
|
<Flex vertical gap={2}>
|
|
<Text type="danger" strong style={{ fontSize: 16 }}>{camera?.placeholder ? '摄像头未配置' : '设备离线'}</Text>
|
|
<Text type="secondary" style={{ color: 'rgba(255,255,255,0.45)' }}>{camera?.location ?? '暂无视频源'}</Text>
|
|
</Flex>
|
|
}
|
|
>
|
|
{camera?.streamUrl && (
|
|
<Button type="primary" danger icon={<ReloadOutlined />} style={{ marginTop: 8 }} onClick={() => setReloadKey(Date.now())}>
|
|
重试连接
|
|
</Button>
|
|
)}
|
|
</Empty>
|
|
) : (
|
|
<Flex vertical align="center" gap={16} style={{ color: '#ffffff' }}>
|
|
<CaretRightOutlined style={{ fontSize: 48, opacity: 0.65 }} />
|
|
<Text style={{ color: '#ffffff' }}>画面已暂停或未启动</Text>
|
|
</Flex>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|