import React, { useEffect, useState, useMemo, useRef } from 'react'; import { Card, Select, Button, Space, Spin, Empty, Row, Col, message, DatePicker, Tabs } from 'antd'; import { Line, Heatmap } from '@ant-design/charts'; import { getIndustryTrend, getIndustryAreaTrend, getSalaryTrend } from '@/services/analysis/industry'; import dayjs from 'dayjs'; import { useRequest } from '@umijs/max'; import { TimeDimension, AnalysisType, IndustryTrendState, IndustryDataItem, ChartConfig, } from '@/types/analysis/industry'; import { formatQuarter, formatDateForDisplay, convertApiData, convertSalaryData } from './utils'; const { Option } = Select; const { RangePicker } = DatePicker; const { TabPane } = Tabs; const flattenAreaData = (apiResponse: any) => { if (!apiResponse || typeof apiResponse !== 'object') { return []; } const flattenedData = []; for (const month in apiResponse) { if (apiResponse.hasOwnProperty(month)) { const areas = apiResponse[month]; areas.forEach((area) => { flattenedData.push({ name: area.name, time: area.time, value: parseInt(area.data) || 0, }); }); } } return flattenedData; }; const IndustryTrendPage: React.FC = () => { const [params, setParams] = useState({ timeDimension: '月', type: '岗位发布数量', startTime: dayjs().subtract(5, 'month').format('YYYY-MM'), endTime: dayjs().format('YYYY-MM'), selectedIndustry: '', selectedSalaryRange: '', analysisCategory: 'industry', // 默认显示行业分析 }); const [allData, setAllData] = useState([]); const [areaData, setAreaData] = useState([]); const [salaryData, setSalaryData] = useState([]); const [availableIndustries, setAvailableIndustries] = useState([]); const [availableSalaryRanges, setAvailableSalaryRanges] = useState([]); const heatmapRef = useRef(null); // 获取行业趋势数据 const { loading: industryLoading, run: fetchIndustryData } = useRequest( async () => { let { startTime, endTime, timeDimension, type } = params; if (timeDimension === '季度') { startTime = formatQuarter(startTime); endTime = formatQuarter(endTime); } return await getIndustryTrend({ timeDimension, type, startTime, endTime, }); }, { manual: true, onSuccess: (data) => { const formattedData = convertApiData(data); setAllData(formattedData); const industries = Array.from(new Set(formattedData.map((item: any) => item.category))) .filter(Boolean) .sort(); setAvailableIndustries(industries); if (industries.length > 0 && !industries.includes(params.selectedIndustry)) { setParams((p) => ({ ...p, selectedIndustry: industries[0] })); } }, onError: (error) => { message.error('行业数据加载失败'); }, }, ); // 获取地区趋势数据 const { loading: areaLoading, run: fetchAreaData } = useRequest( async () => { let { startTime, endTime, timeDimension, type } = params; if (timeDimension === '季度') { startTime = formatQuarter(startTime); endTime = formatQuarter(endTime); } return await getIndustryAreaTrend({ timeDimension, type, startTime, endTime, }); }, { manual: true, onSuccess: (res) => { const formattedData = flattenAreaData(res); setAreaData(formattedData); }, onError: (error) => { message.error('地区数据加载失败'); }, }, ); // 获取薪资趋势数据 const { loading: salaryLoading, run: fetchSalaryData } = useRequest( async () => { let { startTime, endTime, timeDimension, type } = params; if (timeDimension === '季度') { startTime = formatQuarter(startTime); endTime = formatQuarter(endTime); } return await getSalaryTrend({ timeDimension, type, startTime, endTime, }); }, { manual: true, onSuccess: (data) => { const formattedData = convertSalaryData(data); setSalaryData(formattedData); const ranges = Array.from(new Set(formattedData.map((item: any) => item.category))) .filter(Boolean) .sort(); setAvailableSalaryRanges(ranges); if (ranges.length > 0 && !ranges.includes(params.selectedSalaryRange)) { setParams((p) => ({ ...p, selectedSalaryRange: ranges[0] })); } }, onError: (error) => { message.error('薪资数据加载失败'); }, }, ); // 根据分析类别获取当前数据 const currentData = useMemo(() => { if (params.analysisCategory === 'industry') { return allData .filter((item) => item.category === params.selectedIndustry) .map((item) => ({ ...item, originalDate: item.date, date: formatDateForDisplay(item.date, params.timeDimension), })) .sort((a, b) => { if (params.timeDimension === '季度') { const [yearA, quarterA] = a.originalDate.split('-'); const [yearB, quarterB] = b.originalDate.split('-'); const quarterToNumber = (q: string) => { if (q.includes('第一')) return 1; if (q.includes('第二')) return 2; if (q.includes('第三')) return 3; if (q.includes('第四')) return 4; return 0; }; return yearA === yearB ? quarterToNumber(quarterA) - quarterToNumber(quarterB) : parseInt(yearA) - parseInt(yearB); } else if (params.timeDimension === '年') { return parseInt(a.originalDate) - parseInt(b.originalDate); } else { return dayjs(a.originalDate).valueOf() - dayjs(b.originalDate).valueOf(); } }); } else if (params.analysisCategory === 'salary') { return salaryData .filter((item) => item.category === params.selectedSalaryRange) .map((item) => ({ ...item, originalDate: item.date, date: formatDateForDisplay(item.date, params.timeDimension), })) .sort((a, b) => { if (params.timeDimension === '季度') { const [yearA, quarterA] = a.originalDate.split('-'); const [yearB, quarterB] = b.originalDate.split('-'); const quarterToNumber = (q: string) => { if (q.includes('第一')) return 1; if (q.includes('第二')) return 2; if (q.includes('第三')) return 3; if (q.includes('第四')) return 4; return 0; }; return yearA === yearB ? quarterToNumber(quarterA) - quarterToNumber(quarterB) : parseInt(yearA) - parseInt(yearB); } else if (params.timeDimension === '年') { return parseInt(a.originalDate) - parseInt(b.originalDate); } else { return dayjs(a.originalDate).valueOf() - dayjs(b.originalDate).valueOf(); } }); } return []; }, [allData, salaryData, params]); // 热力图配置 const heatmapConfig = useMemo(() => { const sortedData = [...areaData].sort((a, b) => { if (params.timeDimension === '年') { return parseInt(a.time) - parseInt(b.time); } else if (params.timeDimension === '季度') { const [yearA, quarterA] = a.time.split('-Q'); const [yearB, quarterB] = b.time.split('-Q'); return yearA === yearB ? parseInt(quarterA) - parseInt(quarterB) : parseInt(yearA) - parseInt(yearB); } else { return dayjs(a.time).valueOf() - dayjs(b.time).valueOf(); } }); // 计算最大值和阈值 const maxValue = Math.max(...sortedData.map((item) => item.value), 1); const hotThreshold = maxValue * 0.8; // 热门阈值(前20%) const growthThreshold = maxValue * 0.5; // 增长阈值(前50%) return { data: sortedData, height: 300, autoFit: false, xField: 'name', yField: 'time', colorField: 'value', mark: 'cell', style: { inset: 1, fillOpacity: 0.9, }, cellSize: [100, 40], color: ['#5B8FF9', '#5AD8A6', '#5D7092', '#F6BD16', '#E8684A'], label: { text: (d: { value: number }) => { if (d.value >= 10000) return (d.value / 10000).toFixed(0) + 'w'; if (d.value >= 1000) return (d.value / 1000).toFixed(0) + 'k'; return d.value; }, style: { fill: '#fff', fontSize: 12, fontWeight: 'bold', textShadow: '0 0 3px rgba(0,0,0,0.7)', }, position: 'inside', }, xAxis: { title: { text: '区域', style: { fontSize: 12 }, }, label: { autoRotate: true, style: { fontSize: 11, fill: '#666', }, }, }, yAxis: { title: { text: '时间', style: { fontSize: 12 }, }, label: { formatter: (text) => { if (params.timeDimension === '年') return `${text}年`; if (params.timeDimension === '季度') return text.replace('-Q', '年Q'); return text.replace('-', '年').replace('-', '月'); }, style: { fontSize: 11, fill: '#666', }, }, sortable: false, }, tooltip: { title: (d) => `${d.name} - ${d.time}`, field: 'value', valueFormatter: (v) => { if (v >= 10000) return (v / 10000).toFixed(1) + '万'; if (v >= 1000) return (v / 1000).toFixed(1) + '千'; return v; }, pointerEvents: 'none', domStyles: { 'g2-tooltip': { padding: '8px 12px', borderRadius: '4px', }, }, }, interactions: [{ type: 'element-active' }], legend: { position: 'bottom', layout: 'horizontal', slidable: true, title: false, itemName: { style: { fill: '#666', fontSize: 12, }, }, }, }; }, [areaData, params.timeDimension]); const handleTimeDimensionChange = (value: TimeDimension) => { const now = dayjs(); let newStartTime = ''; if (value === '月') { newStartTime = now.subtract(5, 'month').format('YYYY-MM'); } else if (value === '季度') { newStartTime = now.subtract(6, 'quarter').format('YYYY-Q'); } else { newStartTime = now.subtract(5, 'year').format('YYYY'); } setParams((p) => ({ ...p, timeDimension: value, startTime: newStartTime, endTime: value === '月' ? now.format('YYYY-MM') : value === '季度' ? now.format('YYYY-Q') : now.format('YYYY'), selectedIndustry: params.analysisCategory === 'industry' ? '' : p.selectedIndustry, selectedSalaryRange: params.analysisCategory === 'salary' ? '' : p.selectedSalaryRange, })); }; const handleDateRangeChange = (dates: any, dateStrings: [string, string]) => { if (dates && dates[0] && dates[1]) { setParams((p) => ({ ...p, startTime: dateStrings[0], endTime: dateStrings[1], })); } }; const disabledDate = (current: dayjs.Dayjs) => { const now = dayjs(); if (params.timeDimension === '月') { return current.isAfter(now.endOf('month')); } else if (params.timeDimension === '季度') { return current.isAfter(now.endOf('quarter')); } else { return current.isAfter(now.endOf('year')); } }; const getPickerValue = () => { try { return [ dayjs( params.startTime, params.timeDimension === '年' ? 'YYYY' : params.timeDimension === '季度' ? 'YYYY-Q' : 'YYYY-MM', ), dayjs( params.endTime, params.timeDimension === '年' ? 'YYYY' : params.timeDimension === '季度' ? 'YYYY-Q' : 'YYYY-MM', ), ]; } catch (e) { return null; } }; const chartConfig: ChartConfig = { data: currentData, height: 180, xField: 'date', yField: 'value', seriesField: 'category', xAxis: { type: 'cat', label: { formatter: (text: string) => text, }, }, yAxis: { label: { formatter: (val: string) => `${val}${params.type === '招聘增长率' ? '%' : ''}`, }, }, point: { size: 4, shape: 'circle', }, animation: { appear: { animation: 'path-in', duration: 1000, }, }, smooth: true, interactions: [ { type: 'tooltip', cfg: { render: (e, { title, items }) => { const list = items.filter((item) => item.value); return (

{title}

{list.map((item, index) => { const { name, value, color } = item; return (
{name}
{value} {params.type === '招聘增长率' ? '%' : ''}
); })}
); }, }, }, ], legend: false, tooltip: { showTitle: undefined, title: undefined, customContent: undefined, }, }; useEffect(() => { if (params.analysisCategory === 'industry') { fetchIndustryData(); fetchAreaData(); } else if (params.analysisCategory === 'salary') { fetchSalaryData(); fetchAreaData(); } }, [params.timeDimension, params.startTime, params.endTime, params.type, params.analysisCategory]); return (
setParams(p => ({ ...p, analysisCategory: key as 'industry' | 'salary' | 'area', selectedIndustry: key === 'industry' && availableIndustries.length > 0 ? availableIndustries[0] : p.selectedIndustry, selectedSalaryRange: key === 'salary' && availableSalaryRanges.length > 0 ? availableSalaryRanges[0] : p.selectedSalaryRange }))} >
{params.analysisCategory === 'industry' && ( )} {params.analysisCategory === 'salary' && ( )}
{params.analysisCategory !== 'area' && ( {currentData.length > 0 ? ( ) : ( )} )} {areaLoading ? ( ) : areaData.length > 0 ? (
) : ( )}
); }; export default IndustryTrendPage;