Add employee code assignment function

This commit is contained in:
2026-03-02 12:58:05 +08:00
parent d2dac6091e
commit 76ea5a2e06
9 changed files with 1068 additions and 160 deletions

View File

@@ -7,6 +7,7 @@ import DashboardPage from './pages/Dashboard';
import GeneratePage from './pages/Generate'; import GeneratePage from './pages/Generate';
import ManagePage from './pages/Manage'; import ManagePage from './pages/Manage';
import ProfilePage from './pages/Profile'; import ProfilePage from './pages/Profile';
import EmployeeSerialsPage from './pages/EmployeeSerials';
const PrivateRoute = () => { const PrivateRoute = () => {
const user = authApi.getCurrentUser(); const user = authApi.getCurrentUser();
@@ -50,6 +51,7 @@ function App() {
<Route path="/admin/dashboard" element={<DashboardPage />} /> <Route path="/admin/dashboard" element={<DashboardPage />} />
<Route path="/admin/generate" element={<GeneratePage />} /> <Route path="/admin/generate" element={<GeneratePage />} />
<Route path="/admin/manage" element={<ManagePage />} /> <Route path="/admin/manage" element={<ManagePage />} />
<Route path="/admin/employee-serials" element={<EmployeeSerialsPage />} />
<Route path="/admin/profile" element={<ProfilePage />} /> <Route path="/admin/profile" element={<ProfilePage />} />
</Route> </Route>
</Route> </Route>

View File

@@ -8,6 +8,7 @@ import {
LogoutOutlined, LogoutOutlined,
LockOutlined, LockOutlined,
ExclamationCircleOutlined, ExclamationCircleOutlined,
IdcardOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { authApi } from '@/services/api'; import { authApi } from '@/services/api';
import './styles/AdminLayout.css'; import './styles/AdminLayout.css';
@@ -39,6 +40,12 @@ function AdminLayout() {
label: '企业管理', label: '企业管理',
onClick: () => navigate('/admin/manage'), onClick: () => navigate('/admin/manage'),
}, },
{
key: 'employee-serials',
icon: <IdcardOutlined />,
label: '员工管理',
onClick: () => navigate('/admin/employee-serials'),
},
]; ];
const handleLogout = () => { const handleLogout = () => {
@@ -84,6 +91,7 @@ function AdminLayout() {
if (path.includes('/dashboard')) return 'dashboard'; if (path.includes('/dashboard')) return 'dashboard';
if (path.includes('/generate')) return 'generate'; if (path.includes('/generate')) return 'generate';
if (path.includes('/manage')) return 'manage'; if (path.includes('/manage')) return 'manage';
if (path.includes('/employee-serials')) return 'employee-serials';
if (path.includes('/profile')) return 'profile'; if (path.includes('/profile')) return 'profile';
return 'dashboard'; return 'dashboard';
}; };
@@ -93,6 +101,7 @@ function AdminLayout() {
if (path.includes('/dashboard')) return '控制台'; if (path.includes('/dashboard')) return '控制台';
if (path.includes('/generate')) return '生成二维码'; if (path.includes('/generate')) return '生成二维码';
if (path.includes('/manage')) return '企业管理'; if (path.includes('/manage')) return '企业管理';
if (path.includes('/employee-serials')) return '员工管理';
if (path.includes('/profile')) return '用户资料'; if (path.includes('/profile')) return '用户资料';
return '控制台'; return '控制台';
}; };

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Card, Row, Col, Statistic, Table, Spin, message } from 'antd'; import { Card, Row, Col, Statistic, Table, Spin, message, Tag } from 'antd';
import { TeamOutlined, KeyOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; import { TeamOutlined, KeyOutlined, CheckCircleOutlined, UserOutlined } from '@ant-design/icons';
import { dashboardApi } from '@/services/api'; import { dashboardApi } from '@/services/api';
import type { DashboardStats } from '@/types'; import type { DashboardStats } from '@/types';
@@ -45,6 +45,16 @@ function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="总员工数"
value={stats?.totalEmployeeSerials || 0}
prefix={<UserOutlined />}
valueStyle={{ color: '#722ed1' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} lg={6}>
<Card> <Card>
<Statistic <Statistic
@@ -65,16 +75,6 @@ function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="无效序列号"
value={stats?.inactiveSerials || 0}
prefix={<CloseCircleOutlined />}
valueStyle={{ color: '#ff4d4f' }}
/>
</Card>
</Col>
</Row> </Row>
<Card title="最近生成的序列号" style={{ marginBottom: '24px' }}> <Card title="最近生成的序列号" style={{ marginBottom: '24px' }}>
@@ -82,6 +82,16 @@ function DashboardPage() {
columns={[ columns={[
{ title: '序列号', dataIndex: 'serialNumber', key: 'serialNumber' }, { title: '序列号', dataIndex: 'serialNumber', key: 'serialNumber' },
{ title: '企业名称', dataIndex: 'companyName', key: 'companyName' }, { title: '企业名称', dataIndex: 'companyName', key: 'companyName' },
{
title: '类型',
dataIndex: 'type',
key: 'type',
render: (type: string) => (
<Tag color={type === 'employee' ? 'purple' : 'blue'}>
{type === 'employee' ? '员工' : '企业'}
</Tag>
),
},
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',

View File

@@ -0,0 +1,416 @@
import { useEffect, useState } from 'react';
import { Card, Table, Input, Button, Space, message, Modal, Tag, Form, Select, InputNumber, Pagination } from 'antd';
import { UserOutlined, PlusOutlined, StopOutlined, EditOutlined, QrcodeOutlined, DeleteOutlined } from '@ant-design/icons';
import { employeeSerialApi } from '@/services/api';
import type { EmployeeSerial } from '@/types';
function EmployeeSerialsPage() {
const [serials, setSerials] = useState<EmployeeSerial[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(10);
const [total, setTotal] = useState(0);
const [generateModalVisible, setGenerateModalVisible] = useState(false);
const [generateLoading, setGenerateLoading] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const [editLoading, setEditLoading] = useState(false);
const [selectedSerial, setSelectedSerial] = useState<EmployeeSerial | null>(null);
const [qrCodeModalVisible, setQrCodeModalVisible] = useState(false);
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
const [generateForm] = Form.useForm();
const [editForm] = Form.useForm();
useEffect(() => {
loadSerials();
}, [page, limit, searchTerm]);
const handlePageChange = (newPage: number, newLimit: number) => {
setPage(newPage);
setLimit(newLimit);
};
const loadSerials = async () => {
setLoading(true);
try {
const result = await employeeSerialApi.list({ page, limit, search: searchTerm || undefined });
setSerials(result.data);
setTotal(result.pagination.total);
} catch (error: any) {
message.error(error.message || '加载员工序列号列表失败');
setSerials([]);
} finally {
setLoading(false);
}
};
const handleGenerate = async (values: { companyName: string; department: string; employeeName: string; quantity: number }) => {
setGenerateLoading(true);
try {
const result = await employeeSerialApi.generate(values);
message.success(result.message || '生成成功');
setGenerateModalVisible(false);
generateForm.resetFields();
loadSerials();
} catch (error: any) {
message.error(error.message || '生成失败');
} finally {
setGenerateLoading(false);
}
};
const handleEdit = (serial: EmployeeSerial) => {
setSelectedSerial(serial);
editForm.setFieldsValue({
companyName: serial.companyName,
department: serial.department,
employeeName: serial.employeeName,
isActive: serial.isActive,
});
setEditModalVisible(true);
};
const handleUpdate = async (values: { companyName?: string; department?: string; employeeName?: string; isActive?: boolean }) => {
if (!selectedSerial) return;
setEditLoading(true);
try {
await employeeSerialApi.update(selectedSerial.serialNumber, values);
message.success('更新成功');
setEditModalVisible(false);
loadSerials();
} catch (error: any) {
message.error(error.message || '更新失败');
} finally {
setEditLoading(false);
}
};
const handleRevoke = async (serial: EmployeeSerial) => {
Modal.confirm({
title: '确认吊销',
content: `确定要吊销序列号 "${serial.serialNumber}" 吗?`,
okText: '确定',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await employeeSerialApi.revoke(serial.serialNumber);
message.success('吊销成功');
loadSerials();
} catch (error: any) {
message.error(error.message || '吊销失败');
}
},
});
};
const handleDelete = async (serial: EmployeeSerial) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除序列号 "${serial.serialNumber}" 吗?此操作不可恢复!`,
okText: '确定',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await employeeSerialApi.delete(serial.serialNumber);
message.success('删除成功');
loadSerials();
} catch (error: any) {
message.error(error.message || '删除失败');
}
},
});
};
const handleViewQrCode = async (serial: EmployeeSerial) => {
setSelectedSerial(serial);
try {
const baseUrl = window.location.origin;
const result = await employeeSerialApi.generateQrCode(serial.serialNumber, `${baseUrl}/query`);
if (result.qrCodeData) {
const qrDataUrl = result.qrCodeData.startsWith('data:')
? result.qrCodeData
: `data:image/png;base64,${result.qrCodeData}`;
setQrCodeDataUrl(qrDataUrl);
setQrCodeModalVisible(true);
}
} catch (error: any) {
message.error(error.message || '生成二维码失败');
}
};
const handleSearch = (value: string) => {
setSearchTerm(value);
setPage(1);
};
const columns = [
{
title: '序列号',
dataIndex: 'serialNumber',
key: 'serialNumber',
width: 180,
},
{
title: '企业名称',
dataIndex: 'companyName',
key: 'companyName',
},
{
title: '部门',
dataIndex: 'department',
key: 'department',
},
{
title: '员工姓名',
dataIndex: 'employeeName',
key: 'employeeName',
},
{
title: '状态',
dataIndex: 'isActive',
key: 'isActive',
render: (isActive: boolean) => (
<Tag color={isActive ? 'green' : 'red'}>
{isActive ? '有效' : '已吊销'}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'actions',
render: (_: any, record: EmployeeSerial) => (
<Space>
<Button
type="link"
size="small"
icon={<QrcodeOutlined />}
onClick={() => handleViewQrCode(record)}
>
</Button>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
{record.isActive && (
<Button
type="link"
size="small"
danger
icon={<StopOutlined />}
onClick={() => handleRevoke(record)}
>
</Button>
)}
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
>
</Button>
</Space>
),
},
];
return (
<div>
<Card
title={
<Space>
<UserOutlined />
<span></span>
</Space>
}
extra={
<Space>
<Input.Search
placeholder="搜索序列号/企业/部门/员工"
allowClear
style={{ width: 250 }}
onSearch={handleSearch}
onChange={(e) => {
if (!e.target.value) {
handleSearch('');
}
}}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setGenerateModalVisible(true)}
>
</Button>
</Space>
}
>
<Table
columns={columns}
dataSource={serials}
rowKey="serialNumber"
loading={loading}
pagination={false}
/>
<div style={{ marginTop: 16 }}>
<Pagination
current={page}
pageSize={limit}
total={total}
onChange={handlePageChange}
showSizeChanger={true}
showTotal={(t) => `共计 ${t} 条记录`}
/>
</div>
</Card>
<Modal
title="生成员工序列号"
open={generateModalVisible}
onCancel={() => {
setGenerateModalVisible(false);
generateForm.resetFields();
}}
footer={null}
width={500}
>
<Form
form={generateForm}
layout="vertical"
onFinish={handleGenerate}
>
<Form.Item
name="companyName"
label="企业名称"
rules={[{ required: true, message: '请输入企业名称' }]}
>
<Input placeholder="请输入企业名称" />
</Form.Item>
<Form.Item
name="department"
label="部门"
rules={[{ required: true, message: '请输入部门' }]}
>
<Input placeholder="请输入部门" />
</Form.Item>
<Form.Item
name="employeeName"
label="员工姓名"
rules={[{ required: true, message: '请输入员工姓名' }]}
>
<Input placeholder="请输入员工姓名" />
</Form.Item>
<Form.Item
name="quantity"
label="生成数量"
rules={[{ required: true, message: '请输入生成数量' }]}
initialValue={1}
>
<InputNumber min={1} max={1000} style={{ width: '100%' }} />
</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={editModalVisible}
onCancel={() => {
setEditModalVisible(false);
editForm.resetFields();
}}
footer={null}
width={500}
>
<Form
form={editForm}
layout="vertical"
onFinish={handleUpdate}
>
<Form.Item
name="companyName"
label="企业名称"
>
<Input placeholder="请输入企业名称" />
</Form.Item>
<Form.Item
name="department"
label="部门"
>
<Input placeholder="请输入部门" />
</Form.Item>
<Form.Item
name="employeeName"
label="员工姓名"
>
<Input placeholder="请输入员工姓名" />
</Form.Item>
<Form.Item
name="isActive"
label="状态"
>
<Select placeholder="请选择状态">
<Select.Option value={true}></Select.Option>
<Select.Option value={false}></Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setEditModalVisible(false)}></Button>
<Button type="primary" htmlType="submit" loading={editLoading}>
</Button>
</Space>
</Form.Item>
</Form>
</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' }} />
<p style={{ marginTop: '12px', fontFamily: 'monospace', fontSize: '16px', fontWeight: 'bold', color: '#165DFF' }}>
{selectedSerial?.serialNumber}
</p>
<p style={{ marginTop: '8px', fontSize: '12px', color: '#999' }}>
{selectedSerial?.companyName} - {selectedSerial?.department} - {selectedSerial?.employeeName}
</p>
</>
)}
</div>
</Modal>
</div>
);
}
export default EmployeeSerialsPage;

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Form, Input, Button, Card, Radio, InputNumber, DatePicker, message, Modal, Space, ColorPicker, Divider, Row, Col } from 'antd'; import { Form, Input, Button, Card, Radio, InputNumber, DatePicker, message, Modal, Space, ColorPicker } from 'antd';
import { QrcodeOutlined } from '@ant-design/icons'; import { QrcodeOutlined, UserOutlined } from '@ant-design/icons';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import type { Color } from 'antd/es/color-picker'; import type { Color } from 'antd/es/color-picker';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -13,6 +13,7 @@ function GeneratePage() {
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>(''); const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('');
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [qrColor, setQrColor] = useState<string>('#000000'); const [qrColor, setQrColor] = useState<string>('#000000');
const [generateType, setGenerateType] = useState<'company' | 'employee'>('company');
const navigate = useNavigate(); const navigate = useNavigate();
const colorPresets = [ const colorPresets = [
@@ -36,6 +37,9 @@ function GeneratePage() {
headers.Authorization = `Bearer ${token}`; headers.Authorization = `Bearer ${token}`;
} }
let data: any;
if (generateType === 'company') {
const payload = { const payload = {
companyName: values.companyName, companyName: values.companyName,
quantity: values.quantity, quantity: values.quantity,
@@ -49,7 +53,7 @@ function GeneratePage() {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
const data = await response.json(); data = await response.json();
if (data.serials) { if (data.serials) {
setGeneratedData(data); setGeneratedData(data);
@@ -71,6 +75,44 @@ function GeneratePage() {
} else { } else {
throw new Error(data.error || '生成失败'); throw new Error(data.error || '生成失败');
} }
} else {
const payload = {
companyName: values.companyName,
department: values.department,
employeeName: values.employeeName,
quantity: values.quantity,
serialPrefix: values.serialOption === 'custom' ? values.serialPrefix : undefined,
};
const response = await fetch('/api/employee-serials/generate', {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
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);
}
setModalVisible(true);
message.success('生成成功!');
} else {
throw new Error(data.error || '生成失败');
}
}
} catch (error: any) { } catch (error: any) {
message.error(error.message || '生成失败'); message.error(error.message || '生成失败');
} finally { } finally {
@@ -104,6 +146,24 @@ function GeneratePage() {
} }
bordered={false} bordered={false}
> >
<div style={{ marginBottom: 16 }}>
<Button.Group>
<Button
type={generateType === 'company' ? 'primary' : 'default'}
onClick={() => { setGenerateType('company'); form.resetFields(); }}
>
</Button>
<Button
type={generateType === 'employee' ? 'primary' : 'default'}
onClick={() => { setGenerateType('employee'); form.resetFields(); }}
>
</Button>
</Button.Group>
</div>
<div style={{ maxWidth: 500 }}>
<Form <Form
form={form} form={form}
onFinish={handleGenerate} onFinish={handleGenerate}
@@ -115,16 +175,34 @@ function GeneratePage() {
validDays: 365, validDays: 365,
}} }}
> >
<Row gutter={24}>
<Col span={12}>
<Form.Item <Form.Item
label="企业名称" label="企业名称"
name="companyName" name="companyName"
rules={[{ required: true, message: '请输入企业名称' }]} rules={[{ required: true, message: '请输入企业名称' }]}
> >
<Input placeholder="输入企业名称(如:浙江贝凡)" style={{ width: '80%' }} /> <Input placeholder="输入企业名称(如:浙江贝凡)" />
</Form.Item> </Form.Item>
{generateType === 'employee' && (
<>
<Form.Item
label="部门"
name="department"
rules={[{ required: true, message: '请输入部门' }]}
>
<Input placeholder="输入部门(如:研发部)" />
</Form.Item>
<Form.Item
label="员工姓名"
name="employeeName"
rules={[{ required: true, message: '请输入员工姓名' }]}
>
<Input placeholder="输入员工姓名" />
</Form.Item>
</>
)}
<Form.Item label="序列号设置" name="serialOption"> <Form.Item label="序列号设置" name="serialOption">
<Radio.Group> <Radio.Group>
<Radio value="auto"></Radio> <Radio value="auto"></Radio>
@@ -143,7 +221,10 @@ function GeneratePage() {
name="serialPrefix" name="serialPrefix"
rules={[{ max: 10, message: '前缀不能超过10个字符' }]} rules={[{ max: 10, message: '前缀不能超过10个字符' }]}
> >
<Input placeholder="输入自定义前缀MYCOMPANY" maxLength={10} /> <Input
placeholder={generateType === 'employee' ? "如EMP001" : "如MYCOMPANY"}
maxLength={10}
/>
</Form.Item> </Form.Item>
) : null ) : null
} }
@@ -154,11 +235,11 @@ function GeneratePage() {
name="quantity" name="quantity"
rules={[{ required: true, message: '请输入序列号数量' }]} rules={[{ required: true, message: '请输入序列号数量' }]}
> >
<InputNumber min={1} max={100} style={{ width: '80%' }} /> <InputNumber min={1} max={100} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
</Col>
<Col span={12}> {generateType === 'company' && (
<>
<Form.Item label="有效期设置" name="validOption"> <Form.Item label="有效期设置" name="validOption">
<Radio.Group> <Radio.Group>
<Radio value="days"></Radio> <Radio value="days"></Radio>
@@ -177,7 +258,7 @@ function GeneratePage() {
name="validDays" name="validDays"
rules={[{ required: true, message: '请输入有效天数' }]} rules={[{ required: true, message: '请输入有效天数' }]}
> >
<InputNumber min={1} max={3650} style={{ width: '80%' }} /> <InputNumber min={1} max={3650} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
) : ( ) : (
<Form.Item <Form.Item
@@ -185,11 +266,19 @@ function GeneratePage() {
name="validUntil" name="validUntil"
rules={[{ required: true, message: '请选择有效期' }]} rules={[{ required: true, message: '请选择有效期' }]}
> >
<DatePicker showTime style={{ width: '80%' }} /> <DatePicker showTime style={{ width: '100%' }} />
</Form.Item> </Form.Item>
) )
} }
</Form.Item> </Form.Item>
</>
)}
{generateType === 'employee' && (
<div style={{ color: '#999', fontSize: 12, marginBottom: 16 }}>
</div>
)}
<Form.Item <Form.Item
label="二维码颜色" label="二维码颜色"
@@ -224,8 +313,6 @@ function GeneratePage() {
/> />
</div> </div>
</Form.Item> </Form.Item>
</Col>
</Row>
<Form.Item> <Form.Item>
<Button <Button
@@ -234,12 +321,13 @@ function GeneratePage() {
loading={loading} loading={loading}
size="large" size="large"
block block
icon={<QrcodeOutlined />} icon={generateType === 'company' ? <QrcodeOutlined /> : <UserOutlined />}
> >
{generateType === 'company' ? '企业' : '员工'}
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>
</div>
</Card> </Card>
<Modal <Modal
@@ -253,11 +341,22 @@ function GeneratePage() {
<div> <div>
<Space direction="vertical" style={{ width: '100%' }} size="middle"> <Space direction="vertical" style={{ width: '100%' }} size="middle">
<div> <div>
{generateType === 'company' ? (
<>
<p><strong>:</strong> {generatedData.companyName || generatedData.serials?.[0]?.companyName}</p> <p><strong>:</strong> {generatedData.companyName || generatedData.serials?.[0]?.companyName}</p>
<p><strong>:</strong> {generatedData.serials?.length || 0}</p> <p><strong>:</strong> {generatedData.serials?.length || 0}</p>
{generatedData.serials && generatedData.serials.length > 0 && ( {generatedData.serials && generatedData.serials.length > 0 && (
<p><strong>:</strong> {new Date(generatedData.serials[0].validUntil).toLocaleString('zh-CN')}</p> <p><strong>:</strong> {new Date(generatedData.serials[0].validUntil).toLocaleString('zh-CN')}</p>
)} )}
</>
) : (
<>
<p><strong>:</strong> {generatedData.serials?.[0]?.companyName}</p>
<p><strong>:</strong> {generatedData.serials?.[0]?.department}</p>
<p><strong>:</strong> {generatedData.serials?.[0]?.employeeName}</p>
<p><strong>:</strong> {generatedData.serials?.length || 0}</p>
</>
)}
</div> </div>
{qrCodeDataUrl && ( {qrCodeDataUrl && (

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Card, Table, Input, Select, Button, Space, message, Modal, Tag, Spin } from 'antd'; import { Card, Table, Input, Button, Space, message, Modal, Tag, Spin, Form, Radio, InputNumber, DatePicker, ColorPicker, Pagination } from 'antd';
import { TeamOutlined, DeleteOutlined, EyeOutlined, StopOutlined } from '@ant-design/icons'; import { TeamOutlined, DeleteOutlined, EyeOutlined, StopOutlined, PlusOutlined } from '@ant-design/icons';
import type { Company } from '@/types';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { Color } from 'antd/es/color-picker';
interface CompanyData { interface CompanyData {
companyName: string; companyName: string;
@@ -25,16 +25,23 @@ function ManagePage() {
const [qrCodeModalVisible, setQrCodeModalVisible] = useState(false); const [qrCodeModalVisible, setQrCodeModalVisible] = useState(false);
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>(''); const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('');
const [selectedSerial, setSelectedSerial] = 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(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
loadCompanies(); loadCompanies();
}, [searchTerm]); }, [searchTerm, page, pageSize]);
const loadCompanies = async () => { const loadCompanies = async () => {
setLoading(true); setLoading(true);
try { try {
// 直接使用 apiClient 来调用后端接口
const token = localStorage.getItem('authToken'); const token = localStorage.getItem('authToken');
const headers: any = { const headers: any = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -43,9 +50,9 @@ function ManagePage() {
headers.Authorization = `Bearer ${token}`; headers.Authorization = `Bearer ${token}`;
} }
let url = '/api/companies'; let url = `/api/companies?page=${page}&limit=${pageSize}`;
if (searchTerm) { if (searchTerm) {
url += `?search=${encodeURIComponent(searchTerm)}`; url += `&search=${encodeURIComponent(searchTerm)}`;
} }
const response = await fetch(url, { headers }); const response = await fetch(url, { headers });
@@ -53,8 +60,10 @@ function ManagePage() {
if (data.data) { if (data.data) {
setCompanies(data.data); setCompanies(data.data);
setTotal(data.pagination?.total || data.data.length);
} else if (data.message) { } else if (data.message) {
setCompanies([]); setCompanies([]);
setTotal(0);
} else { } else {
throw new Error(data.error || '获取企业列表失败'); throw new Error(data.error || '获取企业列表失败');
} }
@@ -62,6 +71,7 @@ function ManagePage() {
console.error('Load companies error:', error); console.error('Load companies error:', error);
message.error(error.message || '加载企业列表失败'); message.error(error.message || '加载企业列表失败');
setCompanies([]); setCompanies([]);
setTotal(0);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -224,6 +234,71 @@ function ManagePage() {
}); });
}; };
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('生成成功!');
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 handlePageChange = (newPage: number, newPageSize: number) => {
setPage(newPage);
setPageSize(newPageSize);
};
const columns = [ const columns = [
{ {
title: '企业名称', title: '企业名称',
@@ -300,6 +375,7 @@ function ManagePage() {
</Space> </Space>
} }
extra={ extra={
<Space>
<Input.Search <Input.Search
placeholder="搜索企业" placeholder="搜索企业"
allowClear allowClear
@@ -308,6 +384,10 @@ function ManagePage() {
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
value={searchTerm} value={searchTerm}
/> />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setGenerateModalVisible(true)}>
</Button>
</Space>
} }
> >
<Table <Table
@@ -315,12 +395,18 @@ function ManagePage() {
dataSource={companies} dataSource={companies}
rowKey="companyName" rowKey="companyName"
loading={loading} loading={loading}
pagination={{ pagination={false}
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total} 家企业`,
}}
/> />
<div style={{ marginTop: 16 }}>
<Pagination
current={page}
pageSize={pageSize}
total={total}
onChange={handlePageChange}
showSizeChanger={true}
showTotal={() => `共计 ${total} 条记录`}
/>
</div>
</Card> </Card>
<Modal <Modal
@@ -420,6 +506,124 @@ function ManagePage() {
)} )}
</div> </div>
</Modal> </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="quantity"
rules={[{ required: true, message: '请输入序列号数量' }]}
>
<InputNumber min={1} max={100} style={{ width: '100%' }} />
</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="qrColor"
initialValue="#000000"
>
<ColorPicker
value={qrColor}
onChange={(color: Color) => {
const hexColor = color.toHexString();
setQrColor(hexColor);
}}
/>
</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>
</div> </div>
); );
} }

