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`. - 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 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 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 Conventions
- Aftersales serial format is `zjbf-sh-YYMMDDNN` (daily sequence), e.g. `zjbf-sh-26052801`. - 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 { useEffect, useMemo, useState } from 'react';
import QRCode from 'qrcode';
import { import {
Card, Card,
Table, Table,
@@ -17,8 +18,10 @@ import {
PlusOutlined, PlusOutlined,
EditOutlined, EditOutlined,
DeleteOutlined, DeleteOutlined,
QrcodeOutlined,
KeyOutlined, KeyOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { authApi, employeesApi } from '@/services/api'; import { authApi, employeesApi } from '@/services/api';
import type { User, UserRole, CreateUserRequest, UpdateUserRequest, EmployeeSerial } from '@/types'; 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); const canLoginBackend = (role?: UserRole) => !!role && BACKEND_ROLES.includes(role);
function renderEmployeeSerial(serials?: EmployeeSerial[]) { const getDisplaySerial = (serials?: EmployeeSerial[]) => serials?.find((serial) => serial.isActive) || serials?.[0];
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>
);
}
function EmployeeSerialsPage() { function EmployeeSerialsPage() {
const navigate = useNavigate();
const currentUser = authApi.getCurrentUser(); const currentUser = authApi.getCurrentUser();
const isAdmin = currentUser?.role === 'admin'; const isAdmin = currentUser?.role === 'admin';
@@ -81,6 +73,10 @@ function EmployeeSerialsPage() {
const [resetLoading, setResetLoading] = useState(false); const [resetLoading, setResetLoading] = useState(false);
const [resetForm] = Form.useForm<{ newPassword: string }>(); const [resetForm] = Form.useForm<{ newPassword: string }>();
const [qrCodeVisible, setQrCodeVisible] = useState(false);
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
const [selectedSerial, setSelectedSerial] = useState('');
const loadEmployees = async () => { const loadEmployees = async () => {
setLoading(true); setLoading(true);
try { 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( const columns = useMemo(
() => [ () => [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120 }, { title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
@@ -209,8 +222,30 @@ function EmployeeSerialsPage() {
title: '员工码', title: '员工码',
dataIndex: 'employeeSerials', dataIndex: 'employeeSerials',
key: 'employeeSerials', key: 'employeeSerials',
width: 190, width: 240,
render: (serials?: EmployeeSerial[]) => renderEmployeeSerial(serials), 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: '创建时间', title: '创建时间',
@@ -397,6 +432,31 @@ function EmployeeSerialsPage() {
</Form> </Form>
</Modal> </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 <Modal
title={resetPasswordEmployee ? `重置密码:${resetPasswordEmployee.name}` : '重置密码'} title={resetPasswordEmployee ? `重置密码:${resetPasswordEmployee.name}` : '重置密码'}
open={!!resetPasswordEmployee} open={!!resetPasswordEmployee}
+26 -11
View File
@@ -9,7 +9,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { employeeSerialApi } from '@/services/api'; import { employeeSerialApi } from '@/services/api';
import type { Serial } from '@/types'; import type { Serial, User } from '@/types';
import PublicLayout, { PublicLogo } from '@/components/PublicLayout'; import PublicLayout, { PublicLogo } from '@/components/PublicLayout';
import './styles/PublicQuery.css'; import './styles/PublicQuery.css';
@@ -18,6 +18,7 @@ interface EmployeeSerialResult {
companyName: string; companyName: string;
position: string; position: string;
employeeName: string; employeeName: string;
employee?: User;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
} }
@@ -93,6 +94,11 @@ function PublicQueryPage() {
setError(null); 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 ( return (
<PublicLayout> <PublicLayout>
{!showResult ? ( {!showResult ? (
@@ -143,7 +149,7 @@ function PublicQueryPage() {
title={serialType === 'employee' ? '员工身份已吊销' : '授权已吊销'} title={serialType === 'employee' ? '员工身份已吊销' : '授权已吊销'}
subTitle={ subTitle={
serialType === 'employee' serialType === 'employee'
? `序列号验证通过,但已被吊销。企业${(result as EmployeeSerialResult).companyName}` ? `序列号验证通过,但员工身份已被吊销。姓名${getEmployeeName(result as EmployeeSerialResult)}`
: `序列号验证通过,但已被吊销。企业:${(result as Serial).companyName}` : `序列号验证通过,但已被吊销。企业:${(result as Serial).companyName}`
} }
/> />
@@ -160,21 +166,30 @@ function PublicQueryPage() {
<span className="label"></span> <span className="label"></span>
<span className="value serial">{result.serialNumber}</span> <span className="value serial">{result.serialNumber}</span>
</div> </div>
<div className="detail-item"> {serialType === 'employee' ? (
<span className="label"></span>
<span className="value">{result.companyName}</span>
</div>
{serialType === 'employee' && (result as EmployeeSerialResult).position && (
<> <>
<div className="detail-item"> <div className="detail-item">
<span className="label"></span> <span className="label"></span>
<span className="value">{(result as EmployeeSerialResult).position}</span> <span className="value">{getEmployeeName(result as EmployeeSerialResult)}</span>
</div> </div>
<div className="detail-item"> <div className="detail-item">
<span className="label"></span> <span className="label"></span>
<span className="value">{(result as EmployeeSerialResult).employeeName}</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>
</> </>
) : (
<div className="detail-item">
<span className="label"></span>
<span className="value">{result.companyName}</span>
</div>
)} )}
{serialType !== 'employee' && (result as Serial).validUntil && ( {serialType !== 'employee' && (result as Serial).validUntil && (
<div className="detail-item"> <div className="detail-item">
+1
View File
@@ -151,6 +151,7 @@ export interface EmployeeSerial {
position: string; position: string;
employeeName: string; employeeName: string;
employeeId?: number; employeeId?: number;
employee?: User;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
updatedAt?: string; updatedAt?: string;