From 1bb796b5eff76070ada8a467cda42a454145824b Mon Sep 17 00:00:00 2001
From: yy <3078169442@qq.com>
Date: Mon, 9 Jun 2025 11:18:07 +0800
Subject: [PATCH] =?UTF-8?q?fix:=E6=96=B0=E5=A2=9E=E5=B2=97=E4=BD=8D?=
=?UTF-8?q?=E5=88=86=E6=9E=90=E4=BA=94=E4=B8=AA=E5=9B=BE=E8=A1=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Industrytrend/components/chartcards.tsx | 215 ++++++
.../Industrytrend/components/chartconfigs.tsx | 456 ++++++++++++
src/pages/Analysis/Industrytrend/index.tsx | 666 ++++++++----------
src/pages/Analysis/Industrytrend/utils.ts | 71 ++
src/services/analysis/industry.ts | 19 +-
src/types/analysis/industry.d.ts | 4 +-
6 files changed, 1042 insertions(+), 389 deletions(-)
create mode 100644 src/pages/Analysis/Industrytrend/components/chartcards.tsx
create mode 100644 src/pages/Analysis/Industrytrend/components/chartconfigs.tsx
diff --git a/src/pages/Analysis/Industrytrend/components/chartcards.tsx b/src/pages/Analysis/Industrytrend/components/chartcards.tsx
new file mode 100644
index 0000000..f28cd47
--- /dev/null
+++ b/src/pages/Analysis/Industrytrend/components/chartcards.tsx
@@ -0,0 +1,215 @@
+import React from 'react';
+import { Card, Select, Spin, Empty, Row, Col } from 'antd';
+import { Line, Bar, Pie, Heatmap } from '@ant-design/charts';
+
+export const IndustryTrendCard = ({
+ loading,
+ currentIndustryData,
+ config,
+ availableIndustries,
+ selectedIndustry,
+ onIndustryChange,
+}) => (
+
+ {availableIndustries.map((industry: any) => (
+
+ ))}
+
+ }
+ >
+
+ {currentIndustryData.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+);
+
+export const AreaAnalysisCard = ({ loading, areaData, config }) => (
+
+ {loading ? (
+
+ ) : areaData.length > 0 ? (
+
+
+
+ ) : (
+
+ )}
+
+);
+
+export const SalaryTrendCard = ({
+ loading,
+ currentSalaryData,
+ config,
+ availableSalaryRanges,
+ selectedSalaryRange,
+ onSalaryRangeChange,
+}) => (
+
+ {availableSalaryRanges.map((range) => (
+
+ ))}
+
+ }
+ >
+
+ {currentSalaryData.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+);
+
+export const WorkYearCard = ({
+ loading,
+ workYearData,
+ config,
+ availableWorkYearRanges,
+ selectedWorkYearRange,
+ onWorkYearRangeChange,
+}) => (
+
+ {availableWorkYearRanges.map((range: any) => (
+
+ ))}
+
+ }
+ >
+
+ {workYearData && workYearData.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+);
+
+export const EducationCard = ({
+ loading,
+ educationData,
+ config,
+ availableEducationLevels,
+ selectedEducationLevel,
+ onEducationLevelChange,
+}) => (
+
+
+ {availableEducationLevels.map((level) => (
+
+ ))}
+
+ }
+ >
+
+ {educationData.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+);
\ No newline at end of file
diff --git a/src/pages/Analysis/Industrytrend/components/chartconfigs.tsx b/src/pages/Analysis/Industrytrend/components/chartconfigs.tsx
new file mode 100644
index 0000000..e1922ec
--- /dev/null
+++ b/src/pages/Analysis/Industrytrend/components/chartconfigs.tsx
@@ -0,0 +1,456 @@
+import { ChartConfig } from '@/types/analysis/industry';
+import dayjs from 'dayjs';
+import { formatDateForDisplay } from '../utils';
+
+export const getHeatmapConfig = (areaData: any[], timeDimension: string) => {
+ const sortedData = [...areaData].sort((a, b) => {
+ if (timeDimension === '年') {
+ return parseInt(a.time) - parseInt(b.time);
+ } else if (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();
+ }
+ });
+
+ return {
+ data: sortedData,
+ height: 240,
+ autoFit: true,
+ xField: 'name',
+ yField: 'time',
+ colorField: 'value',
+ shapeField: 'square',
+ sizeField: 'value',
+ xAxis: {
+ title: {
+ text: '区域',
+ style: { fontSize: 12 },
+ },
+ label: {
+ style: {
+ fontSize: 10,
+ fill: '#666',
+ },
+ formatter: (text: string) => {
+ return text.length > 4 ? `${text.substring(0, 3)}...` : text;
+ },
+ },
+ },
+ yAxis: {
+ title: {
+ text: '时间',
+ style: { fontSize: 12 },
+ },
+ label: {
+ formatter: (text: string) => {
+ if (timeDimension === '年') return `${text}年`;
+ if (timeDimension === '季度') return text.replace('-Q', '年Q');
+ return text.replace('-', '年').replace('-', '月');
+ },
+ style: {
+ fontSize: 10,
+ fill: '#666',
+ },
+ },
+ },
+ label: {
+ text: (d: { value: number }) => d.value.toString(),
+ position: 'inside',
+ style: {
+ fill: '#fff',
+ pointerEvents: 'none',
+ },
+ },
+ scale: {
+ size: { range: [14, 14] },
+ color: { range: ['#dddddd', '#9ec8e0', '#5fa4cd', '#2e7ab6', '#114d90'] },
+ },
+ tooltip: {
+ title: (d: { name: any; time: any }) => `${d.name} - ${d.time}`,
+ field: 'value',
+ valueFormatter: (v: number) => v.toString(),
+ domStyles: {
+ 'g2-tooltip': {
+ padding: '8px 12px',
+ borderRadius: '4px',
+ },
+ },
+ },
+ interactions: [{ type: 'element-active' }],
+ responsive: true,
+ };
+};
+
+export const getIndustryChartConfig = (currentIndustryData: any[], type: string): ChartConfig => ({
+ data: currentIndustryData,
+ height: 200,
+ xField: 'date',
+ yField: 'value',
+ seriesField: 'category',
+ xAxis: {
+ type: 'cat',
+ label: {
+ formatter: (text: string) => text,
+ },
+ },
+ yAxis: {
+ label: {
+ formatter: (val: string) => `${val}${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}
+ {type === '招聘增长率' ? '%' : ''}
+
+
+ );
+ })}
+
+ );
+ },
+ },
+ },
+ ],
+ legend: false,
+ tooltip: {
+ showTitle: undefined,
+ title: undefined,
+ customContent: undefined,
+ },
+});
+
+export const getSalaryChartConfig = (currentSalaryData: any[]): ChartConfig => ({
+ data: currentSalaryData,
+ height: 240,
+ xField: 'date',
+ yField: 'value',
+ seriesField: 'category',
+ xAxis: {
+ type: 'cat',
+ label: {
+ formatter: (text: string) => text,
+ },
+ },
+ yAxis: {
+ label: {
+ formatter: (val: string) => `${val}`,
+ },
+ },
+ 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}
+
+ );
+ })}
+
+ );
+ },
+ },
+ },
+ ],
+ legend: false,
+ tooltip: {
+ showTitle: undefined,
+ title: undefined,
+ customContent: undefined,
+ },
+});
+
+export const getWorkYearPieConfig = (workYearData: any[], selectedWorkYearRange: string) => {
+ const filteredData = workYearData
+ .filter((item) => item.category === selectedWorkYearRange)
+ .map((item) => ({
+ ...item,
+ date: formatDateForDisplay(item.date, '月'),
+ value: item.value || 0,
+ type: `${formatDateForDisplay(item.date, '月')} ${item.category}`,
+ }));
+
+ return {
+ data: filteredData,
+ angleField: 'value',
+ colorField: 'type',
+ radius: 0.2,
+ innerRadius: 0.5,
+ label: {
+ text: (d: { type: any; value: any }) => `${d.type}\n ${d.value}`,
+ position: 'spider',
+ },
+ legend: false,
+ tooltip: {
+ showTitle: true,
+ title: '工作经验分布',
+ fields: ['type', 'value'],
+ formatter: (datum: { type: any; value: any }) => ({
+ name: datum.type,
+ value: datum.value,
+ }),
+ },
+ interactions: [{ type: 'element-active' }],
+ padding: 'auto',
+ autoFit: true,
+ };
+};
+
+export const getEducationBarConfig = (educationData: any[], selectedEducationLevel: string) => {
+ const educationLevelOrder = [
+ '不限',
+ '初中及以下',
+ '中专/中技',
+ '高中',
+ '大专',
+ '本科',
+ '硕士',
+ '博士',
+ 'MBA/EMBA',
+ '留学-学士',
+ '留学-硕士',
+ '留学-博士',
+ ];
+
+ const educationColorMap: Record = {
+ 不限: '#8884d8',
+ 初中及以下: '#82ca9d',
+ '中专/中技': '#ffc658',
+ 高中: '#ff8042',
+ 大专: '#0088FE',
+ 本科: '#00C49F',
+ 硕士: '#FFBB28',
+ 博士: '#FF8042',
+ 'MBA/EMBA': '#8884d8',
+ '留学-学士': '#82ca9d',
+ '留学-硕士': '#ffc658',
+ '留学-博士': '#ff8042',
+ };
+
+ const cleanEducationData = educationData
+ .filter((item) => item && item.category && item.date && !isNaN(item.value))
+ .map((item) => ({
+ ...item,
+ date: formatDateForDisplay(item.date, '月'),
+ category: item.category || '不限',
+ value: Number(item.value) || 0,
+ }));
+
+ if (!selectedEducationLevel) {
+ const educationSummary: Record = {};
+
+ cleanEducationData.forEach((item) => {
+ if (!educationSummary[item.category]) {
+ educationSummary[item.category] = 0;
+ }
+ educationSummary[item.category] += item.value;
+ });
+
+ const barData = Object.entries(educationSummary)
+ .filter(([_, value]) => value > 0)
+ .map(([name, value]) => ({
+ name,
+ value,
+ color: educationColorMap[name] || '#999',
+ }))
+ .sort((a, b) => educationLevelOrder.indexOf(a.name) - educationLevelOrder.indexOf(b.name));
+
+ return {
+ data: barData,
+ height: 200,
+ xField: 'value',
+ yField: 'name',
+ seriesField: 'name',
+ color: ({ name }: { name: string }) => educationColorMap[name] || '#999',
+ meta: {
+ name: { alias: '学历要求' },
+ value: { alias: '岗位数量' },
+ },
+ xAxis: {
+ label: {
+ formatter: (val: string) => `${val}`,
+ },
+ grid: {
+ line: {
+ style: {
+ stroke: '#f0f0f0',
+ lineDash: [4, 4],
+ },
+ },
+ },
+ },
+ yAxis: {
+ label: {
+ formatter: (text: string) => text,
+ },
+ },
+ barStyle: {
+ radius: [2, 2, 0, 0],
+ },
+ tooltip: {
+ showTitle: true,
+ title: '学历要求分布',
+ fields: ['name', 'value'],
+ formatter: (datum: { name: any; value: any }) => ({
+ name: datum.name,
+ value: datum.value,
+ }),
+ domStyles: {
+ 'g2-tooltip': {
+ background: 'rgba(255, 255, 255, 0.9)',
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
+ borderRadius: '4px',
+ },
+ },
+ },
+ interactions: [{ type: 'element-active' }],
+ legend: false,
+ animation: {
+ appear: {
+ animation: 'scale-in-y',
+ duration: 1000,
+ },
+ },
+ };
+ }
+
+ const timeData = cleanEducationData
+ .filter((item) => item.category === selectedEducationLevel)
+ .sort((a, b) => {
+ const dateA = dayjs(a.date, 'YYYY年MM月').valueOf();
+ const dateB = dayjs(b.date, 'YYYY年MM月').valueOf();
+ return dateA - dateB;
+ });
+
+ return {
+ data: timeData,
+ height: 200,
+ xField: 'date',
+ yField: 'value',
+ seriesField: 'category',
+ color: educationColorMap[selectedEducationLevel] || '#999',
+ meta: {
+ date: {
+ alias: '时间',
+ type: 'cat',
+ values: timeData
+ .map((item) => item.date)
+ .sort((a, b) => {
+ const dateA = dayjs(a, 'YYYY年MM月').valueOf();
+ const dateB = dayjs(b, 'YYYY年MM月').valueOf();
+ return dateA - dateB;
+ }),
+ },
+ value: { alias: '岗位数量' },
+ },
+ barStyle: {
+ radius: [2, 2, 0, 0],
+ },
+ tooltip: {
+ showTitle: true,
+ title: `${selectedEducationLevel}趋势`,
+ fields: ['date', 'value'],
+ formatter: (datum: { date: any; value: any }) => ({ name: datum.date, value: datum.value }),
+ domStyles: {
+ 'g2-tooltip': {
+ background: 'rgba(255, 255, 255, 0.9)',
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
+ borderRadius: '4px',
+ },
+ },
+ },
+ interactions: [{ type: 'element-active' }],
+ legend: false,
+ animation: {
+ appear: {
+ animation: 'scale-in-y',
+ duration: 1000,
+ },
+ },
+ xAxis: {
+ type: 'cat',
+ label: {
+ formatter: (text: string) => text,
+ },
+ },
+ };
+};
\ No newline at end of file
diff --git a/src/pages/Analysis/Industrytrend/index.tsx b/src/pages/Analysis/Industrytrend/index.tsx
index 7e1c5d0..0c563d0 100644
--- a/src/pages/Analysis/Industrytrend/index.tsx
+++ b/src/pages/Analysis/Industrytrend/index.tsx
@@ -1,7 +1,13 @@
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 { 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 {
@@ -11,24 +17,44 @@ import {
IndustryDataItem,
ChartConfig,
} from '@/types/analysis/industry';
-import { formatQuarter, formatDateForDisplay, convertApiData, convertSalaryData } from './utils';
+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';
const { Option } = Select;
const { RangePicker } = DatePicker;
-const { TabPane } = Tabs;
const flattenAreaData = (apiResponse: any) => {
if (!apiResponse || typeof apiResponse !== 'object') {
return [];
}
- const flattenedData = [];
+ const flattenedData: { name: any; time: any; value: number }[] = [];
for (const month in apiResponse) {
if (apiResponse.hasOwnProperty(month)) {
const areas = apiResponse[month];
- areas.forEach((area) => {
+ areas.forEach((area: { name: any; time: any; data: string }) => {
flattenedData.push({
name: area.name,
time: area.time,
@@ -49,7 +75,7 @@ const IndustryTrendPage: React.FC = () => {
endTime: dayjs().format('YYYY-MM'),
selectedIndustry: '',
selectedSalaryRange: '',
- analysisCategory: 'industry', // 默认显示行业分析
+ selectedWorkYearRange: '',
});
const [allData, setAllData] = useState([]);
@@ -58,6 +84,12 @@ const IndustryTrendPage: React.FC = () => {
const [availableIndustries, setAvailableIndustries] = useState([]);
const [availableSalaryRanges, setAvailableSalaryRanges] = useState([]);
const heatmapRef = useRef(null);
+ const containerRef = useRef(null);
+ const [workYearData, setWorkYearData] = useState([]);
+ const [availableWorkYearRanges, setAvailableWorkYearRanges] = useState([]);
+ const [educationData, setEducationData] = useState([]);
+ const [availableEducationLevels, setAvailableEducationLevels] = useState([]);
+ const [selectedEducationLevel, setSelectedEducationLevel] = useState('');
// 获取行业趋势数据
const { loading: industryLoading, run: fetchIndustryData } = useRequest(
@@ -130,16 +162,14 @@ const IndustryTrendPage: React.FC = () => {
// 获取薪资趋势数据
const { loading: salaryLoading, run: fetchSalaryData } = useRequest(
async () => {
- let { startTime, endTime, timeDimension, type } = params;
-
- if (timeDimension === '季度') {
- startTime = formatQuarter(startTime);
- endTime = formatQuarter(endTime);
- }
+ 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,
+ type: '岗位发布数量',
startTime,
endTime,
});
@@ -152,8 +182,11 @@ const IndustryTrendPage: React.FC = () => {
const ranges = Array.from(new Set(formattedData.map((item: any) => item.category)))
.filter(Boolean)
- .sort();
-
+ .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)) {
@@ -166,183 +199,180 @@ const IndustryTrendPage: React.FC = () => {
},
);
- // 根据分析类别获取当前数据
- 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 { 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 = '月';
- 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 await getWorkYearTrend({
+ timeDimension,
+ type: '岗位发布数量',
+ startTime,
+ endTime,
+ });
+ },
+ {
+ manual: true,
+ onSuccess: (data) => {
+ const formattedData = convertWorkYearData(data);
+ setWorkYearData(formattedData);
- 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 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);
+ });
- 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,
+ setAvailableWorkYearRanges(ranges);
+ if (ranges.length > 0 && !ranges.includes(params.selectedWorkYearRange)) {
+ setParams((p) => ({ ...p, selectedWorkYearRange: ranges[0] }));
+ }
},
- 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',
+ onError: (error) => {
+ message.error('工作经验数据加载失败');
},
- xAxis: {
- title: {
- text: '区域',
- style: { fontSize: 12 },
- },
- label: {
- autoRotate: true,
- style: {
- fontSize: 11,
- fill: '#666',
- },
- },
+ },
+ );
+
+ // 获取学历数据
+ 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]);
+ }
},
- 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,
+ onError: (error) => {
+ message.error('学历数据加载失败');
},
- 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 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();
@@ -366,8 +396,7 @@ const IndustryTrendPage: React.FC = () => {
: value === '季度'
? now.format('YYYY-Q')
: now.format('YYYY'),
- selectedIndustry: params.analysisCategory === 'industry' ? '' : p.selectedIndustry,
- selectedSalaryRange: params.analysisCategory === 'salary' ? '' : p.selectedSalaryRange,
+ selectedIndustry: '',
}));
};
@@ -418,146 +447,19 @@ const IndustryTrendPage: React.FC = () => {
}
};
- 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]);
+ fetchIndustryData();
+ fetchAreaData();
+ fetchSalaryData();
+ fetchWorkYearData();
+ fetchEducationData();
+ }, [params.timeDimension, params.startTime, params.endTime, params.type]);
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 ? (
-
- ) : (
-
- )}
-
-
-
- )}
+ {/* 行业趋势图表 - 全宽显示 */}
+
+ setParams((p) => ({ ...p, selectedIndustry: value }))}
+ />
+
-
-
- {areaLoading ? (
-
- ) : areaData.length > 0 ? (
-
-
-
- ) : (
-
- )}
-
+ {/* 区域分析和薪资趋势 - 中等屏幕下分成两列 */}
+
+
+
+
+
+ setParams((p) => ({ ...p, selectedSalaryRange: value }))
+ }
+ />
+
+
+ {/* 工作经验和学历要求 - 中等屏幕下分成两列 */}
+
+
+ setParams((p) => ({ ...p, selectedWorkYearRange: value }))
+ }
+ />
+
+
+
@@ -673,4 +567,4 @@ const IndustryTrendPage: React.FC = () => {
);
};
-export default IndustryTrendPage;
\ No newline at end of file
+export default IndustryTrendPage;
diff --git a/src/pages/Analysis/Industrytrend/utils.ts b/src/pages/Analysis/Industrytrend/utils.ts
index fd325e1..5d29e8f 100644
--- a/src/pages/Analysis/Industrytrend/utils.ts
+++ b/src/pages/Analysis/Industrytrend/utils.ts
@@ -158,4 +158,75 @@ export const convertSalaryData: ApiDataConverter = (apiData: any) => {
console.error('薪资数据转换错误:', error);
return [];
}
+};
+
+export const convertWorkYearData: ApiDataConverter = (apiData: any) => {
+ if (!apiData) return [];
+ try {
+ if (Array.isArray(apiData)) {
+ return apiData.map(item => ({
+ date: item.time || item.date || '',
+ category: item.name || item.category || '未知经验要求',
+ value: Number(item.data || item.value || 0) // 确保数值不为null/undefined
+ }));
+ }
+ if (typeof apiData === 'object') {
+ const result: IndustryDataItem[] = [];
+ Object.entries(apiData).forEach(([date, items]) => {
+ if (Array.isArray(items)) {
+ items.forEach((item: any) => {
+ result.push({
+ date: date || '',
+ category: item.name || item.category || '未知经验要求',
+ value: Number(item.data || item.value || 0) // 确保数值不为null/undefined
+ });
+ });
+ }
+ });
+ return result;
+ }
+ return [];
+ } catch (error) {
+ console.error('工作经验数据转换错误:', error);
+ return [];
+ }
+};
+export const convertEducationData: ApiDataConverter = (apiData: any) => {
+ if (!apiData) return [];
+ try {
+ const result: any[] = [];
+
+ // 处理嵌套的月份数据
+ if (typeof apiData === 'object' && !Array.isArray(apiData)) {
+ Object.entries(apiData).forEach(([date, items]) => {
+ if (Array.isArray(items)) {
+ items.forEach((item: any) => {
+ if (!item) return; // 跳过空项
+ result.push({
+ date: date || '', // 确保日期不为undefined
+ category: item.name || item.category || '不限', // 默认值
+ value: Number(item.data || item.value || 0), // 确保数值有效
+ originalData: item
+ });
+ });
+ }
+ });
+ return result;
+ }
+
+ // 处理数组格式的响应
+ if (Array.isArray(apiData)) {
+ return apiData.map(item => ({
+ date: item.time || item.date || '',
+ category: item.name || item.category || '不限',
+ value: Number(item.data || item.value || 0),
+ originalData: item
+ }));
+ }
+
+ return [];
+ } catch (error) {
+ console.error('学历数据转换错误:', error);
+ return [];
+ }
};
\ No newline at end of file
diff --git a/src/services/analysis/industry.ts b/src/services/analysis/industry.ts
index 209ddc2..00fc63e 100644
--- a/src/services/analysis/industry.ts
+++ b/src/services/analysis/industry.ts
@@ -1,11 +1,12 @@
import { request } from '@umijs/max';
-
+// 行业
export async function getIndustryTrend(params?: API.Analysis.IndustryParams) {
return request
('/api/cms/statics/industry', {
method: 'GET',
params
});
}
+// 区域热力图
export async function getIndustryAreaTrend(params: any) {
try {
const response = await request('/api/cms/statics/industryArea', {
@@ -23,10 +24,24 @@ export async function getIndustryAreaTrend(params: any) {
throw error;
}
}
-
+// 薪资
export async function getSalaryTrend(params?: API.Analysis.IndustryParams) {
return request('/api/cms/statics/salary', {
method: 'GET',
params
});
+}
+// 工作年限
+export async function getWorkYearTrend(params?: API.Analysis.IndustryParams) {
+ return request('/api/cms/statics/workYear', {
+ method: 'GET',
+ params
+ });
+}
+// 学历趋势
+export async function getEducationTrend(params?: API.Analysis.IndustryParams) {
+ return request('/api/cms/statics/education', {
+ method: 'GET',
+ params
+ });
}
\ No newline at end of file
diff --git a/src/types/analysis/industry.d.ts b/src/types/analysis/industry.d.ts
index e7005c0..9da12d0 100644
--- a/src/types/analysis/industry.d.ts
+++ b/src/types/analysis/industry.d.ts
@@ -6,7 +6,8 @@ export type AnalysisType = '岗位发布数量' | '招聘增长率';
export type QuarterFormat = `${number}-${'Q1'|'Q2'|'Q3'|'Q4'|'第一季度'|'第二季度'|'第三季度'|'第四季度'}`;
export type AnalysisCategory = 'industry' | 'area' | 'salary';
export type SalaryRange = '3k-5k' | '5k-8k' | '8k-10k' | '10k+';
-
+export type WorkYearRange = '应届' | '1-3年' | '3-5年' | '5年以上';
+export type EducationLevel = '大专' | '本科' | '硕士' | '博士' | '不限';
export interface IndustryDataItem {
date: string;
category: string;
@@ -26,6 +27,7 @@ export interface IndustryTrendParams {
}
export interface IndustryTrendState extends IndustryTrendParams {
+ selectedWorkYearRange(): unknown;
selectedIndustry: string;
}