Rename customs-tablet-frontend to public-frontend and add new features
- 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>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user