Add customs tablet frontend prototype
This commit is contained in:
+195
-3
@@ -1,16 +1,208 @@
|
|||||||
|
# ==============================
|
||||||
|
# Python
|
||||||
|
# ==============================
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
.venv/
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.virtualenv/
|
||||||
|
.virtenv/
|
||||||
|
|
||||||
|
# uv package manager
|
||||||
|
uv-cache/
|
||||||
|
|
||||||
|
# Python testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Node.js / npm
|
||||||
|
# ==============================
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Next.js (Frontend)
|
||||||
|
# ==============================
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.vercel/
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# IDEs & Editors
|
||||||
|
# ==============================
|
||||||
|
# VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# JetBrains IDEs (IntelliJ, PyCharm, WebStorm, etc.)
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea_modules/
|
||||||
|
|
||||||
|
# Vim/Neovim
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.netrwhist
|
||||||
|
|
||||||
|
# Emacs
|
||||||
|
*~
|
||||||
|
\#*\#
|
||||||
|
.\#*
|
||||||
|
*.elc
|
||||||
|
|
||||||
|
# Sublime Text
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# macOS
|
||||||
|
# ==============================
|
||||||
.DS_Store
|
.DS_Store
|
||||||
._*
|
._*
|
||||||
.AppleDouble
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
# Local runtime logs and temporary files
|
# ==============================
|
||||||
|
# Windows
|
||||||
|
# ==============================
|
||||||
|
Thumbs.db
|
||||||
|
Thumbs.db:encryptable
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
*.stackdump
|
||||||
|
[Dd]esktop.ini
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
*.lnk
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Linux
|
||||||
|
# ==============================
|
||||||
|
*~
|
||||||
|
.fuse_hidden*
|
||||||
|
.directory
|
||||||
|
.Trash-*
|
||||||
|
.nfs*
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Local runtime & temporary files
|
||||||
|
# ==============================
|
||||||
*.log
|
*.log
|
||||||
|
*.log.*
|
||||||
*.pid
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
timeout
|
timeout
|
||||||
|
*.tmp
|
||||||
# Local backup copies
|
*.temp
|
||||||
|
*.cache
|
||||||
*.bak
|
*.bak
|
||||||
*.bak.*
|
*.bak.*
|
||||||
*.bak2
|
*.bak2
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Application specific
|
||||||
|
# ==============================
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.local.example
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# ROS2 / Robotics
|
||||||
|
# ==============================
|
||||||
|
install/
|
||||||
|
log/
|
||||||
|
build/
|
||||||
|
*/install/
|
||||||
|
*/log/
|
||||||
|
*/build/
|
||||||
|
*.bag
|
||||||
|
*.bag.active
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Misc
|
||||||
|
# ==============================
|
||||||
|
# Sensitive data (adjust patterns as needed)
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.secret
|
||||||
|
secrets/
|
||||||
|
|
||||||
|
# Large binary files (adjust as needed)
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
*.rar
|
||||||
|
*.7z
|
||||||
|
|
||||||
|
# Generated documentation
|
||||||
|
docs/_build/
|
||||||
|
site/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
+6458
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "customs-tablet-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"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,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Card, Table, Form, Button, DatePicker, Select, Space, Row, Col, Input } from 'antd';
|
||||||
|
import { SearchOutlined, PlayCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Breadcrumb } from '../../components/Breadcrumb';
|
||||||
|
import { MockApi } from '../../services/mockApi';
|
||||||
|
import { CustomsDeclaration } from '../../types';
|
||||||
|
import { StatusBadge } from '../../components/StatusBadge';
|
||||||
|
import { useAppStore } from '../../store/useAppStore';
|
||||||
|
|
||||||
|
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 [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const { setSelectedCustoms } = useAppStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadCustomsList = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
const res = await MockApi.getCustomsList();
|
||||||
|
if (!isMounted) return;
|
||||||
|
setData(res);
|
||||||
|
setFilteredData(res);
|
||||||
|
} catch {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setErrorMessage('报关单列表加载失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCustomsList();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStartInspection = (record: CustomsDeclaration) => {
|
||||||
|
setSelectedCustoms(record);
|
||||||
|
router.push(`/inspection?customsId=${encodeURIComponent(record.customsId)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
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.customsId.toLowerCase().includes(keyword);
|
||||||
|
const createdAt = new Date(item.createdAt);
|
||||||
|
const matchesDateRange = !dateRange?.[0] || !dateRange?.[1]
|
||||||
|
|| (createdAt >= dateRange[0].startOf('day').toDate() && createdAt <= dateRange[1].endOf('day').toDate());
|
||||||
|
|
||||||
|
return matchesStatus && matchesKeyword && matchesDateRange;
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilteredData(nextData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
form.resetFields();
|
||||||
|
setFilteredData(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandedRowRender = (record: CustomsDeclaration) => {
|
||||||
|
const columns = [
|
||||||
|
{ 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' },
|
||||||
|
];
|
||||||
|
return <Table columns={columns} dataSource={record.items} pagination={false} size="small" rowKey="inventoryCode" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<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 }}>
|
||||||
|
<Select.Option value="all">全部</Select.Option>
|
||||||
|
<Select.Option value="pending">待查验</Select.Option>
|
||||||
|
<Select.Option value="inspecting">查验中</Select.Option>
|
||||||
|
<Select.Option value="released">已放行</Select.Option>
|
||||||
|
<Select.Option value="abnormal">异常</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</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"
|
||||||
|
expandable={{ expandedRowRender }}
|
||||||
|
>
|
||||||
|
<Table.Column title="报关单号" dataIndex="customsId" key="customsId" render={(text: string) => <b>{text}</b>} />
|
||||||
|
<Table.Column title="状态" dataIndex="status" key="status" render={(status: string) => <StatusBadge status={status as 'pending' | 'inspecting' | 'released' | 'abnormal'} />} />
|
||||||
|
<Table.Column title="机器总数" dataIndex="machineCount" key="machineCount" render={(count: number) => `${count} 台`} />
|
||||||
|
<Table.Column title="创建时间" dataIndex="createdAt" key="createdAt" />
|
||||||
|
<Table.Column
|
||||||
|
title="操作"
|
||||||
|
key="action"
|
||||||
|
render={(_, record: CustomsDeclaration) => (
|
||||||
|
<Space size="middle">
|
||||||
|
{record.status === 'pending' || record.status === 'inspecting' ? (
|
||||||
|
<Button type="primary" icon={<PlayCircleOutlined />} onClick={() => handleStartInspection(record)}>
|
||||||
|
开始查验
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="link">查看详情</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,52 @@
|
|||||||
|
/* Ant Design 自带主题 Token,移除不必要的自定义变量 */
|
||||||
|
: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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { Suspense, useEffect, useState, useRef } from 'react';
|
||||||
|
import { Alert, Row, Col, Card, Button, Progress, List, Typography, Space, Modal, Input, Empty, Badge, Spin, Flex, Select, Segmented, theme, Divider, Timeline } from 'antd';
|
||||||
|
import {
|
||||||
|
PlayCircleOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
CaretRightOutlined,
|
||||||
|
PauseCircleFilled,
|
||||||
|
VideoCameraOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Breadcrumb } from '../../components/Breadcrumb';
|
||||||
|
import { MockApi } from '../../services/mockApi';
|
||||||
|
import { CustomsDeclaration, InspectionItem } from '../../types';
|
||||||
|
import { useAppStore } from '../../store/useAppStore';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
type InspectionStatus = 'idle' | 'running' | 'paused' | 'completed';
|
||||||
|
|
||||||
|
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 } = useAppStore();
|
||||||
|
const [currentCustoms, setCurrentCustoms] = useState<CustomsDeclaration | null>(null);
|
||||||
|
const [loadingCustoms, setLoadingCustoms] = useState(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [status, setStatus] = useState<InspectionStatus>('idle');
|
||||||
|
const [logs, setLogs] = useState<{time: string, msg: string, type: 'info'|'warning'|'success'}[]>([]);
|
||||||
|
const [progressData, setProgressData] = useState<ProgressItem[]>([]);
|
||||||
|
const [isPauseModalVisible, setIsPauseModalVisible] = useState(false);
|
||||||
|
const [pauseReason, setPauseReason] = useState('');
|
||||||
|
const [customsList, setCustomsList] = useState<CustomsDeclaration[]>([]);
|
||||||
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
|
const [currentView, setCurrentView] = useState<string>('摄像头1');
|
||||||
|
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingList(true);
|
||||||
|
MockApi.getCustomsList().then(list => {
|
||||||
|
setCustomsList(list);
|
||||||
|
setLoadingList(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadInspectionCustoms = async () => {
|
||||||
|
setLoadingCustoms(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (customsId) {
|
||||||
|
const cachedCustoms = selectedCustoms?.customsId === customsId ? selectedCustoms : null;
|
||||||
|
const customs = cachedCustoms ?? await MockApi.getCustomsById(customsId);
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
if (!customs) {
|
||||||
|
setCurrentCustoms(null);
|
||||||
|
setErrorMessage(`未找到报关单 ${customsId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentCustoms(customs);
|
||||||
|
setSelectedCustoms(customs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentCustoms(selectedCustoms);
|
||||||
|
} catch {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setCurrentCustoms(null);
|
||||||
|
setErrorMessage('查验任务加载失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoadingCustoms(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadInspectionCustoms();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [customsId, selectedCustoms, setSelectedCustoms]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentCustoms) {
|
||||||
|
setProgressData([]);
|
||||||
|
setStatus('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgressData(currentCustoms.items.map(item => ({
|
||||||
|
...item,
|
||||||
|
currentInspected: item.inspected
|
||||||
|
})));
|
||||||
|
setStatus(currentCustoms.status === 'inspecting' ? 'running' : 'idle');
|
||||||
|
setLogs([]);
|
||||||
|
}, [currentCustoms]);
|
||||||
|
|
||||||
|
// 模拟查验过程
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: NodeJS.Timeout;
|
||||||
|
if (status === 'running') {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
setProgressData(prev => {
|
||||||
|
let allDone = true;
|
||||||
|
const next = prev.map(item => {
|
||||||
|
if (item.currentInspected < item.quantify) {
|
||||||
|
allDone = false;
|
||||||
|
// 随机增加进度
|
||||||
|
if (Math.random() > 0.5) {
|
||||||
|
const newInspected = Math.min(item.currentInspected + 1, item.quantify);
|
||||||
|
if (newInspected > item.currentInspected) {
|
||||||
|
addLog(`料号 ${item.inventoryCode} (${item.inventoryName}) 核销 +1`, 'info');
|
||||||
|
}
|
||||||
|
return { ...item, currentInspected: newInspected };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allDone) {
|
||||||
|
setStatus('completed');
|
||||||
|
addLog('全部机器核销完成', 'success');
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 3000); // 每 3 秒更新一次模拟数据
|
||||||
|
}
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
// 自动滚动到最新日志
|
||||||
|
useEffect(() => {
|
||||||
|
if (logsEndRef.current) {
|
||||||
|
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
const addLog = (msg: string, type: 'info'|'warning'|'success' = 'info') => {
|
||||||
|
setLogs(prev => [...prev, { time: new Date().toLocaleTimeString(), msg, type }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = () => {
|
||||||
|
setStatus('running');
|
||||||
|
addLog('开始自动化查验作业', 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = () => {
|
||||||
|
setIsPauseModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPause = () => {
|
||||||
|
setStatus('paused');
|
||||||
|
addLog(`查验已暂停。原因:${pauseReason || '未填写'}`, 'warning');
|
||||||
|
setIsPauseModalVisible(false);
|
||||||
|
setPauseReason('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认结束查验?',
|
||||||
|
content: '结束查验后无法继续当前任务。',
|
||||||
|
onOk: () => {
|
||||||
|
setStatus('completed');
|
||||||
|
addLog('用户手动结束查验', 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotalProgress = () => {
|
||||||
|
if (!progressData.length) return 0;
|
||||||
|
const total = progressData.reduce((acc, curr) => acc + curr.quantify, 0);
|
||||||
|
const inspected = progressData.reduce((acc, curr) => acc + curr.currentInspected, 0);
|
||||||
|
return Math.round((inspected / total) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
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)' }}>
|
||||||
|
<Breadcrumb />
|
||||||
|
|
||||||
|
<Row gutter={24} style={{ flex: 1, minHeight: 0, margin: '0 24px 24px 24px' }}>
|
||||||
|
{/* 左侧:AGV 及监控画面 */}
|
||||||
|
<Col span={14}>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Space>
|
||||||
|
<VideoCameraOutlined style={{ color: token.colorPrimary }} />
|
||||||
|
<span>AGV 实时画面</span>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Badge status="processing" text="设备在线" />
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', flex: 1 } }}
|
||||||
|
style={{ height: '100%', display: 'flex', flexDirection: 'column', borderRadius: token.borderRadiusLG, overflow: 'hidden' }}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: '#000000'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === 'running' ? (
|
||||||
|
<Flex vertical align="center" gap={16} style={{ color: '#ffffff' }}>
|
||||||
|
<CaretRightOutlined style={{ fontSize: 48, color: token.colorPrimary, opacity: 0.8 }} />
|
||||||
|
<Text style={{ color: '#fff' }}>正在接收实时流...</Text>
|
||||||
|
<div style={{ padding: '8px 16px', border: `1px dashed ${token.colorPrimary}`, borderRadius: token.borderRadius }}>
|
||||||
|
<span style={{ color: token.colorPrimary }}>AI 识别分析中</span>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<Flex vertical align="center" gap={16} style={{ color: token.colorTextDescription }}>
|
||||||
|
<PauseCircleFilled style={{ fontSize: 48 }} />
|
||||||
|
<Text type="secondary">画面已暂停或未启动</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '16px 24px', borderTop: `1px solid ${token.colorBorderSecondary}`, background: token.colorFillAlter }}>
|
||||||
|
<Flex align="center" gap="middle">
|
||||||
|
<Text strong>视角切换:</Text>
|
||||||
|
<Segmented
|
||||||
|
options={['摄像头1', '摄像头2', '摄像头3', '摄像头4', '摄像头5']}
|
||||||
|
value={currentView}
|
||||||
|
onChange={setCurrentView}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 右侧:查验控制面板 */}
|
||||||
|
<Col span={10}>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<span>查验任务</span>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
placeholder="搜索并选择报关单..."
|
||||||
|
style={{ width: 240 }}
|
||||||
|
loading={loadingList}
|
||||||
|
optionFilterProp="label"
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label as string ?? '').toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
options={customsList.map(item => ({
|
||||||
|
value: item.customsId,
|
||||||
|
label: `${item.customsId} - ${item.status === 'pending' ? '待查验' : item.status === 'inspecting' ? '查验中' : '已放行'}`
|
||||||
|
}))}
|
||||||
|
value={currentCustoms?.customsId || undefined}
|
||||||
|
onChange={(value) => {
|
||||||
|
router.push(`/inspection?customsId=${value}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
styles={{ body: { overflow: 'hidden', padding: '24px', display: 'flex', flexDirection: 'column', flex: 1 } }}
|
||||||
|
style={{ height: '100%', display: 'flex', flexDirection: 'column', borderRadius: token.borderRadiusLG }}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
{!currentCustoms ? (
|
||||||
|
<Flex vertical align="center" justify="center" style={{ flex: 1 }}>
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={errorMessage}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 24, width: '100%' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Empty description="请在右上角选择要查验的报关单" />
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Flex justify="center" gap="middle" style={{ marginBottom: 32 }}>
|
||||||
|
{status === 'idle' || status === 'paused' ? (
|
||||||
|
<Button type="primary" size="large" icon={<PlayCircleOutlined />} onClick={handleStart} style={{ width: 140 }}>
|
||||||
|
{status === 'idle' ? '开始查验' : '继续查验'}
|
||||||
|
</Button>
|
||||||
|
) : status === 'running' ? (
|
||||||
|
<Button type="primary" danger size="large" icon={<PauseCircleOutlined />} onClick={handlePause} style={{ width: 140 }}>
|
||||||
|
暂停查验
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button size="large" icon={<ReloadOutlined />} disabled={status === 'completed'}>重置</Button>
|
||||||
|
<Button danger size="large" icon={<StopOutlined />} onClick={handleEnd} disabled={status === 'completed' || status === 'idle'}>结束</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Divider titlePlacement="start" plain style={{ margin: '0 0 16px 0' }}><Text strong>当前核销进度</Text></Divider>
|
||||||
|
<div style={{ marginBottom: 16, padding: '0 8px' }}>
|
||||||
|
<Progress percent={calculateTotalProgress()} status={status === 'completed' ? 'success' : 'active'} strokeWidth={10} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: '0 1 35%', marginBottom: 24, overflowY: 'auto', paddingRight: 8 }}>
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={progressData}
|
||||||
|
renderItem={item => (
|
||||||
|
<List.Item style={{ padding: '12px 8px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginBottom: 8 }}>
|
||||||
|
<Text strong>{item.inventoryName}</Text>
|
||||||
|
<Space>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>{item.inventoryCode}</Text>
|
||||||
|
<Badge count={`${item.currentInspected} / ${item.quantify}`} style={{ backgroundColor: item.currentInspected === item.quantify ? token.colorSuccess : token.colorPrimary }} />
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
<Progress percent={Math.round((item.currentInspected / item.quantify) * 100)} showInfo={false} size="small" />
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider titlePlacement="start" plain style={{ margin: '0 0 16px 0' }}><Text strong>查验日志</Text></Divider>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
borderRadius: token.borderRadiusLG,
|
||||||
|
background: token.colorFillQuaternary,
|
||||||
|
padding: 16
|
||||||
|
}}>
|
||||||
|
{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>{item.msg}</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 }}
|
||||||
|
>
|
||||||
|
<Flex vertical gap={16} style={{ paddingTop: 16 }}>
|
||||||
|
<Text>请确认是否暂停当前自动查验任务?</Text>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
placeholder="请输入暂停原因(可选)..."
|
||||||
|
value={pauseReason}
|
||||||
|
onChange={e => setPauseReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { AntdRegistry } from '@ant-design/nextjs-registry';
|
||||||
|
import { ConfigProvider, App } from 'antd';
|
||||||
|
import { TopHeader } from '../components/TopHeader';
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "海关平板前端系统",
|
||||||
|
description: "海关查验系统平板端原型",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<body className={`${inter.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,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Card, Row, Col, Typography, Space, Button, Tabs, Table, Image as AntImage, Empty, Spin, Flex } from 'antd';
|
||||||
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Breadcrumb } from '../../../components/Breadcrumb';
|
||||||
|
import { MockApi } from '../../../services/mockApi';
|
||||||
|
import { MachineDetail, ImageItem } from '../../../types';
|
||||||
|
import { StatusBadge } from '../../../components/StatusBadge';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function MachineDetailPage({ params }: { params: { serialNumber: string } }) {
|
||||||
|
const router = useRouter();
|
||||||
|
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 MockApi.getMachineDetail(params.serialNumber);
|
||||||
|
if (!isMounted) return;
|
||||||
|
setMachine(data);
|
||||||
|
} catch {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setMachine(null);
|
||||||
|
setErrorMessage('机器详情加载失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadMachineDetail();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [params.serialNumber]);
|
||||||
|
|
||||||
|
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 renderImageGroup = (images: ImageItem[]) => {
|
||||||
|
if (!images || images.length === 0) return <Empty description="暂无图片" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AntImage.PreviewGroup>
|
||||||
|
<Space size={[16, 16]} wrap>
|
||||||
|
{images.map((img) => (
|
||||||
|
<Flex
|
||||||
|
key={img.id}
|
||||||
|
vertical
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: 120,
|
||||||
|
gap: 4
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', aspectRatio: '4/3', overflow: 'hidden', borderRadius: 8, background: '#f0f0f0' }}>
|
||||||
|
<AntImage
|
||||||
|
src={img.url}
|
||||||
|
alt={img.name}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
preview={{
|
||||||
|
src: img.url,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Text style={{ fontSize: 12, textAlign: 'center' }}>{img.name}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, textAlign: 'center' }}>{img.createdAt}</Text>
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</AntImage.PreviewGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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">{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}
|
||||||
|
locale={{ emptyText: <Empty description="暂无查验记录" /> }}
|
||||||
|
>
|
||||||
|
<Table.Column title="查验时间" dataIndex="time" />
|
||||||
|
<Table.Column title="操作人" dataIndex="operator" />
|
||||||
|
<Table.Column title="结果" dataIndex="result" />
|
||||||
|
<Table.Column title="备注" dataIndex="remark" />
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Row, Col, Input, Button, Table, Typography, Space, Modal, Upload, Flex } from 'antd';
|
||||||
|
import { CameraOutlined, BarcodeOutlined, FileImageOutlined, SearchOutlined, BulbOutlined } from '@ant-design/icons';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Breadcrumb } from '../../components/Breadcrumb';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
export default function MachineQueryPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [serialNumber, setSerialNumber] = useState('');
|
||||||
|
const [isScanModalVisible, setIsScanModalVisible] = useState(false);
|
||||||
|
const [recentQueries, setRecentQueries] = useState<{serialNumber: string, name: string, time: string}[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load recent queries from localStorage or mock
|
||||||
|
const saved = localStorage.getItem('recent_queries');
|
||||||
|
if (saved) {
|
||||||
|
setRecentQueries(JSON.parse(saved));
|
||||||
|
} else {
|
||||||
|
setRecentQueries([
|
||||||
|
{ serialNumber: 'BG042110276', name: '打印机型号A', time: '06-19 14:30' },
|
||||||
|
{ serialNumber: 'BG042110285', name: '扫描仪型号B', time: '06-19 10:15' }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearch = (sn: string) => {
|
||||||
|
if (!sn) return;
|
||||||
|
|
||||||
|
// Save to recent queries
|
||||||
|
const newQuery = { serialNumber: sn, name: '未知设备 (模拟)', time: new Date().toLocaleString() };
|
||||||
|
const updated = [newQuery, ...recentQueries.filter(q => q.serialNumber !== sn)].slice(0, 10);
|
||||||
|
setRecentQueries(updated);
|
||||||
|
localStorage.setItem('recent_queries', JSON.stringify(updated));
|
||||||
|
|
||||||
|
router.push(`/machines/${sn}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<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(--color-primary)' }} />
|
||||||
|
<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(--color-primary)' }} />
|
||||||
|
<Title level={4} style={{ margin: 0 }}>输入序列号</Title>
|
||||||
|
<Text type="secondary">输入机器序列号精确查询机器信息</Text>
|
||||||
|
<Space.Compact style={{ width: '80%' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入序列号..."
|
||||||
|
size="large"
|
||||||
|
value={serialNumber}
|
||||||
|
onChange={(e) => setSerialNumber(e.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 }}>
|
||||||
|
<Upload.Dragger
|
||||||
|
accept="image/*"
|
||||||
|
showUploadList={false}
|
||||||
|
customRequest={({ onSuccess }) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
onSuccess?.('ok');
|
||||||
|
handleSearch('BG042110276'); // 模拟识别成功
|
||||||
|
}, 1000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon">
|
||||||
|
<FileImageOutlined style={{ fontSize: 48 }} />
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-text">拖拽或点击上传二维码照片</p>
|
||||||
|
<p className="ant-upload-hint">支持 JPG / PNG / BMP,自动识别二维码内容</p>
|
||||||
|
</Upload.Dragger>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="最近查询记录">
|
||||||
|
<Table
|
||||||
|
dataSource={recentQueries}
|
||||||
|
rowKey="serialNumber"
|
||||||
|
pagination={false}
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<Table.Column title="序列号" dataIndex="serialNumber" />
|
||||||
|
<Table.Column title="机器名称" dataIndex="name" />
|
||||||
|
<Table.Column title="查询时间" dataIndex="time" />
|
||||||
|
<Table.Column
|
||||||
|
title="操作"
|
||||||
|
render={(_, record: {serialNumber: string}) => (
|
||||||
|
<Button type="link" onClick={() => handleSearch(record.serialNumber)}>再次查看</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Table>
|
||||||
|
</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>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setIsScanModalVisible(false);
|
||||||
|
handleSearch('BG042110276');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
模拟扫码成功 (BG042110276)
|
||||||
|
</Button>
|
||||||
|
</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 style={{ position: 'absolute', top: -2, left: -2, width: 20, height: 20, borderTop: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }}></div>
|
||||||
|
<div style={{ position: 'absolute', top: -2, right: -2, width: 20, height: 20, borderTop: '4px solid #1890ff', borderRight: '4px solid #1890ff' }}></div>
|
||||||
|
<div style={{ position: 'absolute', bottom: -2, left: -2, width: 20, height: 20, borderBottom: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }}></div>
|
||||||
|
<div style={{ position: 'absolute', bottom: -2, right: -2, width: 20, height: 20, borderBottom: '4px solid #1890ff', borderRight: '4px solid #1890ff' }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16, textAlign: 'center', color: '#666666' }}>
|
||||||
|
<BulbOutlined /> 将二维码对准框内,自动识别
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Card, Row, Col, List, Button, Table, Statistic, Flex } from 'antd';
|
||||||
|
import {
|
||||||
|
FileTextOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
ScanOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
ProfileOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { MockApi } from '../services/mockApi';
|
||||||
|
import { CustomsStats, ActivityItem, CustomsDeclaration } 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('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setErrorMessage('');
|
||||||
|
const [statsData, actData, customsData] = await Promise.all([
|
||||||
|
MockApi.getCustomsStats(),
|
||||||
|
MockApi.getRecentActivities(),
|
||||||
|
MockApi.getPendingCustoms()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
setStats(statsData);
|
||||||
|
setActivities(actData);
|
||||||
|
setPendingCustoms(customsData);
|
||||||
|
} catch {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setErrorMessage('首页数据加载失败,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const inspectingCustoms = pendingCustoms.find(item => item.status === 'inspecting');
|
||||||
|
const goToInspection = () => {
|
||||||
|
if (inspectingCustoms) {
|
||||||
|
router.push(`/inspection?customsId=${encodeURIComponent(inspectingCustoms.customsId)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/customs');
|
||||||
|
};
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
title: '待查验',
|
||||||
|
value: stats?.pendingCount || 0,
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
|
suffix: '份报关单',
|
||||||
|
valueStyle: { color: '#1890ff' },
|
||||||
|
onClick: () => router.push('/customs')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '今日已放行',
|
||||||
|
value: stats?.releasedToday || 0,
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
suffix: '份报关单',
|
||||||
|
valueStyle: { color: '#52c41a' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '查验进行中',
|
||||||
|
value: stats?.inspectingCount || 0,
|
||||||
|
icon: <SyncOutlined spin />,
|
||||||
|
suffix: '个任务',
|
||||||
|
valueStyle: { color: '#faad14' },
|
||||||
|
onClick: goToInspection
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '异常',
|
||||||
|
value: stats?.abnormalCount || 0,
|
||||||
|
icon: <WarningOutlined />,
|
||||||
|
suffix: '个异常',
|
||||||
|
valueStyle: { color: '#ff4d4f' }
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
{ title: '扫码查询机器', desc: '使用平板摄像头扫描设备二维码', icon: <ScanOutlined />, onClick: () => router.push('/machines') },
|
||||||
|
{ title: '序列号查询机器', desc: '手动输入序列号查询机器全部资料', icon: <SearchOutlined />, onClick: () => router.push('/machines') },
|
||||||
|
{ title: '视频监控', desc: '查看厂房实时监控画面', icon: <VideoCameraOutlined />, onClick: () => router.push('/video') },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom: 24 }}>
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={errorMessage}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 统计卡片区域 */}
|
||||||
|
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||||
|
{statCards.map((stat, idx) => (
|
||||||
|
<Col span={6} key={idx}>
|
||||||
|
<Card hoverable={!!stat.onClick} onClick={stat.onClick}>
|
||||||
|
<Statistic
|
||||||
|
title={stat.title}
|
||||||
|
value={stat.value}
|
||||||
|
suffix={stat.suffix}
|
||||||
|
prefix={stat.icon}
|
||||||
|
valueStyle={stat.valueStyle}
|
||||||
|
loading={!stats}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 快捷操作区域 */}
|
||||||
|
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||||
|
{quickActions.map((action, idx) => (
|
||||||
|
<Col span={8} key={idx}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
onClick={action.onClick}
|
||||||
|
styles={{ body: { background: 'linear-gradient(135deg, #f6f8fc 0%, #eef2f9 100%)' } }}
|
||||||
|
>
|
||||||
|
<Flex align="center" gap={16}>
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#fff',
|
||||||
|
boxShadow: '0 4px 12px rgba(24, 144, 255, 0.1)',
|
||||||
|
color: '#1890ff',
|
||||||
|
fontSize: 24
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action.icon}
|
||||||
|
</Flex>
|
||||||
|
<Flex vertical>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 18 }}>{action.title}</div>
|
||||||
|
<div style={{ color: 'var(--color-text-secondary)', fontSize: 14 }}>{action.desc}</div>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={24}>
|
||||||
|
{/* 最近查验动态 */}
|
||||||
|
<Col span={12}>
|
||||||
|
<Card
|
||||||
|
title="最近查验动态"
|
||||||
|
extra={<Button type="link" icon={<RightOutlined />}>查看全部</Button>}
|
||||||
|
styles={{ body: { height: 'calc(100% - 57px)' } }}
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
itemLayout="horizontal"
|
||||||
|
dataSource={activities}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<List.Item>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={
|
||||||
|
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 }} />
|
||||||
|
}
|
||||||
|
title={<span style={{ fontWeight: 500 }}>{item.message}</span>}
|
||||||
|
description={item.time}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
onRow={() => ({
|
||||||
|
onClick: () => router.push('/customs'),
|
||||||
|
style: { cursor: 'pointer' }
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Table.Column title="报关单号" dataIndex="customsId" key="customsId" render={(text) => <b>{text}</b>} />
|
||||||
|
<Table.Column title="机器数量" dataIndex="machineCount" key="machineCount" render={(count) => `${count} 台`} />
|
||||||
|
<Table.Column title="状态" dataIndex="status" key="status" render={(status: string) => <StatusBadge status={status as 'pending' | 'inspecting' | 'released' | 'abnormal'} />} />
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Card, Row, Col, Button, Typography, Space, Spin, Flex, Breadcrumb as AntdBreadcrumb, Badge, Empty } from 'antd';
|
||||||
|
import { FullscreenOutlined, ReloadOutlined, CameraOutlined, ArrowLeftOutlined, CaretRightOutlined, HomeOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||||
|
import { Breadcrumb } from '../../components/Breadcrumb';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { MockApi } from '../../services/mockApi';
|
||||||
|
import { 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('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadCameras = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
const cameraList = await MockApi.getCameraList();
|
||||||
|
if (!isMounted) return;
|
||||||
|
setCameras(cameraList);
|
||||||
|
} catch {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setErrorMessage('摄像头列表加载失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCameras();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 模拟的视频播放器组件
|
||||||
|
const MockVideoPlayer = ({ camera, height, aspectRatio }: { camera: CameraInfo, height?: number | string, aspectRatio?: string }) => (
|
||||||
|
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', borderRadius: 8, background: '#141414', height, aspectRatio, boxShadow: 'inset 0 0 20px rgba(0,0,0,0.5)' }}>
|
||||||
|
{camera.status === 'online' ? (
|
||||||
|
<>
|
||||||
|
{/* 居中播放按钮 */}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
icon={<CaretRightOutlined style={{ fontSize: 40, color: 'white' }} />}
|
||||||
|
style={{ width: 80, height: 80, backgroundColor: 'rgba(255, 255, 255, 0.15)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.2)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 底部状态标识 */}
|
||||||
|
<div style={{ position: 'absolute', bottom: 16, left: 16, zIndex: 10, padding: '4px 10px', background: 'rgba(0,0,0,0.6)', borderRadius: 6, backdropFilter: 'blur(4px)' }}>
|
||||||
|
<Badge status="success" text={<Text style={{ color: 'white', fontSize: 12 }}>在线 / 实时画面模拟</Text>} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
image={<VideoCameraOutlined style={{ fontSize: 64, color: '#ff4d4f', opacity: 0.9 }} />}
|
||||||
|
imageStyle={{ height: 64, marginBottom: 16 }}
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" size={2}>
|
||||||
|
<Text type="danger" strong style={{ fontSize: 16 }}>设备离线</Text>
|
||||||
|
<Text type="secondary" style={{ color: 'rgba(255,255,255,0.45)' }}>无法连接到视频流</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// 模拟重连
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重试连接
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fullscreenCamera) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<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={(e) => { e.preventDefault(); setFullscreenCamera(null); }}>视频监控</a> },
|
||||||
|
{ title: fullscreenCamera.name }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Button type="primary" icon={<CameraOutlined />}>截图</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)' }}>
|
||||||
|
<MockVideoPlayer camera={fullscreenCamera} height="100%" />
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
|
||||||
|
<Breadcrumb />
|
||||||
|
<Space>
|
||||||
|
<Button icon={<FullscreenOutlined />}>全屏模式</Button>
|
||||||
|
<Button type="primary" icon={<CameraOutlined />}>全部截图</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
<MockVideoPlayer 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>
|
||||||
|
</Space>
|
||||||
|
{camera.status === 'online' && <Button type="link" icon={<FullscreenOutlined />} size="small">全屏观看</Button>}
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Breadcrumb as AntdBreadcrumb } from 'antd';
|
||||||
|
import { HomeOutlined } from '@ant-design/icons';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
const breadcrumbNameMap: Record<string, string> = {
|
||||||
|
'/video': '视频监控',
|
||||||
|
'/machines': '机器查询',
|
||||||
|
'/customs': '报关单管理',
|
||||||
|
'/inspection': '远程查验',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Breadcrumb: React.FC = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const pathSnippets = pathname.split('/').filter(i => i);
|
||||||
|
|
||||||
|
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>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{
|
||||||
|
key: 'home',
|
||||||
|
title: <Link href="/"><HomeOutlined /> 首页</Link>,
|
||||||
|
},
|
||||||
|
...extraBreadcrumbItems,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<AntdBreadcrumb items={breadcrumbItems} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Tag } from 'antd';
|
||||||
|
import {
|
||||||
|
ClockCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
MinusCircleOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
QuestionCircleOutlined
|
||||||
|
} 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 getStatusConfig = () => {
|
||||||
|
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' }} /> };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getStatusConfig();
|
||||||
|
|
||||||
|
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,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Layout, Menu, Badge, Avatar, Button, Dropdown, Space, Typography, theme } from 'antd';
|
||||||
|
import {
|
||||||
|
BellOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
ScanOutlined,
|
||||||
|
SafetyCertificateOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useAppStore } from '../store/useAppStore';
|
||||||
|
|
||||||
|
const { Header } = Layout;
|
||||||
|
|
||||||
|
export const TopHeader: React.FC = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, notifications } = useAppStore();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const unreadCount = notifications.filter(n => !n.read).length;
|
||||||
|
|
||||||
|
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 handleMenuClick = (e: { key: string }) => {
|
||||||
|
router.push(e.key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const notificationMenu = {
|
||||||
|
items: notifications.map(n => ({
|
||||||
|
key: n.id,
|
||||||
|
label: (
|
||||||
|
<div style={{ width: 250, padding: '4px 0', whiteSpace: 'normal' }}>
|
||||||
|
<Typography.Text strong={!n.read} type={n.read ? 'secondary' : undefined} style={{ display: 'block' }}>
|
||||||
|
{n.title}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Paragraph
|
||||||
|
style={{ marginTop: 4, marginBottom: 0, color: token.colorTextSecondary, fontSize: 12, lineHeight: 1.5 }}
|
||||||
|
>
|
||||||
|
{n.message}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Typography.Text style={{ marginTop: 4, display: 'block', color: token.colorTextTertiary, fontSize: 10 }}>
|
||||||
|
{n.time}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 }} />
|
||||||
|
<Typography.Title level={4} style={{ margin: 0, color: token.colorPrimary, whiteSpace: 'nowrap' }}>
|
||||||
|
海关智慧查验平台
|
||||||
|
</Typography.Title>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
mode="horizontal"
|
||||||
|
selectedKeys={[pathname === '/' ? '/' : `/${pathname.split('/')[1]}`]}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
style={{ flex: 1, minWidth: 0, borderBottom: 'none', lineHeight: '62px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space size="large" style={{ marginLeft: 24, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<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, transition: 'background 0.3s' }} className="user-entry">
|
||||||
|
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: token.colorPrimary }} />
|
||||||
|
<Typography.Text style={{ fontSize: 14 }}>{user?.name}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
CustomsStats,
|
||||||
|
ActivityItem,
|
||||||
|
CustomsDeclaration,
|
||||||
|
CameraInfo,
|
||||||
|
MachineDetail
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const customsDeclarations: CustomsDeclaration[] = [
|
||||||
|
{
|
||||||
|
id: '1', customsId: 'CD20260619001', status: 'pending', machineCount: 5, createdAt: '2026-06-19 14:00',
|
||||||
|
items: [
|
||||||
|
{ inventoryCode: 'P001', inventoryName: '打印机型号A', spec: 'A4', quantify: 3, inspected: 0 },
|
||||||
|
{ inventoryCode: 'S001', inventoryName: '扫描仪型号B', spec: 'A3', quantify: 2, inspected: 0 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2', customsId: 'CD20260619003', status: 'inspecting', machineCount: 8, createdAt: '2026-06-19 13:00',
|
||||||
|
items: [
|
||||||
|
{ inventoryCode: 'P002', inventoryName: '打印机型号C', spec: 'A4', quantify: 8, inspected: 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3', customsId: 'CD20260618004', status: 'released', machineCount: 10, createdAt: '2026-06-18 10:00',
|
||||||
|
items: [
|
||||||
|
{ inventoryCode: 'M001', inventoryName: '显示器型号A', spec: '27寸', quantify: 10, inspected: 10 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 模拟真实接口耗时,便于原型验证加载和错误状态。
|
||||||
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export const MockApi = {
|
||||||
|
getCustomsStats: async (): Promise<CustomsStats> => {
|
||||||
|
await delay(300);
|
||||||
|
return {
|
||||||
|
pendingCount: 12,
|
||||||
|
releasedToday: 28,
|
||||||
|
inspectingCount: 1,
|
||||||
|
abnormalCount: 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getRecentActivities: async (): Promise<ActivityItem[]> => {
|
||||||
|
await delay(300);
|
||||||
|
return [
|
||||||
|
{ id: '1', time: '14:30', type: 'start', message: 'CD20260619003 开始查验' },
|
||||||
|
{ id: '2', time: '14:15', type: 'success', message: 'CD20260619002 查验完成/放行' },
|
||||||
|
{ id: '3', time: '14:00', type: 'info', message: '新增报关单 CD20260618005' },
|
||||||
|
{ id: '4', time: '13:45', type: 'warning', message: 'CD20260618001 查验异常暂停' },
|
||||||
|
{ id: '5', time: '13:20', type: 'success', message: 'CD20260618004 查验完成/放行' },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
getPendingCustoms: async (): Promise<CustomsDeclaration[]> => {
|
||||||
|
await delay(400);
|
||||||
|
return [
|
||||||
|
{ id: '1', customsId: 'CD20260619001', status: 'pending', machineCount: 5, createdAt: '2026-06-19 14:00', items: [] },
|
||||||
|
{ id: '2', customsId: 'CD20260619003', status: 'inspecting', machineCount: 8, createdAt: '2026-06-19 13:00', items: [] },
|
||||||
|
{ id: '3', customsId: 'CD20260619004', status: 'pending', machineCount: 3, createdAt: '2026-06-19 12:00', items: [] },
|
||||||
|
{ id: '4', customsId: 'CD20260618005', status: 'pending', machineCount: 6, createdAt: '2026-06-18 16:00', items: [] },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
getCustomsList: async (): Promise<CustomsDeclaration[]> => {
|
||||||
|
await delay(500);
|
||||||
|
return customsDeclarations;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCustomsById: async (customsId: string): Promise<CustomsDeclaration | null> => {
|
||||||
|
await delay(300);
|
||||||
|
return customsDeclarations.find(item => item.customsId === customsId) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCameraList: async (): Promise<CameraInfo[]> => {
|
||||||
|
await delay(300);
|
||||||
|
return [
|
||||||
|
{ id: '1', name: '摄像头 1', location: '入料口', streamUrl: '', status: 'online' },
|
||||||
|
{ id: '2', name: '摄像头 2', location: '生产线 A', streamUrl: '', status: 'online' },
|
||||||
|
{ id: '3', name: '摄像头 3', location: '生产线 B', streamUrl: '', status: 'online' },
|
||||||
|
{ id: '4', name: '摄像头 4', location: '出库区', streamUrl: '', status: 'offline' },
|
||||||
|
{ id: '5', name: '摄像头 5', location: 'AGV 作业区', streamUrl: '', status: 'online' },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
getMachineDetail: async (serialNumber: string): Promise<MachineDetail> => {
|
||||||
|
await delay(400);
|
||||||
|
return {
|
||||||
|
serialNumber: serialNumber,
|
||||||
|
modelName: '打印机型号A',
|
||||||
|
modelId: 'MDL-A4-001',
|
||||||
|
customsId: 'CD20260619001',
|
||||||
|
customsName: '某科技公司进口设备批次',
|
||||||
|
status: 'pending',
|
||||||
|
specs: {
|
||||||
|
'尺寸': '480×320×260mm',
|
||||||
|
'重量': '12.5kg',
|
||||||
|
'产地': '中国 / 深圳',
|
||||||
|
'入库日期': '2026-06-15'
|
||||||
|
},
|
||||||
|
createdAt: '2026-06-15 10:00',
|
||||||
|
images: {
|
||||||
|
incomingInspection: [
|
||||||
|
{ id: 'i1', url: 'https://picsum.photos/800/600?1', thumbnailUrl: 'https://picsum.photos/200/150?1', name: '来料检验单 第1页', createdAt: '2026-06-10' },
|
||||||
|
{ id: 'i2', url: 'https://picsum.photos/800/600?2', thumbnailUrl: 'https://picsum.photos/200/150?2', name: '来料检验单 第2页', createdAt: '2026-06-10' }
|
||||||
|
],
|
||||||
|
startupTestSample: [
|
||||||
|
{ id: 'i3', url: 'https://picsum.photos/800/600?3', thumbnailUrl: 'https://picsum.photos/200/150?3', name: '开机测试样张', createdAt: '2026-06-12' }
|
||||||
|
],
|
||||||
|
productionOrder: [
|
||||||
|
{ id: 'i4', url: 'https://picsum.photos/800/600?4', thumbnailUrl: 'https://picsum.photos/200/150?4', name: '生产加工单', createdAt: '2026-06-12' }
|
||||||
|
],
|
||||||
|
robotInspection: [
|
||||||
|
{ id: 'i5', url: 'https://picsum.photos/800/600?5', thumbnailUrl: 'https://picsum.photos/200/150?5', name: '正面照', createdAt: '2026-06-19' },
|
||||||
|
{ id: 'i6', url: 'https://picsum.photos/800/600?6', thumbnailUrl: 'https://picsum.photos/200/150?6', name: '背面照', createdAt: '2026-06-19' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
inspectionRecords: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { User, Notification, CustomsDeclaration, InspectionState } from '../types';
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
user: User | null;
|
||||||
|
notifications: Notification[];
|
||||||
|
selectedCustoms: CustomsDeclaration | null;
|
||||||
|
inspection: InspectionState | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
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: '14:30', read: false },
|
||||||
|
{ id: '2', title: '查验完成', message: '报关单 CD20260619002 查验完成', time: '14:15', read: false },
|
||||||
|
{ id: '3', title: '系统通知', message: '新增 5 份待查验报关单', time: '14:00', read: true }
|
||||||
|
],
|
||||||
|
selectedCustoms: null,
|
||||||
|
inspection: null,
|
||||||
|
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
|
||||||
|
addNotification: (notification) => set((state) => ({
|
||||||
|
notifications: [notification, ...state.notifications]
|
||||||
|
})),
|
||||||
|
|
||||||
|
markNotificationRead: (id) => set((state) => ({
|
||||||
|
notifications: state.notifications.map(n =>
|
||||||
|
n.id === id ? { ...n, read: true } : n
|
||||||
|
)
|
||||||
|
})),
|
||||||
|
|
||||||
|
setSelectedCustoms: (selectedCustoms) => set({ selectedCustoms }),
|
||||||
|
|
||||||
|
setInspection: (inspection) => set({ inspection }),
|
||||||
|
|
||||||
|
updateInspectionStatus: (status) => set((state) => ({
|
||||||
|
inspection: state.inspection ? { ...state.inspection, status } : null
|
||||||
|
})),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
export interface User {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
time: string;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MachineDetail {
|
||||||
|
serialNumber: string;
|
||||||
|
modelName: string;
|
||||||
|
modelId: string;
|
||||||
|
customsId: string;
|
||||||
|
customsName: string;
|
||||||
|
status: 'pending' | 'inspecting' | 'released' | 'abnormal';
|
||||||
|
specs: Record<string, string>;
|
||||||
|
createdAt: string;
|
||||||
|
images: {
|
||||||
|
incomingInspection: ImageItem[];
|
||||||
|
startupTestSample: ImageItem[];
|
||||||
|
productionOrder: ImageItem[];
|
||||||
|
robotInspection: ImageItem[];
|
||||||
|
};
|
||||||
|
inspectionRecords: InspectionRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageItem {
|
||||||
|
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 CustomsStats {
|
||||||
|
pendingCount: number;
|
||||||
|
releasedToday: number;
|
||||||
|
inspectingCount: number;
|
||||||
|
abnormalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CameraInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
streamUrl: string;
|
||||||
|
status: 'online' | 'offline';
|
||||||
|
snapshot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomsDeclaration {
|
||||||
|
id: string;
|
||||||
|
customsId: string;
|
||||||
|
status: 'pending' | 'released' | 'abnormal' | 'inspecting';
|
||||||
|
machineCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
items: InspectionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionState {
|
||||||
|
customsId: string;
|
||||||
|
customsName: string;
|
||||||
|
status: 'idle' | 'running' | 'paused' | 'completed';
|
||||||
|
items: InspectionItem[];
|
||||||
|
startedAt: number;
|
||||||
|
currentMachine?: {
|
||||||
|
machineId: string;
|
||||||
|
serialNumber: string;
|
||||||
|
step: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionItem {
|
||||||
|
inventoryCode: string;
|
||||||
|
inventoryName: string;
|
||||||
|
spec: string;
|
||||||
|
quantify: number;
|
||||||
|
inspected: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityItem {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
type: 'start' | 'success' | 'info' | 'warning';
|
||||||
|
message: 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