diff --git a/config/routes.ts b/config/routes.ts index 4c99e91..4513fcd 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -246,6 +246,11 @@ export default [ path: '/jobfair/outdoor-fair/detail', component: './Jobfair/Outdoorfair/Detail', }, + { + name: '招聘会数据统计', + path: '/jobfair/fair-statistics', + component: './Jobfair/FairStatistics', + }, { name: '跨域联合招聘会管理', path: '/jobfair/cross-city-fair', diff --git a/src/pages/Jobfair/FairStatistics/index.tsx b/src/pages/Jobfair/FairStatistics/index.tsx new file mode 100644 index 0000000..cb8f84a --- /dev/null +++ b/src/pages/Jobfair/FairStatistics/index.tsx @@ -0,0 +1,209 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Card, + Col, + DatePicker, + Empty, + Row, + Segmented, + Spin, + Statistic, + Table, + Typography, + message, +} from 'antd'; +import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons'; +import { PageContainer } from '@ant-design/pro-components'; +import { Line } from '@ant-design/charts'; +import type { ColumnsType } from 'antd/es/table'; +import { getOutdoorFairStatistics } from '@/services/jobportal/outdoorFair'; +import type { + OutdoorFairStatisticsResult, + OutdoorFairStatisticsSeriesItem, +} from '@/services/jobportal/outdoorFair'; + +const { RangePicker } = DatePicker; +const { Text } = Typography; + +type Granularity = 'day' | 'week' | 'month' | 'year'; + +const GRAN_OPTIONS: { label: string; value: Granularity }[] = [ + { label: '按日', value: 'day' }, + { label: '按周', value: 'week' }, + { label: '按月', value: 'month' }, + { label: '按年', value: 'year' }, +]; + +const formatBound = (value: any, suffix: string): string | undefined => { + if (value && typeof value.format === 'function') { + return `${value.format('YYYY-MM-DD')} ${suffix}`; + } + return undefined; +}; + +const FairStatistics: React.FC = () => { + const [granularity, setGranularity] = useState('month'); + const [dateRange, setDateRange] = useState(null); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + const fetchStats = async (gran: Granularity, range: any) => { + setLoading(true); + try { + const res = await getOutdoorFairStatistics({ + granularity: gran, + startTime: formatBound(range?.[0], '00:00:00'), + endTime: formatBound(range?.[1], '23:59:59'), + }); + if (res?.code === 200 && res.data) { + setResult(res.data); + } else { + message.error(res?.msg || '统计失败'); + } + } catch (e) { + message.error('统计失败'); + } finally { + setLoading(false); + } + }; + + // 粒度或日期变化即查询;区间半选(只选了一端)不查询,避免中途无效查询 + useEffect(() => { + const complete = dateRange && dateRange[0] && dateRange[1]; + const empty = !dateRange || (!dateRange[0] && !dateRange[1]); + if (complete) { + fetchStats(granularity, dateRange); + } else if (empty) { + fetchStats(granularity, null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [granularity, dateRange]); + + const chartData = useMemo(() => { + if (!result?.series) return []; + const rows: { time: string; category: string; value: number }[] = []; + result.series.forEach((item: OutdoorFairStatisticsSeriesItem) => { + rows.push({ time: item.time, category: '招聘会数', value: item.fairCount }); + rows.push({ time: item.time, category: '参会单位数', value: item.companyCount }); + rows.push({ time: item.time, category: '职位数', value: item.jobCount }); + }); + return rows; + }, [result]); + + const columns: ColumnsType = [ + { title: '时间', dataIndex: 'time', width: 140 }, + { title: '招聘会数', dataIndex: 'fairCount', width: 120 }, + { title: '参会单位数', dataIndex: 'companyCount', width: 120 }, + { title: '职位数', dataIndex: 'jobCount', width: 120 }, + ]; + + return ( + + + + + 统计粒度 + setGranularity(v as Granularity)} + options={GRAN_OPTIONS} + /> + + + 时间区间 + setDateRange(v)} + style={{ width: 360 }} + /> + + + + + + {result ? ( + <> + + + + +
+ + + + +
+
+ + + + + + + + + + + +
+ + + {chartData.length > 0 ? ( + + ) : ( + + )} + + + + + rowKey="time" + dataSource={result.series || []} + columns={columns} + pagination={false} + size="small" + /> + + + ) : ( + + )} +
+
+ ); +}; + +const RateText: React.FC<{ label: string; rate?: number | null }> = ({ label, rate }) => { + if (rate === null || rate === undefined) { + return ( + + {label}:-- + + ); + } + const up = rate >= 0; + const color = up ? '#cf1322' : '#3f8600'; + const pct = `${(rate * 100).toFixed(1)}%`; + return ( + + {label}: + {up ? : } {pct} + + ); +}; + +export default FairStatistics; diff --git a/src/pages/Jobfair/Outdoorfair/Detail/components/AttendeeTab.tsx b/src/pages/Jobfair/Outdoorfair/Detail/components/AttendeeTab.tsx new file mode 100644 index 0000000..d58f267 --- /dev/null +++ b/src/pages/Jobfair/Outdoorfair/Detail/components/AttendeeTab.tsx @@ -0,0 +1,174 @@ +import React, { useRef, useState } from 'react'; +import { useAccess } from '@umijs/max'; +import { Button, Modal, message } from 'antd'; +import { ActionType, ProColumns, ProTable, ModalForm, ProFormText, ProFormDateTimePicker } from '@ant-design/pro-components'; +import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'; +import { + getOutdoorFairAttendeeList, + addOutdoorFairAttendee, + updateOutdoorFairAttendee, + deleteOutdoorFairAttendee, +} from '@/services/jobportal/outdoorFairAttendee'; +import type { + OutdoorFairAttendeeItem, + OutdoorFairAttendeeForm, +} from '@/services/jobportal/outdoorFairAttendee'; + +interface Props { + fairId: number; +} + +const AttendeeTab: React.FC = ({ fairId }) => { + const access = useAccess(); + const actionRef = useRef(); + const [modalVisible, setModalVisible] = useState(false); + const [currentRow, setCurrentRow] = useState(); + + const handleDelete = (id: number) => { + Modal.confirm({ + title: '确认删除', + content: '确定要删除该签到人员吗?', + onOk: async () => { + const res = await deleteOutdoorFairAttendee(id); + if (res.code === 200) { + message.success('删除成功'); + actionRef.current?.reload(); + } else { + message.error(res.msg || '删除失败'); + } + }, + }); + }; + + const columns: ProColumns[] = [ + { title: '人员', dataIndex: 'name', ellipsis: true }, + { title: '手机号', dataIndex: 'phone', ellipsis: true, hideInSearch: true }, + { title: '住址', dataIndex: 'address', ellipsis: true, hideInSearch: true }, + { + title: '签到时间', + dataIndex: 'checkInTime', + valueType: 'dateTime', + hideInSearch: true, + }, + { + title: '操作', + valueType: 'option', + width: 160, + fixed: 'right', + render: (_, record) => [ + , + , + ], + }, + ]; + + return ( + <> + + actionRef={actionRef} + rowKey="id" + headerTitle="参会人员管理" + search={{ labelWidth: 'auto' }} + options={false} + pagination={{ pageSize: 10 }} + scroll={{ x: 'max-content' }} + toolBarRender={() => [ + , + ]} + request={async (params) => { + const res = await getOutdoorFairAttendeeList({ + fairId, + current: params.current, + pageSize: params.pageSize, + name: params.name, + }); + return { data: res.rows || [], total: res.total || 0, success: res.code === 200 }; + }} + columns={columns} + /> + + + title={currentRow ? '编辑签到人员' : '录入签到人员'} + open={modalVisible} + modalProps={{ destroyOnClose: true, onCancel: () => setModalVisible(false) }} + initialValues={ + currentRow + ? { + id: currentRow.id, + fairId, + name: currentRow.name, + phone: currentRow.phone, + address: currentRow.address, + checkInTime: currentRow.checkInTime, + } + : { fairId } + } + onFinish={async (values) => { + const payload = { ...values, fairId }; + const res = values.id + ? await updateOutdoorFairAttendee(payload) + : await addOutdoorFairAttendee(payload); + if (res.code === 200) { + message.success(values.id ? '修改成功' : '新增成功'); + setModalVisible(false); + setCurrentRow(undefined); + actionRef.current?.reload(); + return true; + } + message.error(res.msg || '操作失败'); + return false; + }} + > + + + + + + + ); +}; + +export default AttendeeTab; diff --git a/src/pages/Jobfair/Outdoorfair/Detail/components/DeviceTab.tsx b/src/pages/Jobfair/Outdoorfair/Detail/components/DeviceTab.tsx new file mode 100644 index 0000000..0b22ea6 --- /dev/null +++ b/src/pages/Jobfair/Outdoorfair/Detail/components/DeviceTab.tsx @@ -0,0 +1,249 @@ +import React, { useRef, useState } from 'react'; +import { useAccess } from '@umijs/max'; +import { Button, Modal, message, Typography } from 'antd'; +import { + ActionType, + ProColumns, + ProTable, + ModalForm, + ProFormDigit, + ProFormSelect, +} from '@ant-design/pro-components'; +import { PlusOutlined, DeleteOutlined, LinkOutlined, DisconnectOutlined } from '@ant-design/icons'; +import { + getOutdoorFairDeviceList, + batchGenerateOutdoorFairDevice, + bindOutdoorFairDevice, + deleteOutdoorFairDevice, + getOutdoorFairCompanyOptions, +} from '@/services/jobportal/outdoorFairDevice'; +import type { OutdoorFairDeviceItem } from '@/services/jobportal/outdoorFairDevice'; + +interface Props { + fairId: number; +} + +const DeviceTab: React.FC = ({ fairId }) => { + const access = useAccess(); + const actionRef = useRef(); + const [batchVisible, setBatchVisible] = useState(false); + const [bindVisible, setBindVisible] = useState(false); + const [currentRow, setCurrentRow] = useState(); + + const handleDelete = (id: number) => { + Modal.confirm({ + title: '确认删除', + content: '确定要删除该设备码吗?', + onOk: async () => { + const res = await deleteOutdoorFairDevice(id); + if (res.code === 200) { + message.success('删除成功'); + actionRef.current?.reload(); + } else { + message.error(res.msg || '删除失败'); + } + }, + }); + }; + + const handleUnbind = (record: OutdoorFairDeviceItem) => { + Modal.confirm({ + title: '确认解绑', + content: `确定要解除设备码与「${record.companyName}」的绑定吗?`, + onOk: async () => { + const res = await bindOutdoorFairDevice(record.id, undefined); + if (res.code === 200) { + message.success('解绑成功'); + actionRef.current?.reload(); + } else { + message.error(res.msg || '解绑失败'); + } + }, + }); + }; + + const columns: ProColumns[] = [ + { + title: '设备码', + dataIndex: 'deviceCode', + ellipsis: true, + render: (_, record) => ( + + {record.deviceCode} + + ), + }, + { + title: '绑定企业', + dataIndex: 'companyName', + ellipsis: true, + hideInSearch: true, + render: (_, record) => (record.companyName ? record.companyName : '— 未绑定 —'), + }, + { + title: '绑定时间', + dataIndex: 'bindTime', + valueType: 'dateTime', + hideInSearch: true, + }, + { + title: '操作', + valueType: 'option', + width: 200, + fixed: 'right', + render: (_, record) => [ + record.companyId ? ( + + ) : ( + + ), + , + ], + }, + ]; + + return ( + <> + + actionRef={actionRef} + rowKey="id" + headerTitle="设备管理" + search={{ labelWidth: 'auto' }} + options={false} + pagination={{ pageSize: 10 }} + scroll={{ x: 'max-content' }} + toolBarRender={() => [ + , + ]} + request={async (params) => { + const res = await getOutdoorFairDeviceList({ + fairId, + current: params.current, + pageSize: params.pageSize, + deviceCode: params.deviceCode, + companyName: params.companyName, + }); + return { data: res.rows || [], total: res.total || 0, success: res.code === 200 }; + }} + columns={columns} + /> + + + title="批量生成设备码" + open={batchVisible} + modalProps={{ destroyOnClose: true, onCancel: () => setBatchVisible(false) }} + initialValues={{ quantity: 10 }} + onFinish={async (values) => { + const res = await batchGenerateOutdoorFairDevice({ + fairId, + quantity: Number(values.quantity), + }); + if (res.code === 200) { + message.success(`成功生成 ${res.data?.length || values.quantity} 个设备码`); + setBatchVisible(false); + actionRef.current?.reload(); + return true; + } + message.error(res.msg || '生成失败'); + return false; + }} + > + + + + + title="绑定参会企业" + open={bindVisible} + modalProps={{ destroyOnClose: true, onCancel: () => setBindVisible(false) }} + onFinish={async (values) => { + if (!currentRow) return false; + const res = await bindOutdoorFairDevice(currentRow.id, values.companyId); + if (res.code === 200) { + message.success('绑定成功'); + setBindVisible(false); + setCurrentRow(undefined); + actionRef.current?.reload(); + return true; + } + message.error(res.msg || '绑定失败'); + return false; + }} + > + { + const [companies, devicesRes] = await Promise.all([ + getOutdoorFairCompanyOptions(fairId), + getOutdoorFairDeviceList({ fairId, current: 1, pageSize: 1000 }), + ]); + // 排除已绑定到其他设备的企业 + const boundIds = new Set( + (devicesRes.rows || []) + .map((d) => d.companyId) + .filter((cid): cid is number => cid != null), + ); + return companies + .filter((item) => !boundIds.has(item.companyId)) + .map((item) => ({ + label: item.companyName, + value: item.companyId, + })); + }} + rules={[{ required: true, message: '请选择参会企业' }]} + /> + + + ); +}; + +export default DeviceTab; diff --git a/src/pages/Jobfair/Outdoorfair/Detail/index.tsx b/src/pages/Jobfair/Outdoorfair/Detail/index.tsx index fd47220..7cb3657 100644 --- a/src/pages/Jobfair/Outdoorfair/Detail/index.tsx +++ b/src/pages/Jobfair/Outdoorfair/Detail/index.tsx @@ -9,6 +9,8 @@ import FairInfoTab from './components/FairInfoTab'; import BoothTab from './components/BoothTab'; import ParticipatingCompaniesTab from './components/ParticipatingCompaniesTab'; import ParticipatingJobsTab from './components/ParticipatingJobsTab'; +import AttendeeTab from './components/AttendeeTab'; +import DeviceTab from './components/DeviceTab'; const OutdoorFairDetail: React.FC = () => { const [searchParams] = useSearchParams(); @@ -41,6 +43,8 @@ const OutdoorFairDetail: React.FC = () => { { key: 'fair-info', label: '招聘会管理' }, { key: 'participating-companies', label: '参会企业' }, { key: 'participating-jobs', label: '参会岗位' }, + { key: 'attendee', label: '参会人员管理' }, + { key: 'device', label: '设备管理' }, { key: 'booth', label: '展位图' }, ]; @@ -84,6 +88,10 @@ const OutdoorFairDetail: React.FC = () => { return ; case 'participating-jobs': return ; + case 'attendee': + return ; + case 'device': + return ; default: return null; } diff --git a/src/services/jobportal/outdoorFair.ts b/src/services/jobportal/outdoorFair.ts index 357af15..5b02ba3 100644 --- a/src/services/jobportal/outdoorFair.ts +++ b/src/services/jobportal/outdoorFair.ts @@ -129,6 +129,53 @@ export async function getOutdoorFairInfo(id: number) { }); } +/** 招聘会统计-时间桶序列项(曲线图用) */ +export interface OutdoorFairStatisticsSeriesItem { + time: string; + fairCount: number; + companyCount: number; + jobCount: number; +} + +/** 招聘会数同比/环比 */ +export interface OutdoorFairStatisticsRate { + fairCount: number; + previousFairCount: number; + /** 增长率:(current-previous)/previous;上期为 0 时为 null */ + rate?: number | null; +} + +/** 招聘会统计结果(dashboard) */ +export interface OutdoorFairStatisticsResult { + granularity: 'day' | 'week' | 'month' | 'year'; + totals: { fairCount: number; companyCount: number; jobCount: number }; + yoy: OutdoorFairStatisticsRate; + mom: OutdoorFairStatisticsRate; + series: OutdoorFairStatisticsSeriesItem[]; +} + +/** 招聘会统计查询参数 */ +export interface OutdoorFairStatisticsParams { + /** 粒度:day/week/month/year */ + granularity?: 'day' | 'week' | 'month' | 'year'; + /** 起始时间 yyyy-MM-dd HH:mm:ss */ + startTime?: string; + /** 截止时间 yyyy-MM-dd HH:mm:ss */ + endTime?: string; +} + +/** + * 招聘会数据统计 + * GET /api/cms/outdoor-fair/statistics + * skipErrorHandler: 由调用方自行处理错误,避免全局弹窗把 null msg 显示成 "null" + */ +export async function getOutdoorFairStatistics(params: OutdoorFairStatisticsParams) { + return request<{ code: number; msg?: string; data: OutdoorFairStatisticsResult }>( + `${CMS_OUTDOOR_FAIR_BASE}/statistics`, + { method: 'GET', params, skipErrorHandler: true }, + ); +} + /** * 新增户外招聘会 * POST /api/cms/outdoor-fair diff --git a/src/services/jobportal/outdoorFairAttendee.ts b/src/services/jobportal/outdoorFairAttendee.ts new file mode 100644 index 0000000..cdbcb3c --- /dev/null +++ b/src/services/jobportal/outdoorFairAttendee.ts @@ -0,0 +1,106 @@ +import { request } from '@umijs/max'; + +/** 户外招聘会签到人员列表项 */ +export interface OutdoorFairAttendeeItem { + id: number; + fairId: number; + /** 人员姓名 */ + name: string; + /** 手机号 */ + phone?: string; + /** 住址 */ + address?: string; + /** 签到时间 */ + checkInTime?: string; + createBy?: string; + createTime?: string; + updateBy?: string; + updateTime?: string; + remark?: string; +} + +/** 签到人员查询参数 */ +export interface OutdoorFairAttendeeListParams { + fairId?: number; + name?: string; + phone?: string; + current?: number; + pageSize?: number; +} + +/** 签到人员列表响应 */ +export interface OutdoorFairAttendeeListResult { + code: number; + msg?: string; + total: number; + rows: OutdoorFairAttendeeItem[]; +} + +/** 签到人员新增/编辑表单 */ +export interface OutdoorFairAttendeeForm { + id?: number; + fairId: number; + name: string; + phone?: string; + address?: string; + checkInTime?: string; +} + +/** 通用响应 */ +export interface OutdoorFairAttendeeCommonResult { + code: number; + msg?: string; + data?: OutdoorFairAttendeeItem; +} + +const BASE = '/api/cms/outdoor-fair-attendee'; + +const formatDateTime = (value: any) => { + if (value && typeof value.format === 'function') { + return value.format('YYYY-MM-DD HH:mm:ss'); + } + return value; +}; + +/** + * 获取签到人员列表 + * GET /api/cms/outdoor-fair-attendee/list + */ +export async function getOutdoorFairAttendeeList(params?: OutdoorFairAttendeeListParams) { + return request(`${BASE}/list`, { + method: 'GET', + params, + }); +} + +/** + * 新增签到人员 + * POST /api/cms/outdoor-fair-attendee + */ +export async function addOutdoorFairAttendee(data: OutdoorFairAttendeeForm) { + return request(BASE, { + method: 'POST', + data: { ...data, checkInTime: formatDateTime(data.checkInTime) }, + }); +} + +/** + * 修改签到人员 + * PUT /api/cms/outdoor-fair-attendee + */ +export async function updateOutdoorFairAttendee(data: OutdoorFairAttendeeForm) { + return request(BASE, { + method: 'PUT', + data: { ...data, checkInTime: formatDateTime(data.checkInTime) }, + }); +} + +/** + * 删除签到人员 + * DELETE /api/cms/outdoor-fair-attendee/{ids} + */ +export async function deleteOutdoorFairAttendee(ids: number | string) { + return request(`${BASE}/${ids}`, { + method: 'DELETE', + }); +} diff --git a/src/services/jobportal/outdoorFairDevice.ts b/src/services/jobportal/outdoorFairDevice.ts new file mode 100644 index 0000000..4ee3db9 --- /dev/null +++ b/src/services/jobportal/outdoorFairDevice.ts @@ -0,0 +1,130 @@ +import { request } from '@umijs/max'; + +/** 户外招聘会设备码列表项 */ +export interface OutdoorFairDeviceItem { + id: number; + fairId: number; + /** 设备码(UUID) */ + deviceCode: string; + /** 绑定的参会企业ID */ + companyId?: number; + /** 绑定的参会企业名称 */ + companyName?: string; + /** 绑定时间 */ + bindTime?: string; + createBy?: string; + createTime?: string; + updateBy?: string; + updateTime?: string; + remark?: string; +} + +/** 设备码查询参数 */ +export interface OutdoorFairDeviceListParams { + fairId?: number; + deviceCode?: string; + companyName?: string; + companyId?: number; + current?: number; + pageSize?: number; +} + +/** 设备码列表响应 */ +export interface OutdoorFairDeviceListResult { + code: number; + msg?: string; + total: number; + rows: OutdoorFairDeviceItem[]; +} + +/** 批量生成参数 */ +export interface OutdoorFairDeviceBatchParams { + fairId: number; + quantity: number; +} + +/** 批量生成响应 */ +export interface OutdoorFairDeviceBatchResult { + code: number; + msg?: string; + data?: OutdoorFairDeviceItem[]; +} + +/** 通用响应 */ +export interface OutdoorFairDeviceCommonResult { + code: number; + msg?: string; + data?: OutdoorFairDeviceItem; +} + +/** 参会企业下拉选项 */ +export interface OutdoorFairCompanyOption { + companyId: number; + companyName: string; +} + +const BASE = '/api/cms/outdoor-fair-device'; + +/** + * 获取设备码列表 + * GET /api/cms/outdoor-fair-device/list + */ +export async function getOutdoorFairDeviceList(params?: OutdoorFairDeviceListParams) { + return request(`${BASE}/list`, { + method: 'GET', + params, + }); +} + +/** + * 批量生成设备码(UUID) + * POST /api/cms/outdoor-fair-device/batch + */ +export async function batchGenerateOutdoorFairDevice(data: OutdoorFairDeviceBatchParams) { + return request(`${BASE}/batch`, { + method: 'POST', + data, + }); +} + +/** + * 绑定/解绑参会企业(companyId 为空表示解绑) + * PUT /api/cms/outdoor-fair-device/{id}/bind + */ +export async function bindOutdoorFairDevice(id: number, companyId?: number) { + return request(`${BASE}/${id}/bind`, { + method: 'PUT', + data: { companyId: companyId ?? null }, + }); +} + +/** + * 删除设备码 + * DELETE /api/cms/outdoor-fair-device/{ids} + */ +export async function deleteOutdoorFairDevice(ids: number | string) { + return request(`${BASE}/${ids}`, { + method: 'DELETE', + }); +} + +/** + * 获取招聘会参会企业下拉(复用主招聘会参会企业接口) + */ +export async function getOutdoorFairCompanyOptions( + fairId: number, +): Promise { + const res = await request<{ code: number; rows?: any[]; total?: number }>( + `/api/cms/outdoor-fair/${fairId}/companies`, + { method: 'GET', params: { current: 1, pageSize: 1000 } }, + ); + if (res.code === 200 && Array.isArray(res.rows)) { + return res.rows + .filter((item) => item && item.companyId != null) + .map((item) => ({ + companyId: item.companyId, + companyName: item.companyName, + })); + } + return []; +}