View File

@@ -1,18 +1,28 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Input, Button, Card, message, Spin, Result } from 'antd'; import { Input, Button, Card, message, Spin, Result, Tag } from 'antd';
import { QrcodeOutlined, SearchOutlined, ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; import { QrcodeOutlined, SearchOutlined, ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { serialApi } from '@/services/api'; import { employeeSerialApi } from '@/services/api';
import type { Serial } from '@/types'; import type { Serial } from '@/types';
import './styles/PublicQuery.css'; import './styles/PublicQuery.css';
import logo from '@/assets/img/logo.png?url'; import logo from '@/assets/img/logo.png?url';
import beian from '@/assets/img/beian.png?url'; import beian from '@/assets/img/beian.png?url';
interface EmployeeSerialResult {
serialNumber: string;
companyName: string;
department: string;
employeeName: string;
isActive: boolean;
createdAt: string;
}
function PublicQueryPage() { function PublicQueryPage() {
const [serialNumber, setSerialNumber] = useState(''); const [serialNumber, setSerialNumber] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [result, setResult] = useState<Serial | null>(null); const [result, setResult] = useState<Serial | EmployeeSerialResult | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showResult, setShowResult] = useState(false); const [showResult, setShowResult] = useState(false);
const [serialType, setSerialType] = useState<'company' | 'employee'>('company');
const performQuery = async (serialToQuery: string) => { const performQuery = async (serialToQuery: string) => {
setLoading(true); setLoading(true);
@@ -20,8 +30,14 @@ function PublicQueryPage() {
setResult(null); setResult(null);
try { try {
const data = await serialApi.query(serialToQuery); const response = await employeeSerialApi.queryAll(serialToQuery);
setResult(data); if (response.type === 'employee') {
setSerialType('employee');
setResult(response.data as EmployeeSerialResult);
} else {
setSerialType('company');
setResult(response.data as Serial);
}
} catch (err: any) { } catch (err: any) {
setError(err.message || '查询失败'); setError(err.message || '查询失败');
setResult(null); setResult(null);
@@ -104,16 +120,19 @@ function PublicQueryPage() {
</div> </div>
) : result ? ( ) : result ? (
<div className="success-container"> <div className="success-container">
{result.status !== 'active' ? ( {(result as any).isActive === false || (result as any).status === 'inactive' ? (
<Result <Result
icon={<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '72px' }} />} icon={<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '72px' }} />}
title="授权已吊销" title={serialType === 'employee' ? "员工身份已吊销" : "授权已吊销"}
subTitle={`序列号验证通过,但已被吊销。企业:${result.companyName}`} subTitle={serialType === 'employee'
? `序列号验证通过,但已被吊销。企业:${(result as EmployeeSerialResult).companyName}`
: `序列号验证通过,但已被吊销。企业:${(result as Serial).companyName}`
}
/> />
) : ( ) : (
<Result <Result
icon={<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '72px' }} />} icon={<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '72px' }} />}
title="授权有效" title={serialType === 'employee' ? "员工身份有效" : "授权有效"}
subTitle="您的序列号已验证通过" subTitle="您的序列号已验证通过"
/> />
)} )}
@@ -127,14 +146,30 @@ function PublicQueryPage() {
<span className="label"></span> <span className="label"></span>
<span className="value">{result.companyName}</span> <span className="value">{result.companyName}</span>
</div> </div>
{serialType === 'employee' && (result as EmployeeSerialResult).department && (
<>
<div className="detail-item">
<span className="label"></span>
<span className="value">{(result as EmployeeSerialResult).department}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{(result as EmployeeSerialResult).employeeName}</span>
</div>
</>
)}
{serialType !== 'employee' && (result as Serial).validUntil && (
<div className="detail-item"> <div className="detail-item">
<span className="label"></span> <span className="label"></span>
<span className="value">{new Date(result.validUntil).toLocaleString('zh-CN')}</span> <span className="value">{new Date((result as Serial).validUntil).toLocaleString('zh-CN')}</span>
</div> </div>
)}
<div className="detail-item"> <div className="detail-item">
<span className="label"></span> <span className="label"></span>
<span className="value status"> <span className="value status">
{result.status === 'active' ? '有效' : '已吊销'} <Tag color={(result as any).isActive === false || (result as any).status === 'inactive' ? 'red' : 'green'}>
{(result as any).isActive === false || (result as any).status === 'inactive' ? '已吊销' : '有效'}
</Tag>
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import type { ApiResponse, AuthResponse, User } from '@/types'; import type { ApiResponse, AuthResponse, User, EmployeeSerial, EmployeeSerialResponse } from '@/types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
@@ -78,10 +78,9 @@ export const authApi = {
updateProfile: async (data: { name?: string; email?: string }) => { updateProfile: async (data: { name?: string; email?: string }) => {
const response = await apiClient.put('/auth/profile', data); const response = await apiClient.put('/auth/profile', data);
const user = response.data; if (response.data.user) {
if (user) { localStorage.setItem('currentUser', JSON.stringify(response.data.user));
localStorage.setItem('currentUser', JSON.stringify(user)); return response.data.user as User;
return user;
} }
throw new Error('更新资料失败'); throw new Error('更新资料失败');
}, },
@@ -91,7 +90,10 @@ export const authApi = {
if (response.data.message) { if (response.data.message) {
return true; return true;
} }
throw new Error(response.data.error || '修改密码失败'); if (response.data.error) {
throw new Error(response.data.error);
}
throw new Error('修改密码失败');
}, },
}; };
@@ -192,6 +194,7 @@ export const dashboardApi = {
return { return {
totalCompanies: data.overview?.totalCompanies || 0, totalCompanies: data.overview?.totalCompanies || 0,
totalSerials: data.overview?.totalSerials || 0, totalSerials: data.overview?.totalSerials || 0,
totalEmployeeSerials: data.overview?.totalEmployeeSerials || 0,
activeSerials: data.overview?.activeSerials || 0, activeSerials: data.overview?.activeSerials || 0,
inactiveSerials: data.overview?.inactiveSerials || 0, inactiveSerials: data.overview?.inactiveSerials || 0,
monthlyData: data.monthlyStats || [], monthlyData: data.monthlyStats || [],
@@ -207,9 +210,110 @@ export const dashboardApi = {
companyName: s.companyName, companyName: s.companyName,
status: s.isActive ? 'active' : 'inactive', status: s.isActive ? 'active' : 'inactive',
createdAt: s.createdAt, createdAt: s.createdAt,
type: s.type,
})) || [], })) || [],
}; };
} }
throw new Error('获取统计数据失败'); throw new Error('获取统计数据失败');
}, },
}; };
export const employeeSerialApi = {
generate: async (data: {
companyName: string;
department: string;
employeeName: string;
quantity: number;
serialPrefix?: string;
}) => {
const response = await apiClient.post('/employee-serials/generate', data);
if (response.data.serials) {
return response.data;
}
throw new Error(response.data.error || '生成员工序列号失败');
},
list: async (filter?: { page?: number; limit?: number; search?: string }) => {
let url = '/employee-serials';
const params = new URLSearchParams();
if (filter?.page && filter.page > 1) params.append('page', String(filter.page));
if (filter?.limit && filter.limit !== 20) params.append('limit', String(filter.limit));
if (filter?.search) params.append('search', filter.search);
if (params.toString()) url += `?${params.toString()}`;
const response = await apiClient.get(url);
if (response.data.data) {
return response.data as EmployeeSerialResponse;
}
throw new Error('获取员工序列号列表失败');
},
query: async (serialNumber: string) => {
const response = await apiClient.get(`/employee-serials/${encodeURIComponent(serialNumber)}/query`);
if (response.data.serial) {
return response.data.serial as EmployeeSerial;
}
throw new Error(response.data.error || '查询员工序列号失败');
},
queryAll: async (serialNumber: string) => {
// 先查企业序列号
try {
const companyResponse = await apiClient.get(`/serials/${encodeURIComponent(serialNumber)}/query`);
if (companyResponse.data.serial) {
return { type: 'company', data: companyResponse.data.serial };
}
} catch (e: any) {
// 企业序列号不存在,继续查员工序列号
}
// 再查员工序列号
try {
const employeeResponse = await apiClient.get(`/employee-serials/${encodeURIComponent(serialNumber)}/query`);
if (employeeResponse.data.serial) {
return { type: 'employee', data: employeeResponse.data.serial };
}
} catch (e: any) {
throw new Error('序列号不存在');
}
throw new Error('序列号不存在');
},
generateQrCode: async (serialNumber: string, baseUrl?: string) => {
const response = await apiClient.post(`/employee-serials/${encodeURIComponent(serialNumber)}/qrcode`, {
baseUrl,
});
if (response.data.qrCodeData) {
return response.data;
}
throw new Error(response.data.error || '生成二维码失败');
},
update: async (serialNumber: string, data: {
companyName?: string;
department?: string;
employeeName?: string;
isActive?: boolean;
}) => {
const response = await apiClient.put(`/employee-serials/${encodeURIComponent(serialNumber)}`, data);
if (response.data.serial) {
return response.data.serial as EmployeeSerial;
}
throw new Error(response.data.error || '更新员工序列号失败');
},
revoke: async (serialNumber: string) => {
const response = await apiClient.post(`/employee-serials/${encodeURIComponent(serialNumber)}/revoke`);
if (response.data.message) {
return true;
}
throw new Error(response.data.error || '吊销员工序列号失败');
},
delete: async (serialNumber: string) => {
const response = await apiClient.delete(`/employee-serials/${encodeURIComponent(serialNumber)}`);
if (response.data.message) {
return true;
}
throw new Error(response.data.error || '删除员工序列号失败');
},
};

View File

@@ -74,6 +74,7 @@ export interface ApiResponse<T> {
export interface DashboardStats { export interface DashboardStats {
totalCompanies: number; totalCompanies: number;
totalSerials: number; totalSerials: number;
totalEmployeeSerials: number;
activeSerials: number; activeSerials: number;
inactiveSerials: number; inactiveSerials: number;
monthlyData: Array<{ monthlyData: Array<{
@@ -89,3 +90,31 @@ export interface CompanyFilter {
search?: string; search?: string;
status?: 'all' | 'active' | 'expired'; status?: 'all' | 'active' | 'expired';
} }
export interface EmployeeSerial {
serialNumber: string;
companyName: string;
department: string;
employeeName: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
export interface EmployeeSerialFilter {
search?: string;
page?: number;
limit?: number;
}
export interface EmployeeSerialPagination {
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface EmployeeSerialResponse {
data: EmployeeSerial[];
pagination: EmployeeSerialPagination;
}