1429442dbd
- 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>
145 lines
5.1 KiB
TypeScript
145 lines
5.1 KiB
TypeScript
'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>
|
|
</>
|
|
);
|
|
};
|