feat: 完善采购申请流程 - 添加审批、执行、列表筛选排序功能

This commit is contained in:
System Administrator
2026-03-28 00:34:32 +07:00
parent 841f19e3f8
commit d437580500
306 changed files with 42669 additions and 27143 deletions
@@ -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
@@ -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