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,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.next
|
||||
node_modules
|
||||
out
|
||||
dist
|
||||
.env*.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
@@ -0,0 +1,9 @@
|
||||
# 海关智慧查验平台前端
|
||||
|
||||
这是从 `prototype/` 复刻并重构后的真实后端接入版本。原型目录保持只读,新代码集中在当前目录。
|
||||
|
||||
默认通过 Next.js rewrites 将 `/api/*` 与 `/photos/*` 转发到 Flask 后端 `http://127.0.0.1:5000`。如需改后端地址,可设置:
|
||||
|
||||
```bash
|
||||
BACKEND_URL=http://后端地址:5000 npm run dev
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const backendUrl = process.env.BACKEND_URL || process.env.NEXT_PUBLIC_BACKEND_URL || 'http://127.0.0.1:5000';
|
||||
|
||||
const nextConfig = {
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${backendUrl}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/photos/:path*',
|
||||
destination: `${backendUrl}/photos/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+6458
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "public-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
"@ant-design/nextjs-registry": "^1.3.0",
|
||||
"antd": "^6.4.4",
|
||||
"dayjs": "^1.11.21",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"next": "14.2.35",
|
||||
"photoswipe": "^5.4.4",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-photoswipe-gallery": "^4.1.2",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.35",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Breadcrumb as AntdBreadcrumb } from 'antd';
|
||||
import { HomeOutlined } from '@ant-design/icons';
|
||||
|
||||
const breadcrumbNameMap: Record<string, string> = {
|
||||
'/video': '视频监控',
|
||||
'/machines': '机器查询',
|
||||
'/customs': '报关单管理',
|
||||
'/inspection': '远程查验',
|
||||
};
|
||||
|
||||
export const Breadcrumb: React.FC = () => {
|
||||
const pathname = usePathname();
|
||||
const pathSnippets = pathname.split('/').filter(Boolean);
|
||||
|
||||
const extraBreadcrumbItems = pathSnippets.map((_, index) => {
|
||||
const url = `/${pathSnippets.slice(0, index + 1).join('/')}`;
|
||||
const isLast = index === pathSnippets.length - 1;
|
||||
let name = breadcrumbNameMap[url] || pathSnippets[index];
|
||||
|
||||
if (!breadcrumbNameMap[url] && pathSnippets[index - 1] === 'machines') {
|
||||
name = '机器详情';
|
||||
} else if (!breadcrumbNameMap[url] && pathSnippets[index - 1] === 'customs') {
|
||||
name = '报关单详情';
|
||||
}
|
||||
|
||||
return {
|
||||
key: url,
|
||||
title: isLast ? name : <Link href={url}>{name}</Link>,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<AntdBreadcrumb
|
||||
items={[
|
||||
{
|
||||
key: 'home',
|
||||
title: <Link href="/"><HomeOutlined /> 首页</Link>,
|
||||
},
|
||||
...extraBreadcrumbItems,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Tag } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
MinusCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
QuestionCircleOutlined,
|
||||
SyncOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
type StatusType =
|
||||
| 'pending'
|
||||
| 'inspecting'
|
||||
| 'released'
|
||||
| 'abnormal'
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'online'
|
||||
| 'offline';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: StatusType;
|
||||
type?: 'badge' | 'tag';
|
||||
}
|
||||
|
||||
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, type = 'badge' }) => {
|
||||
const config = (() => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { color: 'warning', text: '待查验', icon: <ClockCircleOutlined style={{ color: '#faad14' }} /> };
|
||||
case 'inspecting':
|
||||
case 'running':
|
||||
return { color: 'processing', text: '查验中', icon: <SyncOutlined style={{ color: '#1890ff' }} /> };
|
||||
case 'released':
|
||||
case 'completed':
|
||||
return { color: 'success', text: '已放行', icon: <CheckCircleOutlined style={{ color: '#52c41a' }} /> };
|
||||
case 'abnormal':
|
||||
return { color: 'error', text: '异常', icon: <WarningOutlined style={{ color: '#ff4d4f' }} /> };
|
||||
case 'idle':
|
||||
return { color: 'default', text: '空闲', icon: <MinusCircleOutlined style={{ color: '#d9d9d9' }} /> };
|
||||
case 'paused':
|
||||
return { color: 'warning', text: '已暂停', icon: <PauseCircleOutlined style={{ color: '#faad14' }} /> };
|
||||
case 'online':
|
||||
return { color: 'success', text: '在线', icon: <CheckCircleOutlined style={{ color: '#52c41a' }} /> };
|
||||
case 'offline':
|
||||
return { color: 'error', text: '离线', icon: <CloseCircleOutlined style={{ color: '#ff4d4f' }} /> };
|
||||
default:
|
||||
return { color: 'default', text: '未知', icon: <QuestionCircleOutlined style={{ color: '#d9d9d9' }} /> };
|
||||
}
|
||||
})();
|
||||
|
||||
if (type === 'tag') {
|
||||
return <Tag color={config.color} icon={config.icon}>{config.text}</Tag>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
{config.icon}
|
||||
<span>{config.text}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Avatar, Badge, Button, Dropdown, Layout, Menu, Space, Switch, Typography, message, theme } from 'antd';
|
||||
import {
|
||||
BellOutlined,
|
||||
DashboardOutlined,
|
||||
FileTextOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
ScanOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { BackendApi } from '@/services/backendApi';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import type { ApiMode } from '@/types';
|
||||
|
||||
const { Header } = Layout;
|
||||
const { Text, Paragraph, Title } = Typography;
|
||||
|
||||
export const TopHeader: React.FC = () => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, notifications } = useAppStore();
|
||||
const { token } = theme.useToken();
|
||||
const [apiMode, setApiMode] = useState<ApiMode | null>(null);
|
||||
const [switchingMode, setSwitchingMode] = useState(false);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
const unreadCount = notifications.filter((notification) => !notification.read).length;
|
||||
|
||||
useEffect(() => {
|
||||
BackendApi.getApiMode()
|
||||
.then(setApiMode)
|
||||
.catch(() => setApiMode(null));
|
||||
}, []);
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '首页' },
|
||||
{ key: '/video', icon: <VideoCameraOutlined />, label: '视频监控' },
|
||||
{ key: '/machines', icon: <SearchOutlined />, label: '机器查询' },
|
||||
{ key: '/customs', icon: <FileTextOutlined />, label: '报关单' },
|
||||
{ key: '/inspection', icon: <ScanOutlined />, label: '远程查验' },
|
||||
];
|
||||
|
||||
const notificationMenu = {
|
||||
items: notifications.map((notification) => ({
|
||||
key: notification.id,
|
||||
label: (
|
||||
<div style={{ width: 250, padding: '4px 0', whiteSpace: 'normal' }}>
|
||||
<Text strong={!notification.read} type={notification.read ? 'secondary' : undefined} style={{ display: 'block' }}>
|
||||
{notification.title}
|
||||
</Text>
|
||||
<Paragraph style={{ marginTop: 4, marginBottom: 0, color: token.colorTextSecondary, fontSize: 12, lineHeight: 1.5 }}>
|
||||
{notification.message}
|
||||
</Paragraph>
|
||||
<Text style={{ marginTop: 4, display: 'block', color: token.colorTextTertiary, fontSize: 10 }}>
|
||||
{notification.time}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
};
|
||||
|
||||
const toggleApiMode = async (nextTestMode: boolean) => {
|
||||
setSwitchingMode(true);
|
||||
try {
|
||||
const nextMode = await BackendApi.setApiMode(nextTestMode);
|
||||
setApiMode(nextMode);
|
||||
messageApi.success(`已切换至${nextMode.label}`);
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '切换 API 环境失败');
|
||||
} finally {
|
||||
setSwitchingMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Header
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 24px',
|
||||
background: token.colorBgContainer,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
}}
|
||||
>
|
||||
<Space
|
||||
size="middle"
|
||||
onClick={() => router.push('/')}
|
||||
style={{ cursor: 'pointer', marginRight: 48, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<SafetyCertificateOutlined style={{ fontSize: 24, color: token.colorPrimary }} />
|
||||
<Title level={4} style={{ margin: 0, color: token.colorPrimary, whiteSpace: 'nowrap' }}>
|
||||
海关智慧查验平台
|
||||
</Title>
|
||||
</Space>
|
||||
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
selectedKeys={[pathname === '/' ? '/' : `/${pathname.split('/')[1]}`]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => router.push(key)}
|
||||
style={{ flex: 1, minWidth: 0, borderBottom: 'none', lineHeight: '62px' }}
|
||||
/>
|
||||
|
||||
<Space size="large" style={{ marginLeft: 24, display: 'flex', alignItems: 'center' }}>
|
||||
<Space size={8}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{apiMode?.label ?? '环境'}</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={apiMode?.testMode ?? false}
|
||||
checkedChildren="测"
|
||||
unCheckedChildren="正"
|
||||
loading={switchingMode}
|
||||
onChange={toggleApiMode}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Dropdown menu={notificationMenu} placement="bottomRight" trigger={['click']}>
|
||||
<Badge count={unreadCount} size="small" offset={[-4, 4]}>
|
||||
<Button type="text" shape="circle" icon={<BellOutlined style={{ fontSize: 18, color: token.colorText }} />} />
|
||||
</Badge>
|
||||
</Dropdown>
|
||||
|
||||
<Button type="text" shape="circle" icon={<SettingOutlined style={{ fontSize: 18, color: token.colorText }} />} />
|
||||
|
||||
<Space size="small" style={{ cursor: 'pointer', padding: '0 8px', borderRadius: token.borderRadius }}>
|
||||
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: token.colorPrimary }} />
|
||||
<Text style={{ fontSize: 14 }}>{user?.name}</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</Header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
type RequestOptions = RequestInit & {
|
||||
query?: Record<string, string | number | boolean | undefined | null>;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
data: unknown;
|
||||
|
||||
constructor(message: string, status: number, data: unknown) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
const buildUrl = (path: string, query?: RequestOptions['query']) => {
|
||||
const url = new URL(path, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
|
||||
|
||||
Object.entries(query ?? {}).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return `${url.pathname}${url.search}`;
|
||||
};
|
||||
|
||||
const parseResponse = async (response: Response) => {
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (contentType.includes('application/json')) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return response.text();
|
||||
};
|
||||
|
||||
export async function requestJson<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { query, headers, body, ...restOptions } = options;
|
||||
const response = await fetch(buildUrl(path, query), {
|
||||
...restOptions,
|
||||
headers: {
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...headers,
|
||||
},
|
||||
body,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await parseResponse(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = typeof data === 'object' && data && 'error' in data
|
||||
? String((data as { error: unknown }).error)
|
||||
: `请求失败:HTTP ${response.status}`;
|
||||
throw new ApiError(message, response.status, data);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function postJson<T>(path: string, payload?: unknown): Promise<T> {
|
||||
return requestJson<T>(path, {
|
||||
method: 'POST',
|
||||
body: payload === undefined ? undefined : JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { postJson, requestJson } from '@/services/apiClient';
|
||||
import {
|
||||
asArray,
|
||||
asRecord,
|
||||
asString,
|
||||
buildCameraList,
|
||||
extractRows,
|
||||
normalizeCustomsDeclaration,
|
||||
normalizeInspection,
|
||||
normalizeInspectionItem,
|
||||
normalizeMachineDetail,
|
||||
normalizeMissionRuntimeState,
|
||||
} from '@/services/normalizers';
|
||||
import type {
|
||||
ActivityItem,
|
||||
ApiMode,
|
||||
CameraInfo,
|
||||
CustomsDeclaration,
|
||||
CustomsStats,
|
||||
InspectionIssue,
|
||||
InspectionState,
|
||||
MachineDetail,
|
||||
MissionStateResponse,
|
||||
SystemStatus,
|
||||
} from '@/types';
|
||||
|
||||
interface OkResponse<T> {
|
||||
ok?: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ensureOk = <T extends OkResponse<unknown>>(payload: T, fallbackMessage: string) => {
|
||||
if (payload.ok === false) {
|
||||
throw new Error(payload.error || fallbackMessage);
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const BackendApi = {
|
||||
async getSystemStatus(): Promise<SystemStatus> {
|
||||
const [statusPayload, capabilitiesPayload] = await Promise.all([
|
||||
requestJson<Record<string, unknown>>('/api/status'),
|
||||
requestJson<Record<string, unknown>>('/api/camera/capabilities').catch((): Record<string, unknown> => ({})),
|
||||
]);
|
||||
|
||||
return {
|
||||
state: asString(statusPayload.state, 'idle') as SystemStatus['state'],
|
||||
agvConnected: Boolean(statusPayload.agv_connected),
|
||||
armConnected: Boolean(statusPayload.arm_connected),
|
||||
cameraOpened: Boolean(statusPayload.camera_opened),
|
||||
armCameraOpened: Boolean(statusPayload.arm_camera_opened),
|
||||
mapLoaded: Boolean(statusPayload.map_loaded),
|
||||
pointsCount: Number(statusPayload.points_count ?? 0),
|
||||
modelsCount: Number(statusPayload.models_count ?? 0),
|
||||
machinesCount: Number(statusPayload.machines_count ?? 0),
|
||||
hasAgvCamera: Boolean(statusPayload.has_agv_camera ?? capabilitiesPayload.has_agv_camera ?? statusPayload.camera_opened),
|
||||
hasArmCamera: Boolean(statusPayload.has_arm_camera ?? capabilitiesPayload.has_arm_camera ?? statusPayload.arm_camera_opened),
|
||||
};
|
||||
},
|
||||
|
||||
async connectAll() {
|
||||
return requestJson<{ agv: boolean; arm: boolean; camera: boolean; errors?: string[] }>('/api/system/connect', {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
async disconnectAll() {
|
||||
return postJson<{ ok: boolean }>('/api/system/disconnect');
|
||||
},
|
||||
|
||||
async connectDevice(device: 'agv' | 'arm' | 'camera' | 'arm_camera') {
|
||||
return postJson<{ device: string; ok: boolean; error?: string }>('/api/device/connect', { device });
|
||||
},
|
||||
|
||||
async getApiMode(): Promise<ApiMode> {
|
||||
const payload = ensureOk(await requestJson<OkResponse<unknown> & Record<string, unknown>>('/api/config/mode'), '读取 API 环境失败');
|
||||
return {
|
||||
testMode: Boolean(payload.test_mode),
|
||||
baseUrl: asString(payload.base_url),
|
||||
label: asString(payload.label, Boolean(payload.test_mode) ? '测试环境' : '正式环境'),
|
||||
};
|
||||
},
|
||||
|
||||
async setApiMode(testMode: boolean): Promise<ApiMode> {
|
||||
const payload = ensureOk(await postJson<OkResponse<unknown> & Record<string, unknown>>('/api/config/mode', { test_mode: testMode }), '切换 API 环境失败');
|
||||
return {
|
||||
testMode: Boolean(payload.test_mode),
|
||||
baseUrl: asString(payload.base_url),
|
||||
label: asString(payload.label, testMode ? '测试环境' : '正式环境'),
|
||||
};
|
||||
},
|
||||
|
||||
async getCustomsList(pageNum = 1, pageSize = 50): Promise<CustomsDeclaration[]> {
|
||||
const payload = ensureOk(
|
||||
await requestJson<OkResponse<unknown> & Record<string, unknown>>('/api/customs/list', {
|
||||
query: { pageNum, pageSize },
|
||||
}),
|
||||
'报关单列表加载失败',
|
||||
);
|
||||
const rows = extractRows(payload);
|
||||
return rows.map(normalizeCustomsDeclaration);
|
||||
},
|
||||
|
||||
async getCustomsMachines(customsId: string) {
|
||||
const payload = ensureOk(
|
||||
await requestJson<OkResponse<unknown> & Record<string, unknown>>('/api/customs/machines', {
|
||||
query: { customsId },
|
||||
}),
|
||||
'机器列表加载失败',
|
||||
);
|
||||
return extractRows(payload).map(normalizeInspectionItem);
|
||||
},
|
||||
|
||||
async getCustomsById(customsId: string): Promise<CustomsDeclaration | null> {
|
||||
const list = await this.getCustomsList(1, 100);
|
||||
const found = list.find((item) => item.customsId === customsId || item.id === customsId || item.customsName === customsId);
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = await this.getCustomsMachines(found.id).catch(() => found.items);
|
||||
return {
|
||||
...found,
|
||||
items,
|
||||
machineCount: found.machineCount || items.reduce((sum, item) => sum + item.quantify, 0),
|
||||
};
|
||||
},
|
||||
|
||||
async startCustomsInspection(customs: Pick<CustomsDeclaration, 'id' | 'customsName'>): Promise<InspectionState> {
|
||||
const payload = ensureOk(
|
||||
await postJson<OkResponse<unknown> & Record<string, unknown>>('/api/customs/inspection/start', {
|
||||
customsId: customs.id,
|
||||
customsName: customs.customsName,
|
||||
}),
|
||||
'开始查验失败',
|
||||
);
|
||||
const inspection = normalizeInspection(payload.inspection, 'running');
|
||||
if (!inspection) {
|
||||
throw new Error('后端未返回查验状态');
|
||||
}
|
||||
|
||||
return inspection;
|
||||
},
|
||||
|
||||
async getCurrentInspection(): Promise<InspectionState | null> {
|
||||
const payload = ensureOk(await requestJson<OkResponse<unknown> & Record<string, unknown>>('/api/customs/inspection'), '查验状态加载失败');
|
||||
return normalizeInspection(payload.inspection, 'running');
|
||||
},
|
||||
|
||||
async endCustomsInspection() {
|
||||
return postJson<{ ok: boolean }>('/api/customs/inspection/end');
|
||||
},
|
||||
|
||||
async getMissionState(): Promise<MissionStateResponse> {
|
||||
const payload = await requestJson<Record<string, unknown>>('/api/mission/state');
|
||||
const runtimeStatus = normalizeMissionRuntimeState(payload.state);
|
||||
return {
|
||||
state: asString(payload.state, 'idle') as MissionStateResponse['state'],
|
||||
inspection: normalizeInspection(payload.inspection, runtimeStatus),
|
||||
rows: Number(payload.rows ?? 0),
|
||||
cols: Number(payload.cols ?? 0),
|
||||
tasks: asArray(payload.tasks),
|
||||
errorMsg: asString(payload.error_msg),
|
||||
waitingStep: Boolean(payload.waiting_step),
|
||||
waitingError: Boolean(payload.waiting_error),
|
||||
};
|
||||
},
|
||||
|
||||
async startMission() {
|
||||
return ensureOk(await postJson<OkResponse<unknown> & Record<string, unknown>>('/api/mission/start', {}), '启动自动任务失败');
|
||||
},
|
||||
|
||||
async pauseMission() {
|
||||
return postJson<{ ok: boolean }>('/api/mission/pause');
|
||||
},
|
||||
|
||||
async resumeMission() {
|
||||
return postJson<{ ok: boolean }>('/api/mission/resume');
|
||||
},
|
||||
|
||||
async stopMission() {
|
||||
return postJson<{ ok: boolean }>('/api/mission/stop');
|
||||
},
|
||||
|
||||
async getMissionLogs(): Promise<ActivityItem[]> {
|
||||
const payload = await requestJson<Record<string, unknown>>('/api/mission/log');
|
||||
const logItems = asArray(payload.log);
|
||||
return logItems.slice(-80).map((item, index) => {
|
||||
const record = asRecord(item);
|
||||
const message = asString(record.msg ?? record.message ?? item, '系统日志');
|
||||
const time = asString(record.time, dayjs().format('HH:mm:ss'));
|
||||
const lowered = message.toLowerCase();
|
||||
const type: ActivityItem['type'] = lowered.includes('error') || message.includes('失败')
|
||||
? 'warning'
|
||||
: message.includes('完成') || lowered.includes('success')
|
||||
? 'success'
|
||||
: 'info';
|
||||
return {
|
||||
id: asString(record.id, `log-${index}`),
|
||||
time,
|
||||
type,
|
||||
message,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async getCameras(): Promise<CameraInfo[]> {
|
||||
const [statusPayload, capabilitiesPayload] = await Promise.all([
|
||||
requestJson<Record<string, unknown>>('/api/status').catch((): Record<string, unknown> => ({})),
|
||||
requestJson<Record<string, unknown>>('/api/camera/capabilities').catch((): Record<string, unknown> => ({})),
|
||||
]);
|
||||
return buildCameraList(statusPayload, capabilitiesPayload);
|
||||
},
|
||||
|
||||
async getMachineDetail(serialNumber: string): Promise<MachineDetail> {
|
||||
const payload = await requestJson<Record<string, unknown>>('/api/customs/printer', {
|
||||
query: { serialNumber },
|
||||
});
|
||||
ensureOk(payload, '机器详情加载失败');
|
||||
return normalizeMachineDetail(serialNumber, payload);
|
||||
},
|
||||
|
||||
async getCustomsStats(): Promise<CustomsStats> {
|
||||
const [customsList, inspection] = await Promise.all([
|
||||
this.getCustomsList(1, 50),
|
||||
this.getCurrentInspection().catch(() => null),
|
||||
]);
|
||||
|
||||
return {
|
||||
pendingCount: customsList.filter((item) => item.status === 'pending').length,
|
||||
releasedToday: customsList.filter((item) => item.status === 'released' && item.createdAt.startsWith(dayjs().format('YYYY-MM-DD'))).length,
|
||||
inspectingCount: inspection ? 1 : customsList.filter((item) => item.status === 'inspecting').length,
|
||||
abnormalCount: customsList.filter((item) => item.status === 'abnormal').length,
|
||||
};
|
||||
},
|
||||
|
||||
async getRecentActivities(): Promise<ActivityItem[]> {
|
||||
const [logs, inspection] = await Promise.all([
|
||||
this.getMissionLogs().catch(() => []),
|
||||
this.getCurrentInspection().catch(() => null),
|
||||
]);
|
||||
|
||||
if (logs.length) {
|
||||
return logs.slice(-5).reverse();
|
||||
}
|
||||
|
||||
if (inspection) {
|
||||
return [
|
||||
{
|
||||
id: 'current-inspection',
|
||||
time: inspection.startedAt ? dayjs.unix(inspection.startedAt).format('HH:mm') : dayjs().format('HH:mm'),
|
||||
type: 'start',
|
||||
message: `${inspection.customsName || inspection.customsId} 查验中`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
|
||||
async getInspectionIssues(): Promise<InspectionIssue[]> {
|
||||
const missionState = await this.getMissionState().catch(() => null);
|
||||
if (missionState?.waitingError && missionState.errorMsg) {
|
||||
return [
|
||||
{
|
||||
id: 'mission-error',
|
||||
time: dayjs().format('HH:mm:ss'),
|
||||
description: missionState.errorMsg,
|
||||
severity: 'error',
|
||||
status: 'pending',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
import dayjs from 'dayjs';
|
||||
import type {
|
||||
CameraInfo,
|
||||
CustomsDeclaration,
|
||||
CustomsStatus,
|
||||
InspectionItem,
|
||||
InspectionState,
|
||||
MachineDetail,
|
||||
MachineImageItem,
|
||||
MissionRuntimeState,
|
||||
} from '@/types';
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
export const asRecord = (value: unknown): UnknownRecord => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as UnknownRecord;
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export const asArray = (value: unknown): unknown[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
export const asString = (value: unknown, fallback = '') => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const asNumber = (value: unknown, fallback = 0) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const pickString = (record: UnknownRecord, keys: string[], fallback = '') => {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
return asString(value, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const pickNumber = (record: UnknownRecord, keys: string[], fallback = 0) => {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
return asNumber(value, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const normalizeDateTime = (value: unknown) => {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const text = asString(value);
|
||||
const parsed = dayjs(text);
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : text;
|
||||
};
|
||||
|
||||
const normalizeStatus = (raw: unknown, hasCustomsCode = false): CustomsStatus => {
|
||||
const status = asString(raw).toLowerCase();
|
||||
if (['released', 'finish', 'finished', 'completed', 'done', 'pass', 'passed'].includes(status)) {
|
||||
return 'released';
|
||||
}
|
||||
if (['abnormal', 'error', 'failed', 'exception'].includes(status)) {
|
||||
return 'abnormal';
|
||||
}
|
||||
if (['inspecting', 'running', 'processing'].includes(status)) {
|
||||
return 'inspecting';
|
||||
}
|
||||
|
||||
return hasCustomsCode ? 'pending' : 'pending';
|
||||
};
|
||||
|
||||
export const extractRows = (payload: unknown): unknown[] => {
|
||||
const root = asRecord(payload);
|
||||
const data = asRecord(root.data);
|
||||
const nestedData = asRecord(data.data);
|
||||
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(root.rows)) return root.rows;
|
||||
if (Array.isArray(root.records)) return root.records;
|
||||
if (Array.isArray(root.data)) return root.data;
|
||||
if (Array.isArray(data.rows)) return data.rows;
|
||||
if (Array.isArray(data.records)) return data.records;
|
||||
if (Array.isArray(data.data)) return data.data;
|
||||
if (Array.isArray(nestedData.rows)) return nestedData.rows;
|
||||
if (Array.isArray(nestedData.records)) return nestedData.records;
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
export const normalizeInspectionItem = (value: unknown): InspectionItem => {
|
||||
const item = asRecord(value);
|
||||
return {
|
||||
inventoryCode: pickString(item, ['inventoryCode', 'machineCode', 'code'], '-'),
|
||||
inventoryName: pickString(item, ['inventoryName', 'machineName', 'name', 'modelName'], '-'),
|
||||
spec: pickString(item, ['spec', 'inventorySpecification', 'specification'], '-'),
|
||||
quantify: pickNumber(item, ['quantify', 'quantity', 'count'], 0),
|
||||
inspected: pickNumber(item, ['inspected', 'inspectionCount', 'checkedCount'], 0),
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeCustomsDeclaration = (value: unknown): CustomsDeclaration => {
|
||||
const row = asRecord(value);
|
||||
const customs = asRecord(row.customs);
|
||||
const customsId = pickString(customs, ['id'], pickString(row, ['customsId', 'customs_id', 'id']));
|
||||
const customsCode = pickString(customs, ['customsCode'], pickString(row, ['customsCode', 'orderCode', 'drawCode'], customsId || '-'));
|
||||
const orderIds = pickString(customs, ['orderId']);
|
||||
const machineCount = pickNumber(row, ['machineCount', 'machine_count'], orderIds ? orderIds.split(',').filter(Boolean).length : 0);
|
||||
const rawItems = asArray(row.items);
|
||||
const hasCustomsCode = Boolean(pickString(customs, ['customsCode'], pickString(row, ['customsCode'])));
|
||||
|
||||
return {
|
||||
id: customsId || customsCode,
|
||||
customsId: customsId || customsCode,
|
||||
customsName: customsCode,
|
||||
status: normalizeStatus(pickString(row, ['status', 'state']), hasCustomsCode),
|
||||
machineCount,
|
||||
createdAt: normalizeDateTime(pickString(row, ['createTime', 'createdAt', 'applyTime', 'updateTime'], pickString(row, ['orderCode', 'drawCode']))),
|
||||
items: rawItems.map(normalizeInspectionItem),
|
||||
raw: value,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeInspection = (value: unknown, runtimeState: MissionRuntimeState = 'idle'): InspectionState | null => {
|
||||
const inspection = asRecord(value);
|
||||
const customsId = pickString(inspection, ['customsId', 'customs_id', 'id']);
|
||||
if (!customsId && !inspection.items) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
customsId,
|
||||
customsName: pickString(inspection, ['customsName', 'customs_name', 'name'], customsId),
|
||||
status: runtimeState,
|
||||
items: asArray(inspection.items).map(normalizeInspectionItem),
|
||||
startedAt: pickNumber(inspection, ['startedAt', 'started_at'], 0),
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeMissionRuntimeState = (state: unknown): MissionRuntimeState => {
|
||||
const text = asString(state).toLowerCase();
|
||||
if (text === 'running' || text === 'setting') return 'running';
|
||||
if (text === 'paused') return 'paused';
|
||||
if (text === 'completed') return 'completed';
|
||||
return 'idle';
|
||||
};
|
||||
|
||||
const normalizeImage = (value: unknown, index: number, fallbackName: string): MachineImageItem => {
|
||||
const item = asRecord(value);
|
||||
const url = pickString(item, ['url', 'imageUrl', 'path', 'fileUrl']);
|
||||
return {
|
||||
id: pickString(item, ['id'], `${fallbackName}-${index}`),
|
||||
url,
|
||||
thumbnailUrl: pickString(item, ['thumbnailUrl', 'thumbUrl'], url),
|
||||
name: pickString(item, ['name', 'fileName'], `${fallbackName} ${index + 1}`),
|
||||
createdAt: normalizeDateTime(pickString(item, ['createdAt', 'createTime', 'uploadTime'])),
|
||||
};
|
||||
};
|
||||
|
||||
const emptyImageGroups = () => ({
|
||||
incomingInspection: [] as MachineImageItem[],
|
||||
startupTestSample: [] as MachineImageItem[],
|
||||
productionOrder: [] as MachineImageItem[],
|
||||
robotInspection: [] as MachineImageItem[],
|
||||
});
|
||||
|
||||
export const normalizeMachineDetail = (serialNumber: string, payload: unknown): MachineDetail => {
|
||||
const root = asRecord(payload);
|
||||
const printer = asRecord(root.printer ?? asRecord(root.data).printer);
|
||||
const orderItem = asRecord(root.orderItem ?? asRecord(root.data).orderItem);
|
||||
const inventory = asRecord(orderItem.inventory ?? printer.inventory);
|
||||
const modelName = pickString(root, ['modelName'], pickString(inventory, ['inventoryName', 'name'], pickString(printer, ['model', 'machineModel'], '未知设备')));
|
||||
const images = emptyImageGroups();
|
||||
const rawImages = asRecord(root.images ?? printer.images);
|
||||
|
||||
images.incomingInspection = asArray(rawImages.incomingInspection).map((item, index) => normalizeImage(item, index, '来料检验单'));
|
||||
images.startupTestSample = asArray(rawImages.startupTestSample).map((item, index) => normalizeImage(item, index, '开机测试样张'));
|
||||
images.productionOrder = asArray(rawImages.productionOrder).map((item, index) => normalizeImage(item, index, '生产加工单'));
|
||||
images.robotInspection = asArray(rawImages.robotInspection).map((item, index) => normalizeImage(item, index, '机器人查验拍照'));
|
||||
|
||||
return {
|
||||
serialNumber,
|
||||
modelName,
|
||||
modelId: pickString(inventory, ['inventoryCode', 'code'], pickString(root, ['inventoryCode'], '-')),
|
||||
customsId: pickString(orderItem, ['customsId'], '-'),
|
||||
customsName: pickString(orderItem, ['customsName'], '-'),
|
||||
status: 'pending',
|
||||
specs: {
|
||||
物料编码: pickString(inventory, ['inventoryCode', 'code'], '-'),
|
||||
规格: pickString(inventory, ['inventorySpecification', 'specification', 'spec'], '-'),
|
||||
序列号: pickString(printer, ['serialNumber'], serialNumber),
|
||||
},
|
||||
createdAt: normalizeDateTime(pickString(printer, ['createTime', 'createdAt'])),
|
||||
images,
|
||||
inspectionRecords: [],
|
||||
raw: payload,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildCameraList = (statusPayload: unknown, capabilitiesPayload: unknown): CameraInfo[] => {
|
||||
const status = asRecord(statusPayload);
|
||||
const capabilities = asRecord(capabilitiesPayload);
|
||||
const hasAgvCamera = Boolean(status.has_agv_camera ?? capabilities.has_agv_camera ?? status.camera_opened);
|
||||
const hasArmCamera = Boolean(status.has_arm_camera ?? capabilities.has_arm_camera ?? status.arm_camera_opened);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'overview-1',
|
||||
name: '监控摄像头 1',
|
||||
location: '查验区东侧',
|
||||
streamUrl: '',
|
||||
status: 'offline',
|
||||
category: 'overview',
|
||||
placeholder: true,
|
||||
},
|
||||
{
|
||||
id: 'overview-2',
|
||||
name: '监控摄像头 2',
|
||||
location: '查验区南侧',
|
||||
streamUrl: '',
|
||||
status: 'offline',
|
||||
category: 'overview',
|
||||
placeholder: true,
|
||||
},
|
||||
{
|
||||
id: 'overview-3',
|
||||
name: '监控摄像头 3',
|
||||
location: '查验区西侧',
|
||||
streamUrl: '',
|
||||
status: 'offline',
|
||||
category: 'overview',
|
||||
placeholder: true,
|
||||
},
|
||||
{
|
||||
id: 'overview-4',
|
||||
name: '监控摄像头 4',
|
||||
location: '查验区北侧',
|
||||
streamUrl: '',
|
||||
status: 'offline',
|
||||
category: 'overview',
|
||||
placeholder: true,
|
||||
},
|
||||
{
|
||||
id: 'agv-camera',
|
||||
name: 'AGV 主摄像头',
|
||||
location: 'AGV 前端',
|
||||
streamUrl: '/api/camera/refresh',
|
||||
status: hasAgvCamera && Boolean(status.camera_opened) ? 'online' : 'offline',
|
||||
category: 'agv',
|
||||
placeholder: !hasAgvCamera,
|
||||
},
|
||||
{
|
||||
id: 'arm-camera',
|
||||
name: '作业视角',
|
||||
location: '机械臂',
|
||||
streamUrl: '/api/camera/arm_preview',
|
||||
status: hasArmCamera && Boolean(status.arm_camera_opened) ? 'online' : 'offline',
|
||||
category: 'operation',
|
||||
placeholder: !hasArmCamera,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { CustomsDeclaration, InspectionState, Notification, User } from '@/types';
|
||||
|
||||
interface AppState {
|
||||
user: User | null;
|
||||
notifications: Notification[];
|
||||
selectedCustoms: CustomsDeclaration | null;
|
||||
inspection: InspectionState | null;
|
||||
setUser: (user: User | null) => void;
|
||||
addNotification: (notification: Notification) => void;
|
||||
markNotificationRead: (id: string) => void;
|
||||
setSelectedCustoms: (customs: CustomsDeclaration | null) => void;
|
||||
setInspection: (inspection: InspectionState | null) => void;
|
||||
updateInspectionStatus: (status: InspectionState['status']) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
user: { name: '张三', role: '海关查验员' },
|
||||
notifications: [
|
||||
{ id: '1', title: '系统通知', message: '远程查验前端已连接真实后端接口', time: '当前', read: false },
|
||||
],
|
||||
selectedCustoms: null,
|
||||
inspection: null,
|
||||
|
||||
setUser: (user) => set({ user }),
|
||||
addNotification: (notification) => set((state) => ({ notifications: [notification, ...state.notifications] })),
|
||||
markNotificationRead: (id) => set((state) => ({
|
||||
notifications: state.notifications.map((notification) => (
|
||||
notification.id === id ? { ...notification, read: true } : notification
|
||||
)),
|
||||
})),
|
||||
setSelectedCustoms: (selectedCustoms) => set({ selectedCustoms }),
|
||||
setInspection: (inspection) => set({ inspection }),
|
||||
updateInspectionStatus: (status) => set((state) => ({
|
||||
inspection: state.inspection ? { ...state.inspection, status } : null,
|
||||
})),
|
||||
}));
|
||||
@@ -0,0 +1,150 @@
|
||||
export interface User {
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
time: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
export type CustomsStatus = 'pending' | 'released' | 'abnormal' | 'inspecting';
|
||||
export type MissionRuntimeState = 'idle' | 'running' | 'paused' | 'completed';
|
||||
export type DeviceStatus = 'online' | 'offline';
|
||||
|
||||
export interface InspectionItem {
|
||||
inventoryCode: string;
|
||||
inventoryName: string;
|
||||
spec: string;
|
||||
quantify: number;
|
||||
inspected: number;
|
||||
}
|
||||
|
||||
export interface CustomsDeclaration {
|
||||
id: string;
|
||||
customsId: string;
|
||||
customsName: string;
|
||||
status: CustomsStatus;
|
||||
machineCount: number;
|
||||
createdAt: string;
|
||||
items: InspectionItem[];
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface CustomsStats {
|
||||
pendingCount: number;
|
||||
releasedToday: number;
|
||||
inspectingCount: number;
|
||||
abnormalCount: number;
|
||||
}
|
||||
|
||||
export interface ActivityItem {
|
||||
id: string;
|
||||
time: string;
|
||||
type: 'start' | 'success' | 'info' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CameraInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
streamUrl: string;
|
||||
status: DeviceStatus;
|
||||
category: 'overview' | 'agv' | 'operation';
|
||||
placeholder?: boolean;
|
||||
}
|
||||
|
||||
export interface InspectionIssue {
|
||||
id: string;
|
||||
time: string;
|
||||
description: string;
|
||||
severity: 'warning' | 'error';
|
||||
status: 'pending' | 'disposed' | 'cancelled';
|
||||
disposedAt?: string;
|
||||
disposedBy?: string;
|
||||
}
|
||||
|
||||
export interface InspectionState {
|
||||
customsId: string;
|
||||
customsName: string;
|
||||
status: MissionRuntimeState;
|
||||
items: InspectionItem[];
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export interface MachineImageItem {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface InspectionRecord {
|
||||
id: string;
|
||||
time: string;
|
||||
operator: string;
|
||||
result: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface MachineDetail {
|
||||
serialNumber: string;
|
||||
modelName: string;
|
||||
modelId: string;
|
||||
customsId: string;
|
||||
customsName: string;
|
||||
status: CustomsStatus;
|
||||
specs: Record<string, string>;
|
||||
createdAt: string;
|
||||
images: {
|
||||
incomingInspection: MachineImageItem[];
|
||||
startupTestSample: MachineImageItem[];
|
||||
productionOrder: MachineImageItem[];
|
||||
robotInspection: MachineImageItem[];
|
||||
};
|
||||
inspectionRecords: InspectionRecord[];
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
state: 'idle' | 'setting' | 'running' | 'paused';
|
||||
agvConnected: boolean;
|
||||
armConnected: boolean;
|
||||
cameraOpened: boolean;
|
||||
armCameraOpened: boolean;
|
||||
mapLoaded: boolean;
|
||||
pointsCount: number;
|
||||
modelsCount: number;
|
||||
machinesCount: number;
|
||||
hasAgvCamera: boolean;
|
||||
hasArmCamera: boolean;
|
||||
}
|
||||
|
||||
export interface MissionStateResponse {
|
||||
state: 'idle' | 'setting' | 'running' | 'paused';
|
||||
inspection: InspectionState | null;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
tasks?: unknown[];
|
||||
log?: unknown[];
|
||||
errorMsg?: string;
|
||||
waitingStep?: boolean;
|
||||
waitingError?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiMode {
|
||||
testMode: boolean;
|
||||
baseUrl: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface RecentMachineQuery {
|
||||
serialNumber: string;
|
||||
name: string;
|
||||
time: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user