Some checks failed
Node CI / build (14.x, macOS-latest) (push) Has been cancelled
Node CI / build (14.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (14.x, windows-latest) (push) Has been cancelled
Node CI / build (16.x, macOS-latest) (push) Has been cancelled
Node CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (16.x, windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
coverage CI / build (push) Has been cancelled
Node pnpm CI / build (16.x, macOS-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, windows-latest) (push) Has been cancelled
575 lines
18 KiB
TypeScript
575 lines
18 KiB
TypeScript
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
|
import { Card, Select, Button, Space, Spin, Empty, Row, Col, message, DatePicker } from 'antd';
|
|
import { Line, Bar, Pie, Heatmap } from '@ant-design/charts';
|
|
import {
|
|
getIndustryTrend,
|
|
getIndustryAreaTrend,
|
|
getSalaryTrend,
|
|
getWorkYearTrend,
|
|
getEducationTrend,
|
|
} 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,
|
|
convertWorkYearData,
|
|
convertEducationData,
|
|
} from './utils';
|
|
import {
|
|
getHeatmapConfig,
|
|
getIndustryChartConfig,
|
|
getSalaryChartConfig,
|
|
getWorkYearPieConfig,
|
|
getEducationBarConfig,
|
|
} from './components/chartconfigs';
|
|
import {
|
|
IndustryTrendCard,
|
|
AreaAnalysisCard,
|
|
SalaryTrendCard,
|
|
WorkYearCard,
|
|
EducationCard,
|
|
} from './components/chartcards';
|
|
import Chartline from './components/chartline'
|
|
import Chartbar from './components/chartbar'
|
|
|
|
const { Option } = Select;
|
|
const { RangePicker } = DatePicker;
|
|
|
|
const flattenAreaData = (apiResponse: any) => {
|
|
if (!apiResponse || typeof apiResponse !== 'object') {
|
|
return [];
|
|
}
|
|
|
|
const flattenedData: { name: any; time: any; value: number }[] = [];
|
|
|
|
for (const month in apiResponse) {
|
|
if (apiResponse.hasOwnProperty(month)) {
|
|
const areas = apiResponse[month];
|
|
|
|
areas.forEach((area: { name: any; time: any; data: string }) => {
|
|
flattenedData.push({
|
|
name: area.name,
|
|
time: area.time,
|
|
value: parseInt(area.data) || 0,
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
return flattenedData;
|
|
};
|
|
|
|
const IndustryTrendPage: React.FC = () => {
|
|
const [params, setParams] = useState<IndustryTrendState>({
|
|
timeDimension: '月',
|
|
type: '岗位发布数量',
|
|
startTime: dayjs().subtract(5, 'month').format('YYYY-MM'),
|
|
endTime: dayjs().format('YYYY-MM'),
|
|
selectedIndustry: '',
|
|
selectedSalaryRange: '',
|
|
selectedWorkYearRange: '',
|
|
});
|
|
|
|
const [allData, setAllData] = useState<IndustryDataItem[]>([]);
|
|
const [areaData, setAreaData] = useState<any[]>([]);
|
|
const [salaryData, setSalaryData] = useState<any[]>([]);
|
|
const [availableIndustries, setAvailableIndustries] = useState<string[]>([]);
|
|
const [availableSalaryRanges, setAvailableSalaryRanges] = useState<string[]>([]);
|
|
const heatmapRef = useRef(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [workYearData, setWorkYearData] = useState<any[]>([]);
|
|
const [availableWorkYearRanges, setAvailableWorkYearRanges] = useState<string[]>([]);
|
|
const [educationData, setEducationData] = useState<any[]>([]);
|
|
const [availableEducationLevels, setAvailableEducationLevels] = useState<string[]>([]);
|
|
const [selectedEducationLevel, setSelectedEducationLevel] = useState<string>('');
|
|
|
|
// 获取行业趋势数据
|
|
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 () => {
|
|
const now = dayjs();
|
|
let startTime = now.subtract(1, 'year').format('YYYY-MM');
|
|
let endTime = now.format('YYYY-MM');
|
|
const timeDimension = '月';
|
|
|
|
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((a, b) => {
|
|
const extractNumber = (str: string) => parseInt(str.replace(/[^0-9]/g, '') || 0);
|
|
return extractNumber(a) - extractNumber(b);
|
|
});
|
|
|
|
setAvailableSalaryRanges(ranges);
|
|
|
|
if (ranges.length > 0 && !ranges.includes(params.selectedSalaryRange)) {
|
|
setParams((p) => ({ ...p, selectedSalaryRange: ranges[0] }));
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
message.error('薪资数据加载失败');
|
|
},
|
|
},
|
|
);
|
|
|
|
// 获取工作年限数据
|
|
const { loading: workYearLoading, run: fetchWorkYearData } = useRequest(
|
|
async () => {
|
|
const now = dayjs();
|
|
let startTime = now.subtract(1, 'year').format('YYYY-MM');
|
|
let endTime = now.format('YYYY-MM');
|
|
const timeDimension = '月';
|
|
|
|
return await getWorkYearTrend({
|
|
timeDimension,
|
|
type: '岗位发布数量',
|
|
startTime,
|
|
endTime,
|
|
});
|
|
},
|
|
{
|
|
manual: true,
|
|
onSuccess: (data) => {
|
|
const formattedData = convertWorkYearData(data);
|
|
setWorkYearData(formattedData);
|
|
|
|
const ranges = Array.from(new Set(formattedData.map((item: any) => item.category)))
|
|
.filter(Boolean)
|
|
.sort((a, b) => {
|
|
const order = ['应届', '1-3年', '3-5年', '5年以上'];
|
|
return order.indexOf(a) - order.indexOf(b);
|
|
});
|
|
|
|
setAvailableWorkYearRanges(ranges);
|
|
if (ranges.length > 0 && !ranges.includes(params.selectedWorkYearRange)) {
|
|
setParams((p) => ({ ...p, selectedWorkYearRange: ranges[0] }));
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
message.error('工作经验数据加载失败');
|
|
},
|
|
},
|
|
);
|
|
|
|
// 获取学历数据
|
|
const { loading: educationLoading, run: fetchEducationData } = useRequest(
|
|
async () => {
|
|
const now = dayjs();
|
|
let startTime = now.subtract(1, 'year').format('YYYY-MM');
|
|
let endTime = now.format('YYYY-MM');
|
|
const timeDimension = '月';
|
|
|
|
return await getEducationTrend({
|
|
timeDimension,
|
|
type: '岗位发布数量',
|
|
startTime,
|
|
endTime,
|
|
});
|
|
},
|
|
{
|
|
manual: true,
|
|
onSuccess: (data) => {
|
|
const formattedData = convertEducationData(data);
|
|
setEducationData(formattedData);
|
|
|
|
const levels = Array.from(new Set(formattedData.map((item: any) => item.category)))
|
|
.filter(Boolean)
|
|
.sort((a, b) => {
|
|
const order = ['不限', '初中及以下', '中专/中技', '大专', '本科', '硕士', '博士'];
|
|
return order.indexOf(a) - order.indexOf(b);
|
|
});
|
|
|
|
setAvailableEducationLevels(levels);
|
|
if (levels.length > 0 && !levels.includes(selectedEducationLevel)) {
|
|
setSelectedEducationLevel(levels[0]);
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
message.error('学历数据加载失败');
|
|
},
|
|
},
|
|
);
|
|
|
|
const currentWorkYearData = useMemo(() => {
|
|
if (!params.selectedWorkYearRange || workYearData.length === 0) return [];
|
|
|
|
return workYearData
|
|
.filter((item) => item.category === params.selectedWorkYearRange)
|
|
.map((item) => ({
|
|
...item,
|
|
originalDate: item.date,
|
|
date: formatDateForDisplay(item.date, '月'),
|
|
}))
|
|
.sort((a, b) => dayjs(a.originalDate).valueOf() - dayjs(b.originalDate).valueOf());
|
|
}, [workYearData, params.selectedWorkYearRange]);
|
|
|
|
const currentIndustryData = useMemo(() => {
|
|
if (!params.selectedIndustry || allData.length === 0) return [];
|
|
|
|
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();
|
|
}
|
|
});
|
|
}, [allData, params.selectedIndustry, params.timeDimension]);
|
|
|
|
const currentSalaryData = useMemo(() => {
|
|
if (!params.selectedSalaryRange || salaryData.length === 0) return [];
|
|
|
|
return salaryData
|
|
.filter((item) => item.category === params.selectedSalaryRange)
|
|
.map((item) => ({
|
|
...item,
|
|
originalDate: item.date,
|
|
date: formatDateForDisplay(item.date, '月'),
|
|
}))
|
|
.sort((a, b) => dayjs(a.originalDate).valueOf() - dayjs(b.originalDate).valueOf());
|
|
}, [salaryData, params.selectedSalaryRange]);
|
|
|
|
const currentEducationData = useMemo(() => {
|
|
if (!selectedEducationLevel || educationData.length === 0) return [];
|
|
return educationData
|
|
.filter((item) => item.category === selectedEducationLevel)
|
|
.map((item) => ({
|
|
...item,
|
|
originalDate: item.date,
|
|
date: formatDateForDisplay(item.date, '月'),
|
|
}))
|
|
.sort((a, b) => dayjs(a.originalDate).valueOf() - dayjs(b.originalDate).valueOf());
|
|
}, [educationData, selectedEducationLevel]);
|
|
|
|
const heatmapConfig = useMemo(
|
|
() => getHeatmapConfig(areaData, params.timeDimension),
|
|
[areaData, params.timeDimension],
|
|
);
|
|
|
|
const industryChartConfig = useMemo(
|
|
() => getIndustryChartConfig(currentIndustryData, params.type),
|
|
[currentIndustryData, params.type],
|
|
);
|
|
|
|
const salaryChartConfig = useMemo(
|
|
() => getSalaryChartConfig(currentSalaryData),
|
|
[currentSalaryData],
|
|
);
|
|
|
|
const workYearPieConfig = useMemo(
|
|
() => getWorkYearPieConfig(workYearData, params.selectedWorkYearRange),
|
|
[workYearData, params.selectedWorkYearRange],
|
|
);
|
|
|
|
const educationBarConfig = useMemo(
|
|
() => getEducationBarConfig(educationData, selectedEducationLevel),
|
|
[educationData, selectedEducationLevel],
|
|
);
|
|
|
|
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: '',
|
|
}));
|
|
};
|
|
|
|
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;
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchIndustryData();
|
|
fetchAreaData();
|
|
fetchSalaryData();
|
|
fetchWorkYearData();
|
|
fetchEducationData();
|
|
}, [params.timeDimension, params.startTime, params.endTime, params.type]);
|
|
|
|
return (
|
|
<div style={{ padding: 16 }} ref={containerRef}>
|
|
<Card title="企业数据分析" style={{ marginBottom: 24 }}>
|
|
<div style={{ marginBottom: 24 }}>
|
|
<Space size="middle" wrap>
|
|
<Select
|
|
value={params.timeDimension}
|
|
onChange={handleTimeDimensionChange}
|
|
style={{ width: 100 }}
|
|
>
|
|
<Option value="月">按月</Option>
|
|
<Option value="季度">按季度</Option>
|
|
<Option value="年">按年</Option>
|
|
</Select>
|
|
|
|
<RangePicker
|
|
picker={
|
|
params.timeDimension === '月'
|
|
? 'month'
|
|
: params.timeDimension === '季度'
|
|
? 'quarter'
|
|
: 'year'
|
|
}
|
|
value={getPickerValue()}
|
|
onChange={handleDateRangeChange}
|
|
style={{ width: 180 }}
|
|
disabled={industryLoading || areaLoading}
|
|
allowClear={false}
|
|
disabledDate={disabledDate}
|
|
/>
|
|
|
|
<Select
|
|
value={params.type}
|
|
onChange={(value) => setParams((p) => ({ ...p, type: value }))}
|
|
style={{ width: 100 }}
|
|
>
|
|
<Option value="岗位发布数量">岗位数量</Option>
|
|
<Option value="招聘增长率">增长率</Option>
|
|
</Select>
|
|
|
|
<Button
|
|
type="primary"
|
|
onClick={() => {
|
|
fetchIndustryData();
|
|
fetchAreaData();
|
|
}}
|
|
loading={industryLoading || areaLoading}
|
|
>
|
|
查询
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
|
|
<Row gutter={[16, 16]}>
|
|
{/* 行业趋势图表 - 全宽显示 */}
|
|
<Col xs={24} lg={24}>
|
|
<IndustryTrendCard
|
|
loading={industryLoading}
|
|
currentIndustryData={currentIndustryData}
|
|
config={industryChartConfig}
|
|
availableIndustries={availableIndustries}
|
|
selectedIndustry={params.selectedIndustry}
|
|
onIndustryChange={(value) => setParams((p) => ({ ...p, selectedIndustry: value }))}
|
|
/>
|
|
</Col>
|
|
{/* 工作经验和学历要求 - 中等屏幕下分成两列 */}
|
|
<Col xs={24} md={12} lg={12}>
|
|
<WorkYearCard
|
|
loading={workYearLoading}
|
|
workYearData={workYearData}
|
|
config={workYearPieConfig}
|
|
availableWorkYearRanges={availableWorkYearRanges}
|
|
selectedWorkYearRange={params.selectedWorkYearRange}
|
|
onWorkYearRangeChange={(value) =>
|
|
setParams((p) => ({ ...p, selectedWorkYearRange: value }))
|
|
}
|
|
/>
|
|
</Col>
|
|
<Col xs={24} md={12} lg={12}>
|
|
<Chartbar />
|
|
{/* <EducationCard
|
|
loading={educationLoading}
|
|
educationData={educationData}
|
|
config={educationBarConfig}
|
|
availableEducationLevels={availableEducationLevels}
|
|
selectedEducationLevel={selectedEducationLevel}
|
|
onEducationLevelChange={setSelectedEducationLevel}
|
|
/> */}
|
|
</Col>
|
|
{/* 区域分析和薪资趋势 - 中等屏幕下分成两列 */}
|
|
{/* <Col xs={24} md={12} lg={12}>
|
|
<AreaAnalysisCard loading={areaLoading} areaData={areaData} config={heatmapConfig} />
|
|
</Col> */}
|
|
<Col xs={24} md={12} lg={12}>
|
|
<SalaryTrendCard
|
|
loading={salaryLoading}
|
|
currentSalaryData={currentSalaryData}
|
|
config={salaryChartConfig}
|
|
availableSalaryRanges={availableSalaryRanges}
|
|
selectedSalaryRange={params.selectedSalaryRange}
|
|
onSalaryRangeChange={(value) =>
|
|
setParams((p) => ({ ...p, selectedSalaryRange: value }))
|
|
}
|
|
/>
|
|
</Col>
|
|
<Col xs={24} md={12} lg={12}>
|
|
<Chartline />
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default IndustryTrendPage;
|