feat: 完善采购申请流程 - 添加审批、执行、列表筛选排序功能
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Table, Button, Modal, Form, Input, Switch, message, Space, Tag, Popconfirm } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, PhoneOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Contact {
|
||||
id: number
|
||||
name: string
|
||||
name_zh?: string
|
||||
position?: string
|
||||
department?: string
|
||||
is_primary: boolean
|
||||
phone?: string
|
||||
mobile?: string
|
||||
wechat?: string
|
||||
whatsapp?: string
|
||||
line_id?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
interface ContactManagerProps {
|
||||
companyType: 'customer' | 'supplier' | 'subcontractor'
|
||||
companyId: number
|
||||
companyName: string
|
||||
onContactsUpdated?: () => void
|
||||
}
|
||||
|
||||
const ContactManager: React.FC<ContactManagerProps> = ({
|
||||
companyType,
|
||||
companyId,
|
||||
companyName,
|
||||
onContactsUpdated
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingContact, setEditingContact] = useState<Contact | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const fetchContacts = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/${companyType}s/${companyId}/contacts`)
|
||||
const data = await response.json()
|
||||
setContacts(data.contacts || [])
|
||||
} catch (error) {
|
||||
console.error('获取联系人失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (companyId) {
|
||||
fetchContacts()
|
||||
}
|
||||
}, [companyId, companyType])
|
||||
|
||||
const columns: ColumnsType<Contact> = [
|
||||
{
|
||||
title: t('contact.name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold' }}>{text}</div>
|
||||
{record.position && <div style={{ fontSize: '12px', color: '#666' }}>{record.position}</div>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('contact.contactInfo'),
|
||||
key: 'contact',
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
{record.mobile && <div><PhoneOutlined style={{ marginRight: 4 }} />{record.mobile}</div>}
|
||||
{record.phone && <div style={{ fontSize: '12px', color: '#666' }}>电话: {record.phone}</div>}
|
||||
{record.wechat && <div style={{ fontSize: '12px', color: '#666' }}>微信: {record.wechat}</div>}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('contact.status'),
|
||||
dataIndex: 'is_primary',
|
||||
key: 'is_primary',
|
||||
width: 100,
|
||||
render: (isPrimary) => (
|
||||
<Tag color={isPrimary ? 'green' : 'blue'}>
|
||||
{isPrimary ? t('contact.primary') : t('contact.secondary')}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('common.actions'),
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
|
||||
<Popconfirm title={t('common.confirmDelete')} onConfirm={() => handleDelete(record.id)} okText={t('common.yes')} cancelText={t('common.no')}>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
const url = editingContact ? `/api/${companyType}s/${companyId}/contacts/${editingContact.id}` : `/api/${companyType}s/${companyId}/contacts`
|
||||
const method = editingContact ? 'PUT' : 'POST'
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...values, company_type: companyType, company_id: companyId })
|
||||
})
|
||||
if (response.ok) {
|
||||
message.success(editingContact ? t('common.updateSuccess') : t('common.createSuccess'))
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
setEditingContact(null)
|
||||
fetchContacts()
|
||||
onContactsUpdated?.()
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(t('common.operationFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (contact: Contact) => {
|
||||
setEditingContact(contact)
|
||||
form.setFieldsValue(contact)
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (contactId: number) => {
|
||||
try {
|
||||
await fetch(`/api/${companyType}s/${companyId}/contacts/${contactId}`, { method: 'DELETE' })
|
||||
message.success(t('common.deleteSuccess'))
|
||||
fetchContacts()
|
||||
onContactsUpdated?.()
|
||||
} catch (error) {
|
||||
message.error(t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h3>{t('contact.management')}</h3>
|
||||
<p style={{ color: '#666' }}>{companyName} - {t(`company.${companyType}`)}</p>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setEditingContact(null); form.resetFields(); setModalVisible(true) }}>
|
||||
{t('contact.addContact')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table columns={columns} dataSource={contacts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} size="middle" />
|
||||
|
||||
<Modal
|
||||
title={editingContact ? t('contact.editContact') : t('contact.addContact')}
|
||||
open={modalVisible}
|
||||
onCancel={() => { setModalVisible(false); form.resetFields(); setEditingContact(null) }}
|
||||
onOk={() => form.submit()}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit} initialValues={{ is_primary: false }}>
|
||||
<Form.Item name="name" label={t('contact.name')} rules={[{ required: true }]}>
|
||||
<Input placeholder={t('contact.namePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="position" label={t('contact.position')}>
|
||||
<Input placeholder={t('contact.positionPlaceholder')} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<Form.Item name="phone" label={t('contact.phone')}>
|
||||
<Input placeholder={t('contact.phonePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="mobile" label={t('contact.mobile')}>
|
||||
<Input placeholder={t('contact.mobilePlaceholder')} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<Form.Item name="wechat" label={t('contact.wechat')}>
|
||||
<Input placeholder={t('contact.wechatPlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="line_id" label={t('contact.lineId')}>
|
||||
<Input placeholder={t('contact.lineIdPlaceholder')} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="whatsapp" label="WhatsApp">
|
||||
<Input placeholder="输入WhatsApp号码" />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_primary" label={t('contact.primaryContact')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label={t('contact.notes')}>
|
||||
<Input.TextArea rows={3} placeholder={t('contact.notesPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContactManager
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Upload, Modal, Image, Spin, Progress, message } from 'antd';
|
||||
import { PlusOutlined, FileOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
|
||||
|
||||
interface FileUploadProps {
|
||||
value?: string[];
|
||||
onChange?: (urls: string[]) => void;
|
||||
maxCount?: number;
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
// 支持的图片格式
|
||||
const imageFormats = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
|
||||
const officeFormats = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
|
||||
const isImage = (url: string) => {
|
||||
const ext = url.split('.').pop()?.toLowerCase();
|
||||
return imageFormats.includes(ext || '');
|
||||
};
|
||||
|
||||
const isOfficeFile = (url: string) => {
|
||||
const ext = url.split('.').pop()?.toLowerCase();
|
||||
return officeFormats.includes(ext || '');
|
||||
};
|
||||
|
||||
const getOfficePreviewUrl = (url: string) => {
|
||||
// 使用微软的Office 365在线预览服务
|
||||
return `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(url)}`;
|
||||
};
|
||||
|
||||
const FileUpload: React.FC<FileUploadProps> = ({
|
||||
value = [],
|
||||
onChange,
|
||||
maxCount = 9,
|
||||
accept = 'image/*'
|
||||
}) => {
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewImage, setPreviewImage] = useState('');
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// 当 value 变化时,更新 fileList
|
||||
useEffect(() => {
|
||||
// 只有当 value 是数组时才更新 fileList
|
||||
// 这样可以避免在上传过程中被重置
|
||||
if (Array.isArray(value)) {
|
||||
const newFileList = value.map((url, index) => ({
|
||||
uid: `-${index}`,
|
||||
name: url.split('/').pop() || `file-${index}`,
|
||||
status: 'done',
|
||||
url,
|
||||
thumbUrl: isImage(url) ? url : undefined
|
||||
}));
|
||||
|
||||
setFileList(newFileList);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handlePreview = async (file: UploadFile) => {
|
||||
const url = file.url || '';
|
||||
if (isImage(url)) {
|
||||
setPreviewImage(url);
|
||||
setPreviewOpen(true);
|
||||
} else if (isOfficeFile(url)) {
|
||||
// Office文件,使用微软的在线预览服务
|
||||
const previewUrl = getOfficePreviewUrl(url);
|
||||
window.open(previewUrl, '_blank');
|
||||
} else {
|
||||
// 其他文件,新窗口打开
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange: UploadProps['onChange'] = (info) => {
|
||||
const { fileList } = info;
|
||||
setFileList(fileList);
|
||||
|
||||
// 只有当文件状态发生变化时才调用 onChange
|
||||
// 避免在初始化时触发无限循环
|
||||
if (info.file.status === 'done' || info.file.status === 'removed') {
|
||||
// 提取已上传成功的URL
|
||||
const urls = fileList
|
||||
.filter(file => file.status === 'done')
|
||||
.map(file => {
|
||||
// 处理不同格式的文件对象
|
||||
if (file.url) {
|
||||
return file.url;
|
||||
} else if (file.response && file.response.url) {
|
||||
return file.response.url;
|
||||
} else if (file.response && typeof file.response === 'string') {
|
||||
return file.response;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(url => url); // 过滤空字符串
|
||||
|
||||
onChange?.(urls);
|
||||
}
|
||||
};
|
||||
|
||||
const customRequest = async (options: any) => {
|
||||
const { file, onSuccess, onError, onProgress } = options;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
console.log('开始上传文件:', file.name);
|
||||
const res = await fetch('/api/upload/single', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
console.log('上传响应状态:', res.status);
|
||||
const data = await res.json();
|
||||
|
||||
console.log('上传响应数据:', data);
|
||||
|
||||
if (data.success) {
|
||||
onProgress({ percent: 100 });
|
||||
// 传递包含url属性的对象,这是Ant Design Upload组件在customRequest中期望的格式
|
||||
onSuccess({ url: data.data.url }, file);
|
||||
message.success('上传成功');
|
||||
} else {
|
||||
onError(new Error(data.error));
|
||||
message.error(data.error || '上传失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传错误:', error);
|
||||
onError(error);
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadButton = (
|
||||
<div>
|
||||
<PlusOutlined />
|
||||
<div style={{ marginTop: 8 }}>上传</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
fileList={fileList}
|
||||
onPreview={handlePreview}
|
||||
onChange={handleChange}
|
||||
customRequest={customRequest}
|
||||
accept={accept}
|
||||
maxCount={maxCount}
|
||||
multiple
|
||||
>
|
||||
{fileList.length >= maxCount ? null : uploadButton}
|
||||
</Upload>
|
||||
|
||||
{/* 图片预览弹窗 */}
|
||||
<Modal
|
||||
open={previewOpen}
|
||||
title="图片预览"
|
||||
footer={null}
|
||||
onCancel={() => setPreviewOpen(false)}
|
||||
width="80%"
|
||||
centered
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
style={{ maxWidth: '100%', maxHeight: '80vh' }}
|
||||
preview={false}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{uploading && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Spin size="small" /> 上传中...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUpload;
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react'
|
||||
import { Space, Typography } from 'antd'
|
||||
import { ThunderboltOutlined } from '@ant-design/icons'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
interface CompanyLogoProps {
|
||||
showText?: boolean
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
const CompanyLogo: React.FC<CompanyLogoProps> = ({ showText = true, size = 'medium' }) => {
|
||||
const sizeMap = {
|
||||
small: { fontSize: 14, iconSize: 20 },
|
||||
medium: { fontSize: 16, iconSize: 28 },
|
||||
large: { fontSize: 20, iconSize: 36 }
|
||||
}
|
||||
|
||||
const { fontSize, iconSize } = sizeMap[size]
|
||||
|
||||
return (
|
||||
<Space align="center" style={{ cursor: 'pointer' }}>
|
||||
{/* 图标 */}
|
||||
<ThunderboltOutlined
|
||||
style={{
|
||||
fontSize: iconSize,
|
||||
color: '#1890ff',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 公司名称 */}
|
||||
{showText && (
|
||||
<div>
|
||||
<Title
|
||||
level={5}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: fontSize,
|
||||
color: '#262626',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
轻远电力老挝ERP
|
||||
</Title>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: fontSize - 4,
|
||||
display: 'block',
|
||||
marginTop: -2
|
||||
}}
|
||||
>
|
||||
Qingyuan Power Laos
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
export default CompanyLogo
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { Select, Space } from 'antd'
|
||||
import { GlobalOutlined } from '@ant-design/icons'
|
||||
import { useLanguageStore } from '../../store/languageStore'
|
||||
import { languages } from '../../locales'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
size?: 'small' | 'middle' | 'large'
|
||||
showIcon?: boolean
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
size = 'middle',
|
||||
showIcon = true,
|
||||
style
|
||||
}) => {
|
||||
const { currentLanguage, setLanguage } = useLanguageStore()
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={currentLanguage}
|
||||
onChange={setLanguage}
|
||||
size={size}
|
||||
style={{ minWidth: 140, ...style }}
|
||||
suffixIcon={showIcon ? <GlobalOutlined /> : undefined}
|
||||
>
|
||||
{languages.map(lang => (
|
||||
<Option key={lang.code} value={lang.code}>
|
||||
<Space size={4}>
|
||||
<span>{lang.flag}</span>
|
||||
<span>{lang.nativeName}</span>
|
||||
</Space>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export default LanguageSelector
|
||||
@@ -0,0 +1,444 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
Layout,
|
||||
Menu,
|
||||
Button,
|
||||
Avatar,
|
||||
Dropdown,
|
||||
Typography,
|
||||
Space,
|
||||
Badge,
|
||||
Drawer,
|
||||
Modal,
|
||||
theme
|
||||
} from 'antd'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
ProjectOutlined,
|
||||
DollarOutlined,
|
||||
FileTextOutlined,
|
||||
BarChartOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
BellOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
CalculatorOutlined,
|
||||
ToolOutlined,
|
||||
WalletOutlined,
|
||||
MoneyCollectOutlined,
|
||||
AuditOutlined,
|
||||
FileSearchOutlined,
|
||||
ShoppingCartOutlined,
|
||||
TeamOutlined,
|
||||
ShopOutlined,
|
||||
SolutionOutlined,
|
||||
HomeOutlined,
|
||||
SafetyOutlined,
|
||||
FileDoneOutlined,
|
||||
AppstoreOutlined,
|
||||
CheckCircleOutlined,
|
||||
InboxOutlined,
|
||||
DollarCircleOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useLanguageStore } from '../../store/languageStore'
|
||||
import CompanyLogo from '../common/CompanyLogo'
|
||||
import LanguageSelector from '../common/LanguageSelector'
|
||||
|
||||
const { Header, Sider, Content } = Layout
|
||||
const { Text } = Typography
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [mobileMenuVisible, setMobileMenuVisible] = useState(false)
|
||||
const [settingsVisible, setSettingsVisible] = useState(false)
|
||||
const { user, logout } = useAuthStore()
|
||||
const { t } = useLanguageStore()
|
||||
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
} = theme.useToken()
|
||||
|
||||
// 检测屏幕尺寸
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const mobile = window.innerWidth <= 768
|
||||
setIsMobile(mobile)
|
||||
if (mobile) {
|
||||
setCollapsed(true)
|
||||
}
|
||||
}
|
||||
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
// 完整菜单项
|
||||
const menuItems = [
|
||||
// 根据用户角色生成菜单项
|
||||
{
|
||||
key: '/dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '工作台'
|
||||
},
|
||||
{
|
||||
key: '/projects',
|
||||
icon: <ProjectOutlined />,
|
||||
label: '项目管理'
|
||||
},
|
||||
{
|
||||
key: '/budget-projects',
|
||||
icon: <CalculatorOutlined />,
|
||||
label: '预算报价'
|
||||
},
|
||||
{
|
||||
key: '/construction',
|
||||
icon: <ToolOutlined />,
|
||||
label: '施工管理'
|
||||
},
|
||||
{
|
||||
key: 'approval',
|
||||
icon: <SolutionOutlined />,
|
||||
label: '审批管理',
|
||||
children: [
|
||||
{
|
||||
key: '/approval',
|
||||
icon: <CheckCircleOutlined />,
|
||||
label: '待审批'
|
||||
},
|
||||
{
|
||||
key: '/execution',
|
||||
icon: <DollarOutlined />,
|
||||
label: '待执行'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'finance-docs',
|
||||
icon: <FileDoneOutlined />,
|
||||
label: '财务申请',
|
||||
children: [
|
||||
{
|
||||
key: '/advances',
|
||||
icon: <WalletOutlined />,
|
||||
label: '预支申请'
|
||||
},
|
||||
{
|
||||
key: '/reimbursements',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '报销申请'
|
||||
},
|
||||
{
|
||||
key: '/payment-requests',
|
||||
icon: <MoneyCollectOutlined />,
|
||||
label: '付款申请'
|
||||
},
|
||||
{
|
||||
key: '/verification',
|
||||
icon: <AuditOutlined />,
|
||||
label: '核销申请'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'finance-group',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '财务管理',
|
||||
children: [
|
||||
{
|
||||
key: '/finance',
|
||||
label: '财务概览'
|
||||
},
|
||||
{
|
||||
key: '/exchange-rates',
|
||||
icon: <DollarOutlined />,
|
||||
label: '汇率管理'
|
||||
},
|
||||
{
|
||||
key: '/project-cost',
|
||||
icon: <DollarCircleOutlined />,
|
||||
label: '项目成本'
|
||||
},
|
||||
...(user?.role === 'admin' || user?.department === '财务部' ? [
|
||||
{
|
||||
key: '/advances/verification-status',
|
||||
icon: <AuditOutlined />,
|
||||
label: '预支核销状态'
|
||||
}
|
||||
] : [])
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/reports',
|
||||
icon: <FileSearchOutlined />,
|
||||
label: '报表分析'
|
||||
},
|
||||
{
|
||||
key: 'procurement',
|
||||
icon: <ShoppingCartOutlined />,
|
||||
label: '采购管理',
|
||||
children: [
|
||||
{
|
||||
key: '/products',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: '商品管理'
|
||||
},
|
||||
{
|
||||
key: '/purchase-requests',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '采购申请'
|
||||
},
|
||||
{
|
||||
key: '/inventory',
|
||||
icon: <InboxOutlined />,
|
||||
label: '库存管理'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'partners',
|
||||
icon: <TeamOutlined />,
|
||||
label: '合作伙伴',
|
||||
children: [
|
||||
{
|
||||
key: '/suppliers',
|
||||
icon: <ShopOutlined />,
|
||||
label: '供应商管理'
|
||||
},
|
||||
{
|
||||
key: '/subcontractors',
|
||||
icon: <SolutionOutlined />,
|
||||
label: '分包商管理'
|
||||
},
|
||||
{
|
||||
key: '/customers',
|
||||
icon: <HomeOutlined />,
|
||||
label: '客户管理'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// 用户下拉菜单
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人信息'
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统设置'
|
||||
},
|
||||
{
|
||||
type: 'divider' as const
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录'
|
||||
}
|
||||
]
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
if (key === 'logout') {
|
||||
logout()
|
||||
navigate('/login')
|
||||
} else if (key === 'settings') {
|
||||
setSettingsVisible(true)
|
||||
} else if (key.startsWith('/')) {
|
||||
navigate(key)
|
||||
if (isMobile) {
|
||||
setMobileMenuVisible(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前选中的菜单项
|
||||
const getSelectedKey = () => {
|
||||
return location.pathname
|
||||
}
|
||||
|
||||
// 获取当前展开的菜单项
|
||||
const getOpenKeys = () => {
|
||||
const path = location.pathname
|
||||
if (path.startsWith('/suppliers') ||
|
||||
path.startsWith('/subcontractors') ||
|
||||
path.startsWith('/customers')) {
|
||||
return ['partners']
|
||||
}
|
||||
if (path.startsWith('/advances') ||
|
||||
path.startsWith('/reimbursements') ||
|
||||
path.startsWith('/payment-requests') ||
|
||||
path.startsWith('/verification')) {
|
||||
return ['finance-docs']
|
||||
}
|
||||
if (path.startsWith('/approval') || path.startsWith('/execution')) {
|
||||
return ['approval']
|
||||
}
|
||||
if (path.startsWith('/products') ||
|
||||
path.startsWith('/purchase-requests') ||
|
||||
path.startsWith('/inventory')) {
|
||||
return ['procurement']
|
||||
}
|
||||
if (path.startsWith('/project-cost')) {
|
||||
return ['finance-group']
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
{/* 桌面端侧边栏 */}
|
||||
{!isMobile && (
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
background: colorBgContainer,
|
||||
borderRight: '1px solid #f0f0f0'
|
||||
}}
|
||||
width={220}
|
||||
collapsedWidth={80}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div style={{
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
padding: collapsed ? 0 : '0 20px',
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<CompanyLogo collapsed={collapsed} />
|
||||
</div>
|
||||
|
||||
{/* 菜单 */}
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[getSelectedKey()]}
|
||||
defaultOpenKeys={getOpenKeys()}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{ borderRight: 0 }}
|
||||
/>
|
||||
|
||||
{/* 折叠按钮 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
padding: 16,
|
||||
borderTop: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{!collapsed && '收起菜单'}
|
||||
</Button>
|
||||
</div>
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
{/* 移动端抽屉菜单 */}
|
||||
{isMobile && (
|
||||
<Drawer
|
||||
placement="left"
|
||||
onClose={() => setMobileMenuVisible(false)}
|
||||
open={mobileMenuVisible}
|
||||
width={280}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<div style={{ height: 64, padding: '0 20px', display: 'flex', alignItems: 'center', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<CompanyLogo collapsed={false} />
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[getSelectedKey()]}
|
||||
defaultOpenKeys={getOpenKeys()}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{ borderRight: 0 }}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
<Layout style={{ marginLeft: isMobile ? 0 : (collapsed ? 80 : 220), transition: 'margin-left 0.2s' }}>
|
||||
{/* 顶部导航 */}
|
||||
<Header style={{
|
||||
padding: '0 24px',
|
||||
background: colorBgContainer,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
{/* 移动端菜单按钮 */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuFoldOutlined />}
|
||||
onClick={() => setMobileMenuVisible(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<Space size="middle">
|
||||
<LanguageSelector />
|
||||
|
||||
<Dropdown menu={{ items: userMenuItems, onClick: handleMenuClick }} placement="bottomRight">
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} />
|
||||
{!isMobile && <Text>{user?.name || user?.username || '用户'}</Text>}
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<Content style={{
|
||||
margin: 0,
|
||||
minHeight: 280,
|
||||
background: '#f5f5f5'
|
||||
}}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
{/* 设置弹窗 */}
|
||||
<Modal
|
||||
title="系统设置"
|
||||
open={settingsVisible}
|
||||
onCancel={() => setSettingsVisible(false)}
|
||||
footer={null}
|
||||
>
|
||||
<p>系统设置功能开发中...</p>
|
||||
</Modal>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default MainLayout
|
||||
Reference in New Issue
Block a user