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:
2026-06-22 10:18:20 +08:00
parent 083d12016a
commit 1429442dbd
49 changed files with 2758 additions and 2141 deletions
@@ -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>
);
};