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>
);
}