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
+243
View File
@@ -0,0 +1,243 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Col, DatePicker, Form, Input, Row, Select, Space, Table, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlayCircleOutlined, SearchOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import dayjs from 'dayjs';
import { Breadcrumb } from '@/components/Breadcrumb';
import { StatusBadge } from '@/components/StatusBadge';
import { BackendApi } from '@/services/backendApi';
import { useAppStore } from '@/store/useAppStore';
import type { CustomsDeclaration, InspectionItem } from '@/types';
const { RangePicker } = DatePicker;
export default function CustomsPage() {
const router = useRouter();
const [data, setData] = useState<CustomsDeclaration[]>([]);
const [filteredData, setFilteredData] = useState<CustomsDeclaration[]>([]);
const [loading, setLoading] = useState(true);
const [startingId, setStartingId] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [messageApi, contextHolder] = message.useMessage();
const [form] = Form.useForm();
const { setSelectedCustoms, setInspection } = useAppStore();
useEffect(() => {
let isMounted = true;
const loadCustomsList = async () => {
try {
setLoading(true);
setErrorMessage('');
const customsList = await BackendApi.getCustomsList(1, 100);
if (!isMounted) return;
setData(customsList);
setFilteredData(customsList);
} catch (error) {
if (!isMounted) return;
setErrorMessage(error instanceof Error ? error.message : '报关单列表加载失败,请稍后重试');
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadCustomsList();
return () => {
isMounted = false;
};
}, []);
const handleSearch = () => {
const values = form.getFieldsValue();
const keyword = values.searchText?.trim().toLowerCase() || '';
const status = values.statusFilter || 'all';
const dateRange = values.dateRange;
const nextData = data.filter((item) => {
const matchesStatus = status === 'all' || item.status === status;
const matchesKeyword = !keyword || item.customsName.toLowerCase().includes(keyword) || item.customsId.toLowerCase().includes(keyword);
const createdAt = dayjs(item.createdAt);
const matchesDateRange = !dateRange?.[0] || !dateRange?.[1] || !createdAt.isValid()
|| (createdAt.isAfter(dateRange[0].startOf('day')) && createdAt.isBefore(dateRange[1].endOf('day')));
return matchesStatus && matchesKeyword && matchesDateRange;
});
setFilteredData(nextData);
};
const handleReset = () => {
form.resetFields();
setFilteredData(data);
};
const loadExpandedItems = async (record: CustomsDeclaration): Promise<CustomsDeclaration> => {
if (record.items.length) {
return record;
}
const items = await BackendApi.getCustomsMachines(record.id);
const nextRecord = {
...record,
items,
machineCount: record.machineCount || items.reduce((sum, item) => sum + item.quantify, 0),
};
setData((current) => current.map((item) => (item.id === record.id ? nextRecord : item)));
setFilteredData((current) => current.map((item) => (item.id === record.id ? nextRecord : item)));
return nextRecord;
};
const handleStartInspection = async (record: CustomsDeclaration) => {
setStartingId(record.id);
try {
const nextRecord = await loadExpandedItems(record);
const inspection = await BackendApi.startCustomsInspection(nextRecord);
setSelectedCustoms(nextRecord);
setInspection(inspection);
messageApi.success('查验已开始');
router.push(`/inspection?customsId=${encodeURIComponent(nextRecord.id)}`);
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '开始查验失败');
} finally {
setStartingId(null);
}
};
const itemColumns: ColumnsType<InspectionItem> = useMemo(() => [
{ title: '料号', dataIndex: 'inventoryCode', key: 'inventoryCode' },
{ title: '品名', dataIndex: 'inventoryName', key: 'inventoryName' },
{ title: '规格', dataIndex: 'spec', key: 'spec' },
{ title: '总数', dataIndex: 'quantify', key: 'quantify' },
{ title: '已查验', dataIndex: 'inspected', key: 'inspected' },
], []);
const expandedRowRender = (record: CustomsDeclaration) => (
<Table
columns={itemColumns}
dataSource={record.items}
pagination={false}
size="small"
rowKey={(item) => item.inventoryCode}
locale={{ emptyText: '展开后端机器列表为空,或暂未加载' }}
/>
);
const columns: ColumnsType<CustomsDeclaration> = [
{
title: '报关单号',
dataIndex: 'customsName',
key: 'customsName',
render: (text: string) => <b>{text}</b>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: CustomsDeclaration['status']) => <StatusBadge status={status} />,
},
{
title: '机器总数',
dataIndex: 'machineCount',
key: 'machineCount',
render: (count: number) => (count ? `${count}` : '-'),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space size="middle">
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={startingId === record.id}
onClick={() => handleStartInspection(record)}
>
</Button>
</Space>
),
},
];
return (
<div>
{contextHolder}
<Breadcrumb />
{errorMessage && (
<Alert type="error" message={errorMessage} showIcon style={{ marginBottom: 16 }} />
)}
<Card title="筛选条件" style={{ marginBottom: 24 }}>
<Form form={form} layout="inline">
<Row gutter={24} align="middle">
<Col>
<Form.Item label="状态" name="statusFilter" initialValue="all">
<Select
style={{ width: 120 }}
options={[
{ value: 'all', label: '全部' },
{ value: 'pending', label: '待查验' },
{ value: 'inspecting', label: '查验中' },
{ value: 'released', label: '已放行' },
{ value: 'abnormal', label: '异常' },
]}
/>
</Form.Item>
</Col>
<Col>
<Form.Item label="日期范围" name="dateRange">
<RangePicker />
</Form.Item>
</Col>
<Col>
<Form.Item name="searchText">
<Input
placeholder="搜索报关单号..."
prefix={<SearchOutlined />}
onPressEnter={handleSearch}
style={{ width: 250 }}
/>
</Form.Item>
</Col>
<Col>
<Space>
<Button type="primary" onClick={handleSearch}></Button>
<Button onClick={handleReset}></Button>
</Space>
</Col>
</Row>
</Form>
</Card>
<Card title="报关单列表" styles={{ body: { padding: 0 } }}>
<Table
dataSource={filteredData}
loading={loading}
rowKey="id"
columns={columns}
expandable={{
expandedRowRender,
onExpand: (expanded, record) => {
if (expanded && !record.items.length) {
loadExpandedItems(record).catch((error) => {
messageApi.error(error instanceof Error ? error.message : '机器列表加载失败');
});
}
},
}}
/>
</Card>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
Binary file not shown.
+64
View File
@@ -0,0 +1,64 @@
:root {
--color-border-light: #f0f0f0;
}
html,
body {
max-width: 100vw;
height: 100vh;
overflow-x: hidden;
}
body {
color: rgba(0, 0, 0, 0.88);
background: #f0f2f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
.appBody {
height: 100vh;
overflow: hidden;
}
.antAppRoot {
height: 100%;
}
.appShell {
display: flex;
flex-direction: column;
height: 100vh;
}
.appMain {
flex: 1;
padding: 24px;
overflow-y: auto;
background: #f0f2f5;
}
.cameraFrameImage {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.scanCorner {
position: absolute;
width: 20px;
height: 20px;
}
+570
View File
@@ -0,0 +1,570 @@
'use client';
import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react';
import {
Badge,
Button,
Card,
Col,
Empty,
Flex,
Input,
Modal,
Progress,
Row,
Select,
Space,
Spin,
Table,
Tag,
Timeline,
Typography,
message,
theme,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PauseCircleFilled,
PauseCircleOutlined,
PlayCircleOutlined,
ReloadOutlined,
StopOutlined,
} from '@ant-design/icons';
import { useRouter, useSearchParams } from 'next/navigation';
import { Breadcrumb } from '@/components/Breadcrumb';
import { CameraFrame } from '@/components/CameraFrame';
import { BackendApi } from '@/services/backendApi';
import { useAppStore } from '@/store/useAppStore';
import type { ActivityItem, CameraInfo, CustomsDeclaration, InspectionIssue, InspectionItem, MissionRuntimeState } from '@/types';
const { Text } = Typography;
const { TextArea } = Input;
interface ProgressItem extends InspectionItem {
currentInspected: number;
}
export default function InspectionPage() {
return (
<Suspense
fallback={
<Flex vertical align="center" justify="center" style={{ padding: 48, height: '100vh' }}>
<Spin tip="正在加载查验任务..." />
</Flex>
}
>
<InspectionContent />
</Suspense>
);
}
function InspectionContent() {
const router = useRouter();
const searchParams = useSearchParams();
const customsId = searchParams.get('customsId');
const { selectedCustoms, setSelectedCustoms, setInspection } = useAppStore();
const [customsList, setCustomsList] = useState<CustomsDeclaration[]>([]);
const [currentCustoms, setCurrentCustoms] = useState<CustomsDeclaration | null>(null);
const [status, setStatus] = useState<MissionRuntimeState>('idle');
const [logs, setLogs] = useState<ActivityItem[]>([]);
const [progressData, setProgressData] = useState<ProgressItem[]>([]);
const [issues, setIssues] = useState<InspectionIssue[]>([]);
const [cameras, setCameras] = useState<CameraInfo[]>([]);
const [currentOverviewCamera, setCurrentOverviewCamera] = useState<string>('');
const [isPauseModalVisible, setIsPauseModalVisible] = useState(false);
const [pauseReason, setPauseReason] = useState('');
const [loadingCustoms, setLoadingCustoms] = useState(true);
const [loadingList, setLoadingList] = useState(false);
const [operationLoading, setOperationLoading] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const { token } = theme.useToken();
const logsEndRef = useRef<HTMLDivElement>(null);
const refreshRuntime = async () => {
const [missionState, currentInspection, missionLogs, nextIssues] = await Promise.all([
BackendApi.getMissionState(),
BackendApi.getCurrentInspection().catch(() => null),
BackendApi.getMissionLogs().catch(() => []),
BackendApi.getInspectionIssues().catch(() => []),
]);
const inspection = missionState.inspection ?? currentInspection;
const runtimeStatus = missionState.state === 'running' || missionState.state === 'setting'
? 'running'
: missionState.state === 'paused'
? 'paused'
: inspection
? 'idle'
: 'idle';
setStatus(runtimeStatus);
setLogs(missionLogs);
setIssues(nextIssues);
if (inspection) {
setInspection(inspection);
setProgressData(inspection.items.map((item) => ({ ...item, currentInspected: item.inspected })));
}
};
useEffect(() => {
let isMounted = true;
const loadBaseData = async () => {
setLoadingList(true);
try {
const [list, cameraList] = await Promise.all([
BackendApi.getCustomsList(1, 100),
BackendApi.getCameras(),
]);
if (!isMounted) return;
setCustomsList(list);
setCameras(cameraList);
const overviews = cameraList.filter((camera) => camera.category === 'overview');
if (overviews.length > 0) {
setCurrentOverviewCamera(overviews[0].id);
}
} catch (error) {
if (isMounted) {
messageApi.error(error instanceof Error ? error.message : '基础数据加载失败');
}
} finally {
if (isMounted) {
setLoadingList(false);
}
}
};
loadBaseData();
return () => {
isMounted = false;
};
}, [messageApi]);
useEffect(() => {
let isMounted = true;
const loadInspectionCustoms = async () => {
setLoadingCustoms(true);
try {
if (customsId) {
const cachedCustoms = selectedCustoms?.id === customsId || selectedCustoms?.customsId === customsId ? selectedCustoms : null;
const customs = cachedCustoms ?? await BackendApi.getCustomsById(customsId);
if (!isMounted) return;
setCurrentCustoms(customs);
setSelectedCustoms(customs);
if (customs) {
setProgressData(customs.items.map((item) => ({ ...item, currentInspected: item.inspected })));
}
return;
}
const currentInspection = await BackendApi.getCurrentInspection();
if (!isMounted) return;
if (currentInspection) {
setInspection(currentInspection);
setCurrentCustoms({
id: currentInspection.customsId,
customsId: currentInspection.customsId,
customsName: currentInspection.customsName,
status: 'inspecting',
machineCount: currentInspection.items.reduce((sum, item) => sum + item.quantify, 0),
createdAt: '-',
items: currentInspection.items,
});
setProgressData(currentInspection.items.map((item) => ({ ...item, currentInspected: item.inspected })));
} else {
setCurrentCustoms(selectedCustoms);
}
} catch {
if (isMounted) {
setCurrentCustoms(null);
}
} finally {
if (isMounted) {
setLoadingCustoms(false);
}
}
};
loadInspectionCustoms();
return () => {
isMounted = false;
};
}, [customsId, selectedCustoms, setInspection, setSelectedCustoms]);
useEffect(() => {
const loadRuntime = async () => {
const [missionState, currentInspection, missionLogs, nextIssues] = await Promise.all([
BackendApi.getMissionState(),
BackendApi.getCurrentInspection().catch(() => null),
BackendApi.getMissionLogs().catch(() => []),
BackendApi.getInspectionIssues().catch(() => []),
]);
const inspection = missionState.inspection ?? currentInspection;
const runtimeStatus = missionState.state === 'running' || missionState.state === 'setting'
? 'running'
: missionState.state === 'paused'
? 'paused'
: inspection
? 'idle'
: 'idle';
setStatus(runtimeStatus);
setLogs(missionLogs);
setIssues(nextIssues);
if (inspection) {
setInspection(inspection);
setProgressData(inspection.items.map((item) => ({ ...item, currentInspected: item.inspected })));
}
};
loadRuntime().catch(() => undefined);
const timer = window.setInterval(() => {
loadRuntime().catch(() => undefined);
}, 3000);
return () => window.clearInterval(timer);
}, [setInspection]);
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
const calculateTotalProgress = () => {
if (!progressData.length) return 0;
const total = progressData.reduce((sum, item) => sum + item.quantify, 0);
const inspected = progressData.reduce((sum, item) => sum + item.currentInspected, 0);
return total > 0 ? Math.round((inspected / total) * 100) : 0;
};
const overviewCameras = cameras.filter((camera) => camera.category === 'overview');
const agvCamera = cameras.find((camera) => camera.category === 'agv');
const operationCamera = cameras.find((camera) => camera.category === 'operation');
const selectedOverviewCamera = overviewCameras.find((camera) => camera.id === currentOverviewCamera) || overviewCameras[0];
const selectOptions = useMemo(() => customsList.map((item) => ({
value: item.id,
label: `${item.customsName} - ${item.status === 'pending' ? '待查验' : item.status === 'inspecting' ? '查验中' : item.status === 'released' ? '已放行' : '异常'}`,
})), [customsList]);
const handleStart = async () => {
if (!currentCustoms) {
messageApi.warning('请先选择报关单');
return;
}
setOperationLoading(true);
try {
const inspection = await BackendApi.startCustomsInspection(currentCustoms);
setInspection(inspection);
await BackendApi.startMission();
setStatus('running');
await refreshRuntime();
messageApi.success('自动化查验作业已启动');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '启动查验失败');
} finally {
setOperationLoading(false);
}
};
const confirmPause = async () => {
setOperationLoading(true);
try {
await BackendApi.pauseMission();
setStatus('paused');
setLogs((current) => [
...current,
{ id: `pause-${Date.now()}`, time: new Date().toLocaleTimeString(), type: 'warning', message: `查验已暂停。原因:${pauseReason || '未填写'}` },
]);
setIsPauseModalVisible(false);
setPauseReason('');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '暂停查验失败');
} finally {
setOperationLoading(false);
}
};
const handleResume = async () => {
setOperationLoading(true);
try {
await BackendApi.resumeMission();
setStatus('running');
await refreshRuntime();
messageApi.success('查验已继续');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '继续查验失败');
} finally {
setOperationLoading(false);
}
};
const handleEnd = () => {
Modal.confirm({
title: '确认结束查验?',
content: '结束查验会停止当前自动任务并清空当前报关单查验状态。',
okText: '确认结束',
cancelText: '取消',
onOk: async () => {
setOperationLoading(true);
try {
await BackendApi.stopMission();
await BackendApi.endCustomsInspection();
setStatus('completed');
setInspection(null);
await refreshRuntime();
messageApi.success('查验已结束');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '结束查验失败');
} finally {
setOperationLoading(false);
}
},
});
};
const issueColumns: ColumnsType<InspectionIssue> = [
{
title: '时间',
dataIndex: 'time',
key: 'time',
width: 90,
render: (text: string) => <Text style={{ fontSize: 13 }}>{text}</Text>,
},
{
title: '问题描述',
dataIndex: 'description',
key: 'description',
render: (text: string, record) => (
<Space>
<Badge status={record.severity === 'error' ? 'error' : 'warning'} />
<Text style={{ fontSize: 13 }}>{text}</Text>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (issueStatus: InspectionIssue['status']) => {
const info = {
pending: { color: 'red', text: '待处理' },
disposed: { color: 'green', text: '已处置' },
cancelled: { color: 'default', text: '已取消' },
}[issueStatus];
return <Tag color={info.color} style={{ margin: 0 }}>{info.text}</Tag>;
},
},
{
title: '操作',
key: 'action',
width: 120,
render: (_, record) => (
record.status === 'pending' ? (
<Space size="small">
<Button size="small" type="primary" onClick={() => setIssues((current) => current.map((issue) => issue.id === record.id ? { ...issue, status: 'disposed' } : issue))}></Button>
<Button size="small" onClick={() => setIssues((current) => current.map((issue) => issue.id === record.id ? { ...issue, status: 'cancelled' } : issue))}></Button>
</Space>
) : null
),
},
];
if (loadingCustoms) {
return (
<Flex vertical align="center" justify="center" style={{ padding: 48, height: '100vh' }}>
<Spin tip="正在加载查验任务..." />
</Flex>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)' }}>
{contextHolder}
<Flex align="center" justify="space-between" style={{ padding: '0 24px', margin: '16px 0' }}>
<Breadcrumb />
<Flex align="center" gap="large">
{currentCustoms && (
<Badge
status={status === 'running' ? 'processing' : status === 'idle' ? 'default' : status === 'paused' ? 'warning' : 'success'}
text={<Text strong>{status === 'running' ? '作业中' : status === 'idle' ? '待作业' : status === 'paused' ? '已暂停' : '已完成'}</Text>}
/>
)}
<Select
showSearch
placeholder="搜索并选择报关单..."
style={{ width: 240 }}
loading={loadingList}
optionFilterProp="label"
options={selectOptions}
value={currentCustoms?.id}
onChange={(value) => router.push(`/inspection?customsId=${encodeURIComponent(value)}`)}
/>
</Flex>
</Flex>
<Row gutter={24} style={{ flex: 1, minHeight: 0, margin: '0 24px 24px 24px' }}>
<Col span={16} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 16, marginBottom: 16 }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card
size="small"
title={<Text strong>AGV ({agvCamera?.name || '未知'})</Text>}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
<CameraFrame camera={agvCamera} active={status === 'running'} />
</Card>
<Card
size="small"
title={<Text strong> ({operationCamera?.name || '未知'})</Text>}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
<CameraFrame camera={operationCamera} active={status === 'running'} />
</Card>
</div>
<Card
size="small"
title={
<Flex justify="space-between" align="center">
<Text strong> ({selectedOverviewCamera?.name || '未知'})</Text>
<Button
size="small"
onClick={() => {
if (overviewCameras.length > 0) {
const currentIndex = overviewCameras.findIndex((camera) => camera.id === currentOverviewCamera);
const nextIndex = (currentIndex + 1) % overviewCameras.length;
setCurrentOverviewCamera(overviewCameras[nextIndex]?.id || currentOverviewCamera);
}
}}
>
</Button>
</Flex>
}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
style={{ flex: 2, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
<CameraFrame camera={selectedOverviewCamera} active={status === 'running'} />
</Card>
</div>
<Card size="small" style={{ flexShrink: 0 }}>
<Flex justify="center" gap="middle">
{status === 'idle' ? (
<Button type="primary" size="large" icon={<PlayCircleOutlined />} onClick={handleStart} loading={operationLoading} style={{ width: 140 }}>
</Button>
) : status === 'paused' ? (
<Button type="primary" size="large" icon={<PlayCircleOutlined />} onClick={handleResume} loading={operationLoading} style={{ width: 140 }}>
</Button>
) : status === 'running' ? (
<Button type="primary" danger size="large" icon={<PauseCircleOutlined />} onClick={() => setIsPauseModalVisible(true)} loading={operationLoading} style={{ width: 140 }}>
</Button>
) : null}
<Button size="large" icon={<ReloadOutlined />} disabled={status === 'completed'} onClick={() => refreshRuntime()}></Button>
<Button danger size="large" icon={<StopOutlined />} onClick={handleEnd} disabled={status === 'completed' || status === 'idle'}></Button>
</Flex>
</Card>
</Col>
<Col span={8} style={{ display: 'flex', flexDirection: 'column', gap: 16, height: '100%' }}>
<Card
title={<Text strong></Text>}
size="small"
style={{ flex: 4, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { padding: 12, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } }}
>
<div style={{ marginBottom: 16 }}>
<Progress percent={calculateTotalProgress()} status={status === 'completed' ? 'success' : 'active'} strokeWidth={10} />
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{progressData.length ? progressData.map((item) => (
<div key={item.inventoryCode} style={{ padding: '8px 0', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<Flex justify="space-between" align="center" style={{ marginBottom: 4 }}>
<Text strong style={{ fontSize: 13 }}>{item.inventoryName}</Text>
<Space>
<Text type="secondary" style={{ fontSize: 12 }}>{item.inventoryCode}</Text>
<Badge count={`${item.currentInspected} / ${item.quantify}`} style={{ backgroundColor: item.currentInspected === item.quantify ? token.colorSuccess : token.colorPrimary }} />
</Space>
</Flex>
<Progress percent={item.quantify > 0 ? Math.round((item.currentInspected / item.quantify) * 100) : 0} showInfo={false} size="small" />
</div>
)) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无核销数据" />
)}
</div>
</Card>
<Card
title={<Text strong></Text>}
size="small"
style={{ flex: 3, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } }}
>
<div style={{ flex: 1, overflowY: 'auto' }}>
<Table columns={issueColumns} dataSource={issues} rowKey="id" size="small" pagination={false} sticky locale={{ emptyText: <Empty description="暂无异常" /> }} />
</div>
</Card>
<Card
title={<Text strong></Text>}
size="small"
style={{ flex: 3, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { padding: 12, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, background: token.colorFillQuaternary } }}
>
<div style={{ flex: 1, overflowY: 'auto', paddingRight: 8 }}>
{logs.length > 0 ? (
<Timeline
items={logs.map((item) => ({
color: item.type === 'success' ? 'green' : item.type === 'warning' ? 'orange' : 'blue',
children: (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>{item.time}</Text>
<Text style={{ fontSize: 13 }}>{item.message}</Text>
</Space>
),
}))}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无日志" style={{ margin: '20px 0' }} />
)}
<div ref={logsEndRef} />
</div>
</Card>
</Col>
</Row>
<Modal
title="暂停查验"
open={isPauseModalVisible}
onOk={confirmPause}
onCancel={() => setIsPauseModalVisible(false)}
okText="确认暂停"
cancelText="取消"
okButtonProps={{ danger: true, loading: operationLoading }}
>
<Flex vertical gap={16} style={{ paddingTop: 16 }}>
<Text></Text>
<TextArea
rows={4}
placeholder="请输入暂停原因"
value={pauseReason}
onChange={(event) => setPauseReason(event.target.value)}
/>
</Flex>
</Modal>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { Metadata } from 'next';
import { AntdRegistry } from '@ant-design/nextjs-registry';
import { App, ConfigProvider } from 'antd';
import './globals.css';
import { TopHeader } from '@/components/TopHeader';
export const metadata: Metadata = {
title: '海关智慧查验平台',
description: '海关查验系统平板端',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body className="appBody">
<AntdRegistry>
<ConfigProvider
theme={{
token: {
colorPrimary: '#1677ff',
borderRadius: 8,
},
components: {
Card: {
borderRadiusLG: 12,
},
Statistic: {
contentFontSize: 32,
titleFontSize: 16,
},
},
}}
>
<App className="antAppRoot">
<div className="appShell">
<TopHeader />
<main className="appMain">{children}</main>
</div>
</App>
</ConfigProvider>
</AntdRegistry>
</body>
</html>
);
}
@@ -0,0 +1,161 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Alert, Button, Card, Col, Empty, Flex, Image as AntImage, Row, Space, Spin, Table, Tabs, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { Breadcrumb } from '@/components/Breadcrumb';
import { StatusBadge } from '@/components/StatusBadge';
import { BackendApi } from '@/services/backendApi';
import type { InspectionRecord, MachineDetail, MachineImageItem } from '@/types';
const { Text } = Typography;
export default function MachineDetailPage({ params }: { params: { serialNumber: string } }) {
const router = useRouter();
const serialNumber = decodeURIComponent(params.serialNumber);
const [machine, setMachine] = useState<MachineDetail | null>(null);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
let isMounted = true;
const loadMachineDetail = async () => {
try {
setLoading(true);
setErrorMessage('');
const data = await BackendApi.getMachineDetail(serialNumber);
if (!isMounted) return;
setMachine(data);
} catch (error) {
if (!isMounted) return;
setMachine(null);
setErrorMessage(error instanceof Error ? error.message : '机器详情加载失败,请稍后重试');
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadMachineDetail();
return () => {
isMounted = false;
};
}, [serialNumber]);
const renderImageGroup = (images: MachineImageItem[]) => {
if (!images.length) return <Empty description="暂无图片" />;
return (
<AntImage.PreviewGroup>
<Space size={[16, 16]} wrap>
{images.map((image) => (
<Flex key={image.id} vertical style={{ position: 'relative', width: 120, gap: 4 }}>
<div style={{ width: '100%', aspectRatio: '4/3', overflow: 'hidden', borderRadius: 8, background: '#f0f0f0' }}>
<AntImage
src={image.url}
alt={image.name}
width="100%"
height="100%"
style={{ objectFit: 'cover' }}
preview={{ src: image.url }}
/>
</div>
<Text style={{ fontSize: 12, textAlign: 'center' }}>{image.name}</Text>
<Text type="secondary" style={{ fontSize: 11, textAlign: 'center' }}>{image.createdAt}</Text>
</Flex>
))}
</Space>
</AntImage.PreviewGroup>
);
};
const recordColumns: ColumnsType<InspectionRecord> = [
{ title: '查验时间', dataIndex: 'time', key: 'time' },
{ title: '操作人', dataIndex: 'operator', key: 'operator' },
{ title: '结果', dataIndex: 'result', key: 'result' },
{ title: '备注', dataIndex: 'remark', key: 'remark' },
];
if (loading) {
return (
<Flex vertical align="center" justify="center" style={{ padding: 48 }}>
<Spin tip="正在加载机器详情..." />
</Flex>
);
}
if (!machine) {
return (
<Flex vertical align="center" style={{ padding: 48 }}>
{errorMessage && (
<Alert type="error" message={errorMessage} showIcon style={{ maxWidth: 480, marginBottom: 16 }} />
)}
<Empty description="暂无机器详情" />
<Button type="primary" onClick={() => router.push('/machines')} style={{ marginTop: 16 }}>
</Button>
</Flex>
);
}
const imageTabs = [
{ key: 'incoming', label: '来料检验单', children: renderImageGroup(machine.images.incomingInspection) },
{ key: 'startup', label: '开机测试样张', children: renderImageGroup(machine.images.startupTestSample) },
{ key: 'production', label: '生产加工单', children: renderImageGroup(machine.images.productionOrder) },
{ key: 'robot', label: '机器人查验拍照', children: renderImageGroup(machine.images.robotInspection) },
];
return (
<div>
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Breadcrumb />
<Button icon={<ArrowLeftOutlined />} onClick={() => router.back()}></Button>
</Flex>
{errorMessage && (
<Alert type="warning" message={errorMessage} showIcon style={{ marginBottom: 16 }} />
)}
<Card title="机器基本信息" style={{ marginBottom: 24 }}>
<Row gutter={[24, 16]}>
<Col span={8}>
<Text type="secondary"></Text> <Text strong>{machine.serialNumber}</Text>
</Col>
<Col span={8}>
<Text type="secondary"></Text> <Text strong>{machine.modelName}</Text>
</Col>
<Col span={8}>
<Text type="secondary"></Text> <Button type="link" disabled={machine.customsId === '-'}>{machine.customsId}</Button>
</Col>
<Col span={8}>
<Text type="secondary"></Text> <StatusBadge status={machine.status} />
</Col>
{Object.entries(machine.specs).map(([key, value]) => (
<Col span={8} key={key}>
<Text type="secondary">{key}</Text> <Text>{value}</Text>
</Col>
))}
</Row>
</Card>
<Card title="图片资料" style={{ marginBottom: 24 }}>
<Tabs items={imageTabs} />
</Card>
<Card title="查验记录">
<Table
dataSource={machine.inspectionRecords}
rowKey="id"
pagination={false}
columns={recordColumns}
locale={{ emptyText: <Empty description="暂无查验记录" /> }}
/>
</Card>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Button, Card, Col, Flex, Input, Modal, Row, Space, Table, Typography, Upload, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { BarcodeOutlined, BulbOutlined, CameraOutlined, FileImageOutlined, SearchOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { Breadcrumb } from '@/components/Breadcrumb';
import type { RecentMachineQuery } from '@/types';
const { Title, Text } = Typography;
const { Dragger } = Upload;
const RECENT_QUERY_STORAGE_KEY = 'recent_queries';
export default function MachineQueryPage() {
const router = useRouter();
const [serialNumber, setSerialNumber] = useState('');
const [isScanModalVisible, setIsScanModalVisible] = useState(false);
const [recentQueries, setRecentQueries] = useState<RecentMachineQuery[]>([]);
const [messageApi, contextHolder] = message.useMessage();
useEffect(() => {
const saved = window.localStorage.getItem(RECENT_QUERY_STORAGE_KEY);
if (!saved) {
setRecentQueries([]);
return;
}
try {
setRecentQueries(JSON.parse(saved) as RecentMachineQuery[]);
} catch {
setRecentQueries([]);
}
}, []);
const saveRecentQuery = (nextSerialNumber: string) => {
const query: RecentMachineQuery = {
serialNumber: nextSerialNumber,
name: '待后端返回',
time: new Date().toLocaleString(),
};
const updated = [query, ...recentQueries.filter((item) => item.serialNumber !== nextSerialNumber)].slice(0, 10);
setRecentQueries(updated);
window.localStorage.setItem(RECENT_QUERY_STORAGE_KEY, JSON.stringify(updated));
};
const handleSearch = (value: string) => {
const nextSerialNumber = value.trim();
if (!nextSerialNumber) {
messageApi.warning('请输入序列号');
return;
}
saveRecentQuery(nextSerialNumber);
router.push(`/machines/${encodeURIComponent(nextSerialNumber)}`);
};
const columns: ColumnsType<RecentMachineQuery> = [
{ title: '序列号', dataIndex: 'serialNumber', key: 'serialNumber' },
{ title: '机器名称', dataIndex: 'name', key: 'name' },
{ title: '查询时间', dataIndex: 'time', key: 'time' },
{
title: '操作',
key: 'action',
render: (_, record) => (
<Button type="link" onClick={() => handleSearch(record.serialNumber)}></Button>
),
},
];
return (
<div>
{contextHolder}
<Breadcrumb />
<Card title="查询方式选择" style={{ marginBottom: 24 }}>
<Row gutter={48}>
<Col span={12} style={{ borderRight: '1px solid var(--color-border-light)' }}>
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
<CameraOutlined style={{ fontSize: 48, color: 'var(--ant-color-primary, #1677ff)' }} />
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary">使</Text>
<Button type="primary" size="large" onClick={() => setIsScanModalVisible(true)}></Button>
</Flex>
</Col>
<Col span={12}>
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
<BarcodeOutlined style={{ fontSize: 48, color: 'var(--ant-color-primary, #1677ff)' }} />
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary"></Text>
<Space.Compact style={{ width: '80%' }}>
<Input
placeholder="请输入序列号..."
size="large"
value={serialNumber}
onChange={(event) => setSerialNumber(event.target.value)}
onPressEnter={() => handleSearch(serialNumber)}
/>
<Button type="primary" size="large" icon={<SearchOutlined />} onClick={() => handleSearch(serialNumber)}></Button>
</Space.Compact>
</Flex>
</Col>
</Row>
</Card>
<Card title="或上传二维码照片识别" style={{ marginBottom: 24 }}>
<Dragger
accept="image/*"
showUploadList={false}
beforeUpload={() => {
messageApi.info('图片二维码识别后端暂未提供,请手动输入序列号');
return Upload.LIST_IGNORE;
}}
>
<p className="ant-upload-drag-icon">
<FileImageOutlined style={{ fontSize: 48 }} />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"></p>
</Dragger>
</Card>
<Card title="最近查询记录">
<Table
dataSource={recentQueries}
rowKey="serialNumber"
pagination={false}
size="middle"
columns={columns}
/>
</Card>
<Modal
title="扫描二维码"
open={isScanModalVisible}
onCancel={() => setIsScanModalVisible(false)}
footer={[
<Button key="manual" onClick={() => setIsScanModalVisible(false)}>
</Button>,
<Button key="close" type="primary" onClick={() => setIsScanModalVisible(false)}>
</Button>,
]}
width={600}
centered
>
<div
style={{
position: 'relative',
display: 'flex',
height: 300,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
background: '#000000',
}}
>
<Flex vertical align="center" gap={16} style={{ color: '#ffffff', zIndex: 1 }}>
<CameraOutlined style={{ fontSize: 48, opacity: 0.5 }} />
<div></div>
<Text style={{ color: 'rgba(255,255,255,0.65)' }}></Text>
</Flex>
<div
style={{
position: 'absolute',
width: 200,
height: 200,
border: '2px solid rgba(24, 144, 255, 0.5)',
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)',
}}
>
<div className="scanCorner" style={{ top: -2, left: -2, borderTop: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }} />
<div className="scanCorner" style={{ top: -2, right: -2, borderTop: '4px solid #1890ff', borderRight: '4px solid #1890ff' }} />
<div className="scanCorner" style={{ bottom: -2, left: -2, borderBottom: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }} />
<div className="scanCorner" style={{ bottom: -2, right: -2, borderBottom: '4px solid #1890ff', borderRight: '4px solid #1890ff' }} />
</div>
</div>
<div style={{ marginTop: 16, textAlign: 'center', color: '#666666' }}>
<BulbOutlined /> 使
</div>
</Modal>
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Alert, Button, Card, Col, Empty, Flex, Row, Statistic, Table, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CheckCircleOutlined,
ClockCircleOutlined,
FileTextOutlined,
ProfileOutlined,
RightOutlined,
ScanOutlined,
SearchOutlined,
SyncOutlined,
VideoCameraOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { BackendApi } from '@/services/backendApi';
import type { ActivityItem, CustomsDeclaration, CustomsStats } from '@/types';
import { StatusBadge } from '@/components/StatusBadge';
export default function DashboardPage() {
const router = useRouter();
const [stats, setStats] = useState<CustomsStats | null>(null);
const [activities, setActivities] = useState<ActivityItem[]>([]);
const [pendingCustoms, setPendingCustoms] = useState<CustomsDeclaration[]>([]);
const [errorMessage, setErrorMessage] = useState('');
const [messageApi, contextHolder] = message.useMessage();
useEffect(() => {
let isMounted = true;
const loadData = async () => {
try {
setErrorMessage('');
const [statsData, activityData, customsData] = await Promise.all([
BackendApi.getCustomsStats(),
BackendApi.getRecentActivities(),
BackendApi.getCustomsList(1, 10),
]);
if (!isMounted) return;
setStats(statsData);
setActivities(activityData);
setPendingCustoms(customsData.filter((item) => item.status === 'pending' || item.status === 'inspecting'));
} catch (error) {
if (!isMounted) return;
setErrorMessage(error instanceof Error ? error.message : '首页数据加载失败,请稍后重试');
}
};
loadData();
return () => {
isMounted = false;
};
}, []);
const goToInspection = async () => {
try {
const inspection = await BackendApi.getCurrentInspection();
if (inspection) {
router.push(`/inspection?customsId=${encodeURIComponent(inspection.customsId)}`);
return;
}
router.push('/customs');
} catch {
router.push('/customs');
}
};
const statCards = [
{
title: '待查验',
value: stats?.pendingCount || 0,
icon: <FileTextOutlined />,
suffix: '份报关单',
contentStyle: { color: '#1890ff' },
onClick: () => router.push('/customs'),
},
{
title: '今日已放行',
value: stats?.releasedToday || 0,
icon: <CheckCircleOutlined />,
suffix: '份报关单',
contentStyle: { color: '#52c41a' },
},
{
title: '查验进行中',
value: stats?.inspectingCount || 0,
icon: <SyncOutlined spin />,
suffix: '个任务',
contentStyle: { color: '#faad14' },
onClick: goToInspection,
},
{
title: '异常',
value: stats?.abnormalCount || 0,
icon: <WarningOutlined />,
suffix: '个异常',
contentStyle: { color: '#ff4d4f' },
},
];
const quickActions = [
{ title: '扫码查询机器', desc: '使用平板摄像头扫描设备二维码', icon: <ScanOutlined />, onClick: () => router.push('/machines'), color: '#1890ff' },
{ title: '序列号查询机器', desc: '手动输入序列号查询机器全部资料', icon: <SearchOutlined />, onClick: () => router.push('/machines'), color: '#1890ff' },
{ title: '视频监控', desc: '查看已接入与待接入的视频画面', icon: <VideoCameraOutlined />, onClick: () => router.push('/video'), color: '#1890ff' },
];
const pendingColumns: ColumnsType<CustomsDeclaration> = [
{
title: '报关单号',
dataIndex: 'customsName',
key: 'customsName',
render: (text: string) => <b>{text}</b>,
},
{
title: '机器数量',
dataIndex: 'machineCount',
key: 'machineCount',
render: (count: number) => (count ? `${count}` : '-'),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: CustomsDeclaration['status']) => <StatusBadge status={status} />,
},
];
return (
<div style={{ paddingBottom: 24 }}>
{contextHolder}
{errorMessage && (
<Alert
type="error"
message={errorMessage}
showIcon
style={{ marginBottom: 16 }}
action={<Button size="small" onClick={() => router.refresh()}></Button>}
/>
)}
<Row gutter={24} style={{ marginBottom: 24 }}>
{statCards.map((stat) => (
<Col span={6} key={stat.title}>
<Card hoverable={Boolean(stat.onClick)} onClick={stat.onClick}>
<Statistic
title={stat.title}
value={stat.value}
suffix={stat.suffix}
prefix={stat.icon}
styles={{ content: stat.contentStyle }}
loading={!stats && !errorMessage}
/>
</Card>
</Col>
))}
</Row>
<div style={{ marginBottom: 32, marginTop: 16 }}>
<div style={{ fontSize: 18, marginBottom: 16, fontWeight: 600, color: '#333' }}></div>
<Row gutter={24}>
{quickActions.map((action) => (
<Col span={8} key={action.title}>
<Card
hoverable
onClick={action.onClick}
style={{
borderRadius: 8,
overflow: 'hidden',
border: '1px solid #f0f0f0',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
}}
styles={{ body: { padding: 24, background: '#fff', height: '100%' } }}
>
<Flex align="center" gap={20}>
<Flex
align="center"
justify="center"
style={{
width: 56,
height: 56,
borderRadius: 8,
background: '#f0f5ff',
color: action.color,
fontSize: 28,
flexShrink: 0,
}}
>
{action.icon}
</Flex>
<Flex vertical gap={4} style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 18, color: '#262626' }}>{action.title}</div>
<div style={{ color: '#595959', fontSize: 13, lineHeight: 1.5 }}>{action.desc}</div>
</Flex>
</Flex>
</Card>
</Col>
))}
</Row>
</div>
<Row gutter={24}>
<Col span={12}>
<Card
title="最近查验动态"
extra={<Button type="link" icon={<RightOutlined />} onClick={() => messageApi.info('后端暂未提供完整动态列表接口')}></Button>}
styles={{ body: { height: 'calc(100% - 57px)' } }}
>
{activities.length ? (
<Flex vertical>
{activities.map((item) => (
<Flex
key={item.id}
align="center"
gap={12}
style={{ padding: '12px 0', borderBottom: '1px solid #f0f0f0' }}
>
{item.type === 'start' ? <ClockCircleOutlined style={{ fontSize: 20 }} /> :
item.type === 'success' ? <CheckCircleOutlined style={{ fontSize: 20 }} /> :
item.type === 'warning' ? <WarningOutlined style={{ fontSize: 20 }} /> :
<ProfileOutlined style={{ fontSize: 20 }} />}
<Flex vertical gap={2}>
<span style={{ fontWeight: 500 }}>{item.message}</span>
<span style={{ color: '#8c8c8c', fontSize: 13 }}>{item.time}</span>
</Flex>
</Flex>
))}
</Flex>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无后端动态" />
)}
</Card>
</Col>
<Col span={12}>
<Card
title="待查验报关单"
extra={<Button type="link" onClick={() => router.push('/customs')}> <RightOutlined /></Button>}
styles={{ body: { height: 'calc(100% - 57px)' } }}
>
<Table
dataSource={pendingCustoms}
rowKey="id"
columns={pendingColumns}
pagination={false}
size="small"
onRow={() => ({
onClick: () => router.push('/customs'),
style: { cursor: 'pointer' },
})}
/>
</Card>
</Col>
</Row>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { Alert, Badge, Breadcrumb as AntdBreadcrumb, Button, Card, Col, Empty, Flex, Row, Space, Spin, Typography, message } from 'antd';
import { ArrowLeftOutlined, CameraOutlined, FullscreenOutlined, HomeOutlined, ReloadOutlined } from '@ant-design/icons';
import { Breadcrumb } from '@/components/Breadcrumb';
import { CameraFrame } from '@/components/CameraFrame';
import { BackendApi } from '@/services/backendApi';
import type { CameraInfo } from '@/types';
const { Text } = Typography;
export default function VideoPage() {
const [cameras, setCameras] = useState<CameraInfo[]>([]);
const [fullscreenCamera, setFullscreenCamera] = useState<CameraInfo | null>(null);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [messageApi, contextHolder] = message.useMessage();
const loadCameras = async () => {
try {
setLoading(true);
setErrorMessage('');
const cameraList = await BackendApi.getCameras();
setCameras(cameraList);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '摄像头列表加载失败,请稍后重试');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadCameras();
}, []);
if (fullscreenCamera) {
return (
<div>
{contextHolder}
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Space align="center" size="middle">
<Button icon={<ArrowLeftOutlined />} onClick={() => setFullscreenCamera(null)}></Button>
<AntdBreadcrumb
items={[
{ title: <Link href="/"><HomeOutlined /> </Link> },
{ title: <a href="#" onClick={(event) => { event.preventDefault(); setFullscreenCamera(null); }}></a> },
{ title: fullscreenCamera.name },
]}
/>
</Space>
<Button type="primary" icon={<CameraOutlined />} onClick={() => messageApi.info('截图接口暂未提供')}></Button>
</Flex>
<Flex justify="center" align="center" style={{ height: 'calc(100vh - 180px)', marginBottom: 16 }}>
<div style={{ height: '100%', maxWidth: '100%', aspectRatio: '16 / 9', boxShadow: '0 8px 24px rgba(0,0,0,0.1)' }}>
<CameraFrame camera={fullscreenCamera} height="100%" />
</div>
</Flex>
</div>
);
}
return (
<div>
{contextHolder}
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
<Breadcrumb />
<Space>
<Button icon={<ReloadOutlined />} onClick={loadCameras}></Button>
<Button icon={<FullscreenOutlined />} onClick={() => messageApi.info('请选择一个在线画面进入全屏')}></Button>
<Button type="primary" icon={<CameraOutlined />} onClick={() => messageApi.info('全部截图接口暂未提供')}></Button>
</Space>
</Flex>
{errorMessage && (
<Alert type="error" message={errorMessage} showIcon style={{ marginBottom: 24 }} />
)}
{loading ? (
<Flex vertical align="center" justify="center" style={{ padding: 64 }}>
<Spin size="large" tip="正在加载摄像头..." />
</Flex>
) : cameras.length ? (
<Row gutter={[24, 24]}>
{cameras.map((camera) => (
<Col xs={24} lg={12} key={camera.id}>
<Card
hoverable={camera.status === 'online'}
style={{ overflow: 'hidden', borderRadius: 12, borderColor: camera.status === 'online' ? '#f0f0f0' : '#ffccc7' }}
styles={{ body: { padding: 0 } }}
onClick={() => camera.status === 'online' && setFullscreenCamera(camera)}
>
<CameraFrame camera={camera} height={300} />
<Flex justify="space-between" align="center" style={{ padding: '12px 20px', background: camera.status === 'online' ? '#ffffff' : '#fff1f0', borderTop: '1px solid #f0f0f0' }}>
<Space size="middle">
<Badge status={camera.status === 'online' ? 'processing' : 'error'} />
<Text strong style={{ fontSize: 15, color: camera.status === 'online' ? 'inherit' : '#cf1322' }}>{camera.name}</Text>
{camera.placeholder && <Text type="secondary" style={{ fontSize: 12 }}></Text>}
</Space>
{camera.status === 'online' && <Button type="link" icon={<FullscreenOutlined />} size="small"></Button>}
</Flex>
</Card>
</Col>
))}
</Row>
) : (
<Empty description="暂无摄像头数据" />
)}
</div>
);
}