Files
yunhaifinance/frontend/src/pages/CustomersPage.tsx
T
a273825743 706dcc24eb 备份:PWA配置前的完整版本
包含所有最新开发的功能和修复,作为PWA正确配置前的备份点。
2026-06-13 12:44:48 +08:00

385 lines
16 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Table, Button, Modal, Form, Input, message, Space, Tag, Card, Row, Col, Statistic, Image } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, HomeOutlined, BankOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import FileUpload from '../components/FileUpload'
import useFormDraft from '../hooks/useFormDraft'
import { useLanguageStore } from '../store/languageStore'
interface Contact {
name: string
position: string
phone: string
is_primary?: boolean
}
interface PaymentInfo {
account_name: string
bank_account: string
bank_name: string
qr_code?: string
is_primary: boolean
}
interface Customer {
id: number
code: string
name: string
address: string
contacts: Contact[]
payment_infos: PaymentInfo[]
remark: string
total_contract_amount: number
total_received: number
total_receivable: number
created_at: string
}
const CustomerPage: React.FC = () => {
const navigate = useNavigate()
const { t, currentLanguage } = useLanguageStore()
const [customers, setCustomers] = useState<Customer[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null)
const [searchText, setSearchText] = useState('')
const [form] = Form.useForm()
// 表单草稿保护
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
form,
storageKey: 'customer_create',
})
const handleFormChange = useCallback(() => {
saveDraft()
}, [saveDraft])
const fetchCustomers = async () => {
setLoading(true)
try {
const response = await fetch('/api/customers')
const data = await response.json()
if (data.success) setCustomers(data.data || [])
} catch (error) {
message.error(t('customer.getListFailed'))
} finally {
setLoading(false)
}
}
useEffect(() => { fetchCustomers() }, [])
const stats = {
total: customers.length,
totalContract: customers.reduce((sum, c) => sum + (c.total_contract_amount || 0), 0),
totalReceivable: customers.reduce((sum, c) => sum + (c.total_receivable || 0), 0)
}
const getPrimaryContact = (contacts: Contact[]) => {
const primary = contacts?.find(c => c.is_primary)
return primary?.name || '-'
}
const getPrimaryPaymentInfo = (paymentInfos: PaymentInfo[]) => {
const primary = paymentInfos?.find(p => p.is_primary)
return primary
}
const columns: ColumnsType<Customer> = [
{
title: t('customer.name'), dataIndex: 'name', key: 'name',
render: (text, record) => (
<Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/customers/${record.id}`)}>{text}</Button>
)
},
{ title: t('customer.address'), dataIndex: 'address', key: 'address', width: 150, render: (t) => t || '-' },
{ title: t('customer.mainContact'), key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
{
title: t('customer.paymentInfo'),
key: 'payment_info',
width: 200,
render: (_, record) => {
const primary = getPrimaryPaymentInfo(record.payment_infos || [])
if (!primary) return <Tag>{t('common.notSet')}</Tag>
return (
<div style={{ fontSize: 12 }}>
<div><BankOutlined /> {primary.bank_name || '-'}</div>
<div>{t('customer.accountNameLabel')} {primary.account_name || '-'}</div>
<div>{t('customer.accountNumLabel')} {primary.bank_account ? primary.bank_account.slice(-4).padStart(primary.bank_account.length, '*') : '-'}</div>
</div>
)
}
},
{ title: t('customer.contractAmount'), dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => ${(v || 0).toLocaleString()}` },
{ title: t('customer.receivableAmount'), dataIndex: 'total_receivable', key: 'total_receivable', width: 100, render: (v) => <span style={{ color: v > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()}</span> },
{ title: t('common.action'), key: 'actions', width: 100, render: (_, record) => (
<Space>
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} size="small" />
</Space>
)}
]
const filteredCustomers = customers.filter(c =>
c.code?.toLowerCase().includes(searchText.toLowerCase()) ||
c.name?.toLowerCase().includes(searchText.toLowerCase()) ||
c.address?.toLowerCase().includes(searchText.toLowerCase())
)
const handleContactChange = (index: number, field: string, value: any) => {
form.setFieldsValue({
contacts: form.getFieldValue('contacts').map((contact: any, i: number) => {
if (field === 'is_primary' && value) {
return i === index ? { ...contact, [field]: value } : { ...contact, is_primary: false }
}
return i === index ? { ...contact, [field]: value } : contact
})
})
}
const handlePaymentInfoChange = (index: number, field: string, value: any) => {
form.setFieldsValue({
payment_infos: form.getFieldValue('payment_infos').map((info: any, i: number) => {
if (field === 'is_primary' && value) {
return i === index ? { ...info, [field]: value } : { ...info, is_primary: false }
}
return i === index ? { ...info, [field]: value } : info
})
})
}
const handleSubmit = async (values: any) => {
try {
let contacts = values.contacts || [{ name: '', position: '', phone: '', is_primary: true }]
const hasPrimary = contacts.some((c: Contact) => c.is_primary)
if (!hasPrimary && contacts[0].name) contacts[0].is_primary = true
let paymentInfos = values.payment_infos || []
const hasPrimaryPayment = paymentInfos.some((p: PaymentInfo) => p.is_primary)
if (!hasPrimaryPayment && paymentInfos.length > 0 && paymentInfos[0].account_name) {
paymentInfos[0].is_primary = true
}
const url = editingCustomer ? `/api/customers/${editingCustomer.id}` : '/api/customers'
const method = editingCustomer ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...values, contacts, payment_infos: paymentInfos })
})
const data = await response.json()
if (data.success) {
message.success(editingCustomer ? t('common.updateSuccess') : t('common.createSuccess'))
clearDraft()
setModalVisible(false)
form.resetFields()
setEditingCustomer(null)
fetchCustomers()
} else {
message.error(data.message || t('common.operationFailed'))
}
} catch (error) {
message.error(t('common.operationFailed'))
}
}
const handleEdit = (customer: Customer) => {
setEditingCustomer(customer)
form.setFieldsValue({
name: customer.name,
address: customer.address,
remark: customer.remark,
contacts: customer.contacts?.length ? customer.contacts : [{ name: '', position: '', phone: '', is_primary: true }],
payment_infos: customer.payment_infos?.length ? customer.payment_infos : []
})
setModalVisible(true)
}
const handleDelete = async (id: number) => {
Modal.confirm({
title: t('common.confirmDelete'), content: t('customer.confirmDeleteMsg'), okText: t('common.confirm'), cancelText: t('common.cancel'),
onOk: async () => {
try {
const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' })
const data = await response.json()
if (data.success) { message.success(t('common.deleteSuccess')); fetchCustomers() }
else message.error(data.message || t('common.deleteFailed'))
} catch (error) { message.error(t('common.deleteFailed')) }
}
})
}
const handleAdd = () => {
setEditingCustomer(null)
form.resetFields()
form.setFieldsValue({
contacts: [{ name: '', position: '', phone: '', is_primary: true }],
payment_infos: []
})
setModalVisible(true)
// 检查是否有草稿,提示用户是否恢复
setTimeout(() => {
if (hasDraft()) {
Modal.confirm({
title: t('common.draftFound'),
content: t('common.draftRestore'),
okText: t('common.restoreDraft'),
cancelText: t('common.reFill'),
onOk: () => {
restoreDraft()
},
onCancel: () => {
clearDraft()
form.resetFields()
form.setFieldsValue({
contacts: [{ name: '', position: '', phone: '', is_primary: true }],
payment_infos: []
})
},
})
}
}, 0)
}
return (
<div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}><Card><Statistic title={t('customer.totalCount')} value={stats.total} prefix={<HomeOutlined />} /></Card></Col>
<Col span={8}><Card><Statistic title={t('customer.totalContract')} value={stats.totalContract} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
<Col span={8}><Card><Statistic title={t('customer.totalReceivable')} value={stats.totalReceivable} prefix="¥" valueStyle={{ color: stats.totalReceivable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
</Row>
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Input placeholder={t('customer.searchPlaceholder')} prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>{t('customer.newCustomer')}</Button>
</div>
</Card>
<Card>
<Table columns={columns} dataSource={filteredCustomers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => t('common.totalCount', { total }) }} scroll={{ x: 1100 }} />
</Card>
<Modal
title={editingCustomer ? t('customer.editCustomer') : t('customer.newCustomer')}
open={modalVisible}
onCancel={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: t('common.closeConfirm'),
content: t('common.closeConfirmMsg'),
okText: t('common.close'),
cancelText: t('common.continueEdit'),
onOk: () => {
saveDraft()
form.resetFields();
setModalVisible(false)
setEditingCustomer(null)
},
})
} else {
form.resetFields();
setModalVisible(false)
setEditingCustomer(null)
}
}}
onOk={() => form.submit()}
destroyOnClose
width={800}
maskClosable={false}
>
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}>
<Form.Item name="name" label={t('customer.name')} rules={[{ required: true, message: t('customer.nameRequired') }]}>
<Input placeholder={t('customer.namePlaceholder')} />
</Form.Item>
<Form.Item name="address" label={t('customer.address')}>
<Input placeholder={t('customer.addressPlaceholder')} />
</Form.Item>
<Form.Item name="remark" label={t('common.remark')}>
<Input.TextArea rows={2} placeholder={t('customer.remarkPlaceholder')} />
</Form.Item>
<h4>{t('customer.contact')}</h4>
<Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}>
{(fields, { add, remove }) => (
<div>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('logistics.name')} />
</Form.Item>
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('common.position')} />
</Form.Item>
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('common.phone')} />
</Form.Item>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
<input
type="checkbox"
onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
/>
</Form.Item>
<span>{t('customer.mainContact')}</span>
</div>
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}>{t('common.delete')}</Button>}
</div>
))}
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>{t('customer.addContact')}</Button>
</div>
)}
</Form.List>
<h4 style={{ marginTop: 24 }}>{t('customer.paymentInfo')}</h4>
<Form.List name="payment_infos" initialValue={[]}>
{(fields, { add, remove }) => (
<div>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ border: '1px solid #e8e8e8', padding: 16, marginBottom: 16, borderRadius: 4, backgroundColor: '#fafafa' }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'account_name']} label={t('customer.accountName')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('customer.accountName')} />
</Form.Item>
<Form.Item {...restField} name={[name, 'bank_name']} label={t('customer.bankName')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('customer.bankName')} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'bank_account']} label={t('customer.bankAccount')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('customer.bankAccount')} />
</Form.Item>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 30 }}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
<input
type="checkbox"
onChange={(e) => handlePaymentInfoChange(name, 'is_primary', e.target.checked)}
/>
</Form.Item>
<span>{t('customer.mainAccount')}</span>
</div>
</div>
<Form.Item {...restField} name={[name, 'qr_code']} label={t('customer.qrCode')} style={{ marginBottom: 0 }}>
<FileUpload maxCount={1} accept="image/*" />
</Form.Item>
{fields.length > 0 && (
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}>{t('customer.deletePaymentInfo')}</Button>
)}
</div>
))}
<Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}>
{t('customer.addPaymentInfo')}
</Button>
</div>
)}
</Form.List>
</Form>
</Modal>
</div>
)
}
export default CustomerPage