Files
frontend/src/pages/Manage.tsx
2026-03-02 13:36:21 +08:00

718 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { Card, Table, Input, Button, Space, message, Modal, Tag, Spin, Form, Radio, InputNumber, DatePicker, ColorPicker, Pagination } from 'antd';
import { TeamOutlined, DeleteOutlined, EyeOutlined, StopOutlined, PlusOutlined } from '@ant-design/icons';
import QRCode from 'qrcode';
import { useNavigate } from 'react-router-dom';
import type { Color } from 'antd/es/color-picker';
interface CompanyData {
companyName: string;
serialCount: number;
firstCreated: string;
lastCreated: string;
activeCount: number;
status: 'active' | 'disabled';
}
function ManagePage() {
const [companies, setCompanies] = useState<CompanyData[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCompany, setSelectedCompany] = useState<any>(null);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [companyDetail, setCompanyDetail] = useState<any>(null);
const [qrCodeModalVisible, setQrCodeModalVisible] = useState(false);
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('');
const [selectedSerial, setSelectedSerial] = useState<string>('');
const [generateModalVisible, setGenerateModalVisible] = useState(false);
const [generateLoading, setGenerateLoading] = useState(false);
const [generateForm] = Form.useForm();
const [qrColor, setQrColor] = useState<string>('#000000');
const [generatedData, setGeneratedData] = useState<any>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const navigate = useNavigate();
useEffect(() => {
loadCompanies();
}, [searchTerm, page, pageSize]);
const loadCompanies = async () => {
setLoading(true);
try {
const token = localStorage.getItem('authToken');
const headers: any = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
let url = '/api/companies';
const params = new URLSearchParams();
if (page > 1) params.append('page', String(page));
if (pageSize !== 10) params.append('limit', String(pageSize));
if (searchTerm) params.append('search', searchTerm);
if (params.toString()) url += `?${params.toString()}`;
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`);
}
const data = await response.json();
if (data.data) {
setCompanies(data.data);
setTotal(data.pagination?.total || data.data.length);
} else if (data.message) {
setCompanies([]);
setTotal(0);
} else {
throw new Error(data.error || '获取企业列表失败');
}
} catch (error: any) {
console.error('Load companies error:', error);
message.error(error.message || '加载企业列表失败');
setCompanies([]);
setTotal(0);
} finally {
setLoading(false);
}
};
const handleViewDetail = async (company: CompanyData) => {
setSelectedCompany(company);
setDetailModalVisible(true);
setDetailLoading(true);
try {
const token = localStorage.getItem('authToken');
const headers: any = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`/api/companies/${encodeURIComponent(company.companyName)}`, { headers });
const data = await response.json();
if (data.data) {
setCompanyDetail(data.data);
} else {
throw new Error(data.error || '获取企业详情失败');
}
} catch (error: any) {
message.error(error.message || '获取企业详情失败');
} finally {
setDetailLoading(false);
}
};
const handleDelete = async (company: CompanyData) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除企业 "${company.companyName}" 吗?这将删除该企业的所有序列号!`,
okText: '确定',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const token = localStorage.getItem('authToken');
const headers: any = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`/api/companies/${encodeURIComponent(company.companyName)}`, {
method: 'DELETE',
headers,
});
const data = await response.json();
if (data.message) {
message.success('删除成功');
loadCompanies();
} else {
throw new Error(data.error || '删除失败');
}
} catch (error: any) {
message.error(error.message || '删除失败');
}
},
});
};
const handleRevoke = async (company: CompanyData) => {
Modal.confirm({
title: '确认吊销',
content: `确定要吊销企业 "${company.companyName}" 吗?这将使该企业的所有序列号失效!`,
okText: '确定',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const token = localStorage.getItem('authToken');
const headers: any = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`/api/companies/${encodeURIComponent(company.companyName)}/revoke`, {
method: 'POST',
headers,
});
const data = await response.json();
if (data.message) {
message.success('吊销成功');
loadCompanies();
} else {
throw new Error(data.error || '吊销失败');
}
} catch (error: any) {
message.error(error.message || '吊销失败');
}
},
});
};
const handleViewQRCode = async (serialNumber: string) => {
try {
const baseUrl = window.location.origin;
const queryUrl = `${baseUrl}/query?serial=${serialNumber}`;
const qrCode = await QRCode.toDataURL(queryUrl);
setQrCodeDataUrl(qrCode);
setSelectedSerial(serialNumber);
setQrCodeModalVisible(true);
} catch (error) {
message.error('生成二维码失败');
}
};
const handleQuerySerial = (serialNumber: string) => {
setQrCodeModalVisible(false);
navigate(`/query?serial=${serialNumber}`);
};
const handleRevokeSerial = async (serialNumber: string) => {
Modal.confirm({
title: '确认吊销',
content: `确定要吊销序列号 "${serialNumber}" 吗?`,
okText: '确定',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const token = localStorage.getItem('authToken');
const headers: any = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`/api/serials/${encodeURIComponent(serialNumber)}/revoke`, {
method: 'POST',
headers,
});
const data = await response.json();
if (data.message) {
message.success('吊销成功');
if (selectedCompany) {
handleViewDetail(selectedCompany);
}
} else {
throw new Error(data.error || '吊销失败');
}
} catch (error: any) {
message.error(error.message || '吊销失败');
}
},
});
};
const handleGenerate = async (values: any) => {
setGenerateLoading(true);
try {
const token = localStorage.getItem('authToken');
const headers: any = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const payload = {
companyName: values.companyName,
quantity: values.quantity,
validDays: values.validOption === 'days' ? values.validDays : undefined,
serialPrefix: values.serialOption === 'custom' ? values.serialPrefix : undefined,
};
const response = await fetch('/api/serials/generate', {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
const data = await response.json();
if (data.serials) {
setGeneratedData(data);
if (data.serials && data.serials.length > 0) {
const baseUrl = window.location.origin;
const queryUrl = `${baseUrl}/query?serial=${data.serials[0].serialNumber}`;
const qrCode = await QRCode.toDataURL(queryUrl, {
color: {
dark: qrColor,
light: '#ffffff',
},
});
setQrCodeDataUrl(qrCode);
}
message.success('生成成功!');
setGenerateSuccessModalVisible(true);
loadCompanies();
} else {
throw new Error(data.error || '生成失败');
}
} catch (error: any) {
message.error(error.message || '生成失败');
} finally {
setGenerateLoading(false);
}
};
const handleDownloadQR = () => {
const link = document.createElement('a');
link.download = `qrcode-${generatedData?.serials?.[0]?.serialNumber}.png`;
link.href = qrCodeDataUrl;
link.click();
};
const handleViewQuery = () => {
if (generatedData?.serials?.[0]?.serialNumber) {
navigate(`/query?serial=${generatedData.serials[0].serialNumber}`);
setGenerateSuccessModalVisible(false);
generateForm.resetFields();
}
};
const [generateSuccessModalVisible, setGenerateSuccessModalVisible] = useState(false);
const colorPresets = [
'#000000',
'#165DFF',
'#52C41A',
'#FAAD14',
'#FF4D4F',
'#722ED1',
'#EB2F96',
];
const handlePageChange = (newPage: number, newPageSize: number) => {
setPage(newPage);
setPageSize(newPageSize);
};
const columns = [
{
title: '企业名称',
dataIndex: 'companyName',
key: 'companyName',
},
{
title: '序列号数量',
dataIndex: 'serialCount',
key: 'serialCount',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : 'red'}>
{status === 'active' ? '正常' : '已吊销'}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'firstCreated',
key: 'firstCreated',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '最后更新',
dataIndex: 'lastCreated',
key: 'lastCreated',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'actions',
render: (_: any, record: CompanyData) => (
<Space>
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
>
</Button>
<Button
type="link"
danger
icon={<StopOutlined />}
onClick={() => handleRevoke(record)}
>
</Button>
<Button
type="link"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
>
</Button>
</Space>
),
},
];
return (
<div>
<Card
title={
<Space>
<TeamOutlined />
<span></span>
</Space>
}
extra={
<Space>
<Input.Search
placeholder="搜索企业"
allowClear
style={{ width: 200 }}
onSearch={setSearchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
value={searchTerm}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setGenerateModalVisible(true)}>
</Button>
</Space>
}
>
<Table
columns={columns}
dataSource={companies}
rowKey="companyName"
loading={loading}
pagination={false}
/>
<div style={{ marginTop: 16 }}>
<Pagination
current={page}
pageSize={pageSize}
total={total}
onChange={handlePageChange}
showSizeChanger={true}
showTotal={() => `共计 ${total} 条记录`}
/>
</div>
</Card>
<Modal
title="企业详情"
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={null}
width={800}
>
{detailLoading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : companyDetail ? (
<div>
<p><strong>:</strong> {companyDetail.companyName}</p>
<p><strong>:</strong> {companyDetail.serialCount}</p>
<p><strong>:</strong> {companyDetail.activeCount}</p>
<p><strong>:</strong> {companyDetail.disabledCount || 0}</p>
<p><strong>:</strong> {companyDetail.expiredCount || 0}</p>
{companyDetail.serials && companyDetail.serials.length > 0 && (
<div style={{ marginTop: '16px' }}>
<h4></h4>
<Table
columns={[
{ title: '序列号', dataIndex: 'serialNumber', key: 'serialNumber' },
{
title: '状态',
dataIndex: 'isActive',
key: 'isActive',
render: (isActive: boolean) => (
<Tag color={isActive ? 'green' : 'red'}>
{isActive ? '有效' : '已吊销'}
</Tag>
),
},
{
title: '有效期至',
dataIndex: 'validUntil',
key: 'validUntil',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'actions',
render: (_: any, record: any) => (
<Space>
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewQRCode(record.serialNumber)}
>
</Button>
{record.isActive && (
<Button
type="link"
size="small"
danger
icon={<StopOutlined />}
onClick={() => handleRevokeSerial(record.serialNumber)}
>
</Button>
)}
</Space>
),
},
]}
dataSource={companyDetail.serials}
rowKey="serialNumber"
pagination={false}
size="small"
/>
</div>
)}
</div>
) : null}
</Modal>
<Modal
title="序列号二维码"
open={qrCodeModalVisible}
onCancel={() => setQrCodeModalVisible(false)}
footer={null}
width={400}
>
<div style={{ textAlign: 'center' }}>
{qrCodeDataUrl && (
<>
<img src={qrCodeDataUrl} alt="QR Code" style={{ width: '200px', height: '200px', cursor: 'pointer' }} onClick={() => handleQuerySerial(selectedSerial)} />
<p style={{ marginTop: '12px', fontFamily: 'monospace', fontSize: '16px', fontWeight: 'bold', color: '#165DFF' }}>{selectedSerial}</p>
<p style={{ marginTop: '8px', fontSize: '12px', color: '#999' }}></p>
</>
)}
</div>
</Modal>
<Modal
title="生成企业序列号"
open={generateModalVisible}
onCancel={() => {
setGenerateModalVisible(false);
generateForm.resetFields();
}}
footer={null}
width={600}
>
<Form
form={generateForm}
layout="vertical"
onFinish={handleGenerate}
initialValues={{
serialOption: 'auto',
quantity: 1,
validOption: 'days',
validDays: 365,
}}
>
<Form.Item
label="企业名称"
name="companyName"
rules={[{ required: true, message: '请输入企业名称' }]}
>
<Input placeholder="输入企业名称(如:浙江贝凡)" />
</Form.Item>
<Form.Item label="序列号设置" name="serialOption">
<Radio.Group>
<Radio value="auto"></Radio>
<Radio value="custom"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.serialOption !== currentValues.serialOption}
>
{({ getFieldValue }) =>
getFieldValue('serialOption') === 'custom' ? (
<Form.Item
label="自定义前缀"
name="serialPrefix"
rules={[{ max: 10, message: '前缀不能超过10个字符' }]}
>
<Input placeholder="输入自定义前缀MYCOMPANY" maxLength={10} />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item label="有效期设置" name="validOption">
<Radio.Group>
<Radio value="days"></Radio>
<Radio value="date"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.validOption !== currentValues.validOption}
>
{({ getFieldValue }) =>
getFieldValue('validOption') === 'days' ? (
<Form.Item
label="有效天数"
name="validDays"
rules={[{ required: true, message: '请输入有效天数' }]}
>
<InputNumber min={1} max={3650} style={{ width: '100%' }} />
</Form.Item>
) : (
<Form.Item
label="有效期至"
name="validUntil"
rules={[{ required: true, message: '请选择有效期' }]}
>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
)
}
</Form.Item>
<Form.Item
label="生成数量"
name="quantity"
rules={[{ required: true, message: '请输入生成数量' }]}
>
<InputNumber min={1} max={100} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="二维码颜色"
name="qrColor"
initialValue="#000000"
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{colorPresets.map((color) => (
<div
key={color}
onClick={() => setQrColor(color)}
style={{
width: '28px',
height: '28px',
backgroundColor: color,
border: qrColor === color ? '2px solid #165DFF' : '2px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
transition: 'all 0.2s',
}}
/>
))}
</div>
<ColorPicker
value={qrColor}
onChange={(color: Color) => {
const hexColor = color.toHexString();
setQrColor(hexColor);
}}
/>
</div>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setGenerateModalVisible(false)}></Button>
<Button type="primary" htmlType="submit" loading={generateLoading}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title="生成成功"
open={generateSuccessModalVisible}
onCancel={() => setGenerateSuccessModalVisible(false)}
footer={null}
width={600}
>
{generatedData && (
<div>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<p><strong>:</strong> {generatedData.companyName || generatedData.serials?.[0]?.companyName}</p>
<p><strong>:</strong> {generatedData.serials?.length || 0}</p>
{generatedData.serials && generatedData.serials.length > 0 && (
<p><strong>:</strong> {new Date(generatedData.serials[0].validUntil).toLocaleString('zh-CN')}</p>
)}
</div>
{qrCodeDataUrl && (
<div style={{ textAlign: 'center' }}>
<img src={qrCodeDataUrl} alt="QR Code" style={{ width: '200px', height: '200px' }} />
{generatedData.serials && generatedData.serials.length > 0 && (
<p style={{ marginTop: '12px', fontFamily: 'monospace', fontSize: '16px', fontWeight: 'bold', color: '#165DFF' }}>{generatedData.serials[0].serialNumber}</p>
)}
</div>
)}
<Space>
<Button type="primary" onClick={handleViewQuery}></Button>
<Button onClick={handleDownloadQR}></Button>
<Button onClick={() => {
setGenerateSuccessModalVisible(false);
generateForm.resetFields();
}}></Button>
</Space>
</Space>
</div>
)}
</Modal>
</div>
);
}
export default ManagePage;