Add employee serial QR query support

This commit is contained in:
Frudrax Cheng
2026-05-28 10:24:49 +08:00
parent b9bc8f5419
commit 06da68e41b
5 changed files with 106 additions and 26 deletions
+2
View File
@@ -118,6 +118,8 @@ src/
- Password field is shown and required only for `admin` and `technician`.
- Employee creation uses `employeesApi.create`, and the backend automatically creates the employee serial; do not implement a separate "create then assign code" primary flow.
- Employee rows should display generated `employeeSerials` from the employee list response.
- Employee rows should provide a QR-code view for the active employee serial, using `/query?serial=...` as the scan target.
- Public employee serial queries should show employee name, phone, employee number, and position.
### Aftersales Conventions
- Aftersales serial format is `zjbf-sh-YYMMDDNN` (daily sequence), e.g. `zjbf-sh-26052801`.
+2
View File
@@ -110,6 +110,8 @@ VITE_API_BASE_URL=/api
- 管理员/技术员有后台登录权限,创建时显示并必填初始密码
- 员工无后台权限,创建时不显示密码框
- 创建员工后自动生成员工码,列表直接展示员工码
- 支持查看员工码二维码,扫码进入公开查询页
- 员工码查询页展示姓名、电话、工号、岗位
- 售后工单
- 技术员创建工单、填写处理结果、提交客户确认
- 服务类型:软件故障 / 硬件故障 / 售后维保
+75 -15
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import QRCode from 'qrcode';
import {
Card,
Table,
@@ -17,8 +18,10 @@ import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
QrcodeOutlined,
KeyOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { authApi, employeesApi } from '@/services/api';
import type { User, UserRole, CreateUserRequest, UpdateUserRequest, EmployeeSerial } from '@/types';
@@ -42,21 +45,10 @@ const ROLE_OPTIONS = (Object.keys(ROLE_LABEL) as UserRole[]).map((value) => ({
const canLoginBackend = (role?: UserRole) => !!role && BACKEND_ROLES.includes(role);
function renderEmployeeSerial(serials?: EmployeeSerial[]) {
const activeSerial = serials?.find((serial) => serial.isActive) || serials?.[0];
if (!activeSerial) return '-';
return (
<Space direction="vertical" size={0}>
<span style={{ fontFamily: 'monospace' }}>{activeSerial.serialNumber}</span>
<Tag color={activeSerial.isActive ? 'green' : 'red'}>
{activeSerial.isActive ? '有效' : '已吊销'}
</Tag>
</Space>
);
}
const getDisplaySerial = (serials?: EmployeeSerial[]) => serials?.find((serial) => serial.isActive) || serials?.[0];
function EmployeeSerialsPage() {
const navigate = useNavigate();
const currentUser = authApi.getCurrentUser();
const isAdmin = currentUser?.role === 'admin';
@@ -81,6 +73,10 @@ function EmployeeSerialsPage() {
const [resetLoading, setResetLoading] = useState(false);
const [resetForm] = Form.useForm<{ newPassword: string }>();
const [qrCodeVisible, setQrCodeVisible] = useState(false);
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
const [selectedSerial, setSelectedSerial] = useState('');
const loadEmployees = async () => {
setLoading(true);
try {
@@ -192,6 +188,23 @@ function EmployeeSerialsPage() {
});
};
const handleViewQRCode = async (serialNumber: string) => {
try {
const queryUrl = `${window.location.origin}/query?serial=${serialNumber}`;
const qrCode = await QRCode.toDataURL(queryUrl);
setQrCodeDataUrl(qrCode);
setSelectedSerial(serialNumber);
setQrCodeVisible(true);
} catch (err: any) {
message.error(err?.message || '生成二维码失败');
}
};
const handleQuerySerial = (serialNumber: string) => {
setQrCodeVisible(false);
navigate(`/query?serial=${serialNumber}`);
};
const columns = useMemo(
() => [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
@@ -209,8 +222,30 @@ function EmployeeSerialsPage() {
title: '员工码',
dataIndex: 'employeeSerials',
key: 'employeeSerials',
width: 190,
render: (serials?: EmployeeSerial[]) => renderEmployeeSerial(serials),
width: 240,
render: (serials?: EmployeeSerial[]) => {
const serial = getDisplaySerial(serials);
if (!serial) return '-';
return (
<Space direction="vertical" size={0}>
<Space size={8}>
<span style={{ fontFamily: 'monospace' }}>{serial.serialNumber}</span>
<Button
type="link"
size="small"
icon={<QrcodeOutlined />}
onClick={() => handleViewQRCode(serial.serialNumber)}
>
</Button>
</Space>
<Tag color={serial.isActive ? 'green' : 'red'}>
{serial.isActive ? '有效' : '已吊销'}
</Tag>
</Space>
);
},
},
{
title: '创建时间',
@@ -397,6 +432,31 @@ function EmployeeSerialsPage() {
</Form>
</Modal>
<Modal
title="员工码二维码"
open={qrCodeVisible}
onCancel={() => setQrCodeVisible(false)}
footer={null}
width={400}
>
<div style={{ textAlign: 'center' }}>
{qrCodeDataUrl && (
<>
<img
src={qrCodeDataUrl}
alt="员工码二维码"
style={{ width: 200, height: 200, cursor: 'pointer' }}
onClick={() => handleQuerySerial(selectedSerial)}
/>
<p style={{ marginTop: 12, fontFamily: 'monospace', fontSize: 16, fontWeight: 'bold', color: '#165DFF' }}>
{selectedSerial}
</p>
<p style={{ marginTop: 8, fontSize: 12, color: '#999' }}></p>
</>
)}
</div>
</Modal>
<Modal
title={resetPasswordEmployee ? `重置密码:${resetPasswordEmployee.name}` : '重置密码'}
open={!!resetPasswordEmployee}
+26 -11
View File
@@ -9,7 +9,7 @@ import {
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { employeeSerialApi } from '@/services/api';
import type { Serial } from '@/types';
import type { Serial, User } from '@/types';
import PublicLayout, { PublicLogo } from '@/components/PublicLayout';
import './styles/PublicQuery.css';
@@ -18,6 +18,7 @@ interface EmployeeSerialResult {
companyName: string;
position: string;
employeeName: string;
employee?: User;
isActive: boolean;
createdAt: string;
}
@@ -93,6 +94,11 @@ function PublicQueryPage() {
setError(null);
};
const getEmployeeName = (serial: EmployeeSerialResult) => serial.employee?.name || serial.employeeName || '-';
const getEmployeePhone = (serial: EmployeeSerialResult) => serial.employee?.phone || '-';
const getEmployeeNo = (serial: EmployeeSerialResult) => serial.employee?.employeeNo || '-';
const getEmployeePosition = (serial: EmployeeSerialResult) => serial.employee?.position || serial.position || '-';
return (
<PublicLayout>
{!showResult ? (
@@ -143,7 +149,7 @@ function PublicQueryPage() {
title={serialType === 'employee' ? '员工身份已吊销' : '授权已吊销'}
subTitle={
serialType === 'employee'
? `序列号验证通过,但已被吊销。企业${(result as EmployeeSerialResult).companyName}`
? `序列号验证通过,但员工身份已被吊销。姓名${getEmployeeName(result as EmployeeSerialResult)}`
: `序列号验证通过,但已被吊销。企业:${(result as Serial).companyName}`
}
/>
@@ -160,21 +166,30 @@ function PublicQueryPage() {
<span className="label"></span>
<span className="value serial">{result.serialNumber}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{result.companyName}</span>
</div>
{serialType === 'employee' && (result as EmployeeSerialResult).position && (
{serialType === 'employee' ? (
<>
<div className="detail-item">
<span className="label"></span>
<span className="value">{(result as EmployeeSerialResult).position}</span>
<span className="label"></span>
<span className="value">{getEmployeeName(result as EmployeeSerialResult)}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{(result as EmployeeSerialResult).employeeName}</span>
<span className="label"></span>
<span className="value">{getEmployeePhone(result as EmployeeSerialResult)}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{getEmployeeNo(result as EmployeeSerialResult)}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{getEmployeePosition(result as EmployeeSerialResult)}</span>
</div>
</>
) : (
<div className="detail-item">
<span className="label"></span>
<span className="value">{result.companyName}</span>
</div>
)}
{serialType !== 'employee' && (result as Serial).validUntil && (
<div className="detail-item">
+1
View File
@@ -151,6 +151,7 @@ export interface EmployeeSerial {
position: string;
employeeName: string;
employeeId?: number;
employee?: User;
isActive: boolean;
createdAt: string;
updatedAt?: string;