Add employee serial QR query support
This commit is contained in:
@@ -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`.
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ VITE_API_BASE_URL=/api
|
|||||||
- 管理员/技术员有后台登录权限,创建时显示并必填初始密码
|
- 管理员/技术员有后台登录权限,创建时显示并必填初始密码
|
||||||
- 员工无后台权限,创建时不显示密码框
|
- 员工无后台权限,创建时不显示密码框
|
||||||
- 创建员工后自动生成员工码,列表直接展示员工码
|
- 创建员工后自动生成员工码,列表直接展示员工码
|
||||||
|
- 支持查看员工码二维码,扫码进入公开查询页
|
||||||
|
- 员工码查询页展示姓名、电话、工号、岗位
|
||||||
- 售后工单
|
- 售后工单
|
||||||
- 技术员创建工单、填写处理结果、提交客户确认
|
- 技术员创建工单、填写处理结果、提交客户确认
|
||||||
- 服务类型:软件故障 / 硬件故障 / 售后维保
|
- 服务类型:软件故障 / 硬件故障 / 售后维保
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
+28
-13
@@ -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>
|
||||||
|
{serialType === 'employee' ? (
|
||||||
|
<>
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="label">姓名</span>
|
||||||
|
<span className="value">{getEmployeeName(result as EmployeeSerialResult)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<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">
|
<div className="detail-item">
|
||||||
<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).position && (
|
|
||||||
<>
|
|
||||||
<div className="detail-item">
|
|
||||||
<span className="label">职位</span>
|
|
||||||
<span className="value">{(result as EmployeeSerialResult).position}</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 && (
|
{serialType !== 'employee' && (result as Serial).validUntil && (
|
||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user