flat: 暂存

This commit is contained in:
Apcallover
2025-11-06 10:02:54 +08:00
parent 203b7d4ef7
commit 7cdaedcaa7
12 changed files with 1733 additions and 1262 deletions

View File

@@ -1,215 +1,210 @@
import React from 'react'; import React from 'react';
import { Card, Select, Spin, Empty, Row, Col } from 'antd'; import { Card, Empty, Select, Spin } from 'antd';
import { Line, Bar, Pie, Heatmap } from '@ant-design/charts'; import { Bar, Heatmap, Line, Pie } from '@ant-design/charts';
export const IndustryTrendCard = ({ export const IndustryTrendCard = ({
loading, loading,
currentIndustryData, currentIndustryData,
config, config,
availableIndustries, availableIndustries,
selectedIndustry, selectedIndustry,
onIndustryChange, onIndustryChange,
}) => ( }) => (
<Card <Card
title="行业趋势分析" title="行业趋势分析"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
bodyStyle={{ padding: '12px', height: 250 }} bodyStyle={{ padding: '12px', height: 250 }}
extra={ extra={
<Select <Select
value={selectedIndustry} value={selectedIndustry}
onChange={onIndustryChange} onChange={onIndustryChange}
style={{ width: 180 }} style={{ width: 180 }}
loading={loading} loading={loading}
placeholder="选择行业" placeholder="选择行业"
disabled={availableIndustries.length === 0} disabled={availableIndustries.length === 0}
> >
{availableIndustries.map((industry: any) => ( {availableIndustries.map((industry: any) => (
<Option key={industry} value={industry}> <Option key={industry} value={industry}>
{industry} {industry}
</Option> </Option>
))} ))}
</Select> </Select>
} }
> >
<Spin spinning={loading}> <Spin spinning={loading}>
{currentIndustryData.length > 0 ? ( {currentIndustryData.length > 0 ? (
<Line {...config} /> <Line {...config} />
) : ( ) : (
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description={ description={
loading loading ? '数据加载中...' : selectedIndustry ? '当前时间段无数据' : '请先选择行业'
? '数据加载中...' }
: selectedIndustry />
? '当前时间段无数据' )}
: '请先选择行业' </Spin>
} </Card>
/> );
)}
</Spin> export const AreaAnalysisCard = ({ loading, areaData, config }) => (
</Card> <Card
); title="区域分析"
style={{ marginBottom: 16 }}
export const AreaAnalysisCard = ({ loading, areaData, config }) => ( bodyStyle={{
<Card padding: 12,
title="区域分析" height: 250,
style={{ marginBottom: 16 }} position: 'relative',
bodyStyle={{ }}
padding: 12, >
height: 250, {loading ? (
position: 'relative', <Spin tip="数据加载中..." size="large" />
}} ) : areaData.length > 0 ? (
> <div style={{ height: '100%', width: '100%', minHeight: 300 }}>
{loading ? ( <Heatmap {...config} />
<Spin tip="数据加载中..." size="large" /> </div>
) : areaData.length > 0 ? ( ) : (
<div style={{ height: '100%', width: '100%', minHeight: 300 }}> <Empty description="暂无区域数据" />
<Heatmap {...config} /> )}
</div> </Card>
) : ( );
<Empty description="暂无区域数据" />
)} export const SalaryTrendCard = ({
</Card> loading,
); currentSalaryData,
config,
export const SalaryTrendCard = ({ availableSalaryRanges,
loading, selectedSalaryRange,
currentSalaryData, onSalaryRangeChange,
config, }) => (
availableSalaryRanges, <Card
selectedSalaryRange, title="薪资区间趋势分析"
onSalaryRangeChange, style={{ marginBottom: 16 }}
}) => ( bodyStyle={{ padding: '12px', height: 250 }}
<Card extra={
title="薪资区间趋势分析" <Select
style={{ marginBottom: 16 }} value={selectedSalaryRange}
bodyStyle={{ padding: '12px', height: 250 }} onChange={onSalaryRangeChange}
extra={ style={{ width: 180 }}
<Select loading={loading}
value={selectedSalaryRange} placeholder="选择薪资区间"
onChange={onSalaryRangeChange} disabled={availableSalaryRanges.length === 0}
style={{ width: 180 }} >
loading={loading} {availableSalaryRanges.map((range) => (
placeholder="选择薪资区间" <Option key={range} value={range}>
disabled={availableSalaryRanges.length === 0} {range}
> </Option>
{availableSalaryRanges.map((range) => ( ))}
<Option key={range} value={range}> </Select>
{range} }
</Option> >
))} <Spin spinning={loading}>
</Select> {currentSalaryData.length > 0 ? (
} <Line {...config} />
> ) : (
<Spin spinning={loading}> <Empty
{currentSalaryData.length > 0 ? ( image={Empty.PRESENTED_IMAGE_SIMPLE}
<Line {...config} /> description={
) : ( loading
<Empty ? '数据加载中...'
image={Empty.PRESENTED_IMAGE_SIMPLE} : selectedSalaryRange
description={ ? '当前时间段无数据'
loading : '请先选择薪资区间'
? '数据加载中...' }
: selectedSalaryRange />
? '当前时间段无数据' )}
: '请先选择薪资区间' </Spin>
} </Card>
/> );
)}
</Spin> export const WorkYearCard = ({
</Card> loading,
); workYearData,
config,
export const WorkYearCard = ({ availableWorkYearRanges,
loading, selectedWorkYearRange,
workYearData, onWorkYearRangeChange,
config, }) => (
availableWorkYearRanges, <Card
selectedWorkYearRange, title="工作经验要求分布"
onWorkYearRangeChange, style={{ marginBottom: 16 }}
}) => ( bodyStyle={{
<Card padding: '12px',
title="工作经验要求分布" height: 250,
style={{ marginBottom: 16 }} display: 'flex',
bodyStyle={{ justifyContent: 'center',
padding: '12px', alignItems: 'center',
height: 250, overflow: 'hidden',
display: 'flex', }}
justifyContent: 'center', extra={
alignItems: 'center', <Select
overflow: 'hidden', value={selectedWorkYearRange}
}} onChange={onWorkYearRangeChange}
extra={ style={{ width: 180 }}
<Select loading={loading}
value={selectedWorkYearRange} placeholder="选择经验要求"
onChange={onWorkYearRangeChange} disabled={availableWorkYearRanges.length === 0}
style={{ width: 180 }} >
loading={loading} {availableWorkYearRanges.map((range: any) => (
placeholder="选择经验要求" <Option key={range} value={range}>
disabled={availableWorkYearRanges.length === 0} {range}
> </Option>
{availableWorkYearRanges.map((range: any) => ( ))}
<Option key={range} value={range}> </Select>
{range} }
</Option> >
))} <Spin spinning={loading}>
</Select> {workYearData && workYearData.length > 0 ? (
} <div style={{ width: '100%', height: '100%', padding: 8 }}>
> <Pie {...config} />
<Spin spinning={loading}> </div>
{workYearData && workYearData.length > 0 ? ( ) : (
<div style={{ width: '100%', height: '100%', padding: 8 }}> <Empty
<Pie {...config} /> image={Empty.PRESENTED_IMAGE_SIMPLE}
</div> description={loading ? '数据加载中...' : '暂无工作经验数据'}
) : ( />
<Empty )}
image={Empty.PRESENTED_IMAGE_SIMPLE} </Spin>
description={loading ? '数据加载中...' : '暂无工作经验数据'} </Card>
/> );
)}
</Spin> export const EducationCard = ({
</Card> loading,
); educationData,
config,
export const EducationCard = ({ availableEducationLevels,
loading, selectedEducationLevel,
educationData, onEducationLevelChange,
config, }) => (
availableEducationLevels, <Card
selectedEducationLevel, title="学历要求分布"
onEducationLevelChange, style={{ marginBottom: 16 }}
}) => ( bodyStyle={{ padding: '12px', height: 250 }}
<Card extra={
title="学历要求分布" <Select
style={{ marginBottom: 16 }} value={selectedEducationLevel}
bodyStyle={{ padding: '12px', height: 250 }} onChange={onEducationLevelChange}
extra={ style={{ width: 180 }}
<Select loading={loading}
value={selectedEducationLevel} placeholder="选择学历要求"
onChange={onEducationLevelChange} disabled={availableEducationLevels.length === 0}
style={{ width: 180 }} >
loading={loading} {availableEducationLevels.map((level) => (
placeholder="选择学历要求" <Option key={level} value={level}>
disabled={availableEducationLevels.length === 0} {level}
> </Option>
<Option value=""></Option> ))}
{availableEducationLevels.map((level) => ( </Select>
<Option key={level} value={level}> }
{level} >
</Option> <Spin spinning={loading}>
))} {educationData.length > 0 ? (
</Select> <Bar {...config} />
} ) : (
> <Empty
<Spin spinning={loading}> image={Empty.PRESENTED_IMAGE_SIMPLE}
{educationData.length > 0 ? ( description={loading ? '数据加载中...' : '暂无学历数据'}
<Bar {...config} /> />
) : ( )}
<Empty </Spin>
image={Empty.PRESENTED_IMAGE_SIMPLE} </Card>
description={loading ? '数据加载中...' : '暂无学历数据'} );
/>
)}
</Spin>
</Card>
);

View File

@@ -1,456 +1,456 @@
import { ChartConfig } from '@/types/analysis/industry'; import { ChartConfig } from '@/types/analysis/industry';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { formatDateForDisplay } from '../utils'; import { formatDateForDisplay } from '../utils';
export const getHeatmapConfig = (areaData: any[], timeDimension: string) => { export const getHeatmapConfig = (areaData: any[], timeDimension: string) => {
const sortedData = [...areaData].sort((a, b) => { const sortedData = [...areaData].sort((a, b) => {
if (timeDimension === '年') { if (timeDimension === '年') {
return parseInt(a.time) - parseInt(b.time); return parseInt(a.time) - parseInt(b.time);
} else if (timeDimension === '季度') { } else if (timeDimension === '季度') {
const [yearA, quarterA] = a.time.split('-Q'); const [yearA, quarterA] = a.time.split('-Q');
const [yearB, quarterB] = b.time.split('-Q'); const [yearB, quarterB] = b.time.split('-Q');
return yearA === yearB return yearA === yearB
? parseInt(quarterA) - parseInt(quarterB) ? parseInt(quarterA) - parseInt(quarterB)
: parseInt(yearA) - parseInt(yearB); : parseInt(yearA) - parseInt(yearB);
} else { } else {
return dayjs(a.time).valueOf() - dayjs(b.time).valueOf(); return dayjs(a.time).valueOf() - dayjs(b.time).valueOf();
} }
}); });
return { return {
data: sortedData, data: sortedData,
height: 240, height: 240,
autoFit: true, autoFit: true,
xField: 'name', xField: 'name',
yField: 'time', yField: 'time',
colorField: 'value', colorField: 'value',
shapeField: 'square', shapeField: 'square',
sizeField: 'value', sizeField: 'value',
xAxis: { xAxis: {
title: { title: {
text: '区域', text: '区域',
style: { fontSize: 12 }, style: { fontSize: 12 },
}, },
label: { label: {
style: { style: {
fontSize: 10, fontSize: 10,
fill: '#666', fill: '#666',
}, },
formatter: (text: string) => { formatter: (text: string) => {
return text.length > 4 ? `${text.substring(0, 3)}...` : text; return text.length > 4 ? `${text.substring(0, 3)}...` : text;
}, },
}, },
}, },
yAxis: { yAxis: {
title: { title: {
text: '时间', text: '时间',
style: { fontSize: 12 }, style: { fontSize: 12 },
}, },
label: { label: {
formatter: (text: string) => { formatter: (text: string) => {
if (timeDimension === '年') return `${text}`; if (timeDimension === '年') return `${text}`;
if (timeDimension === '季度') return text.replace('-Q', '年Q'); if (timeDimension === '季度') return text.replace('-Q', '年Q');
return text.replace('-', '年').replace('-', '月'); return text.replace('-', '年').replace('-', '月');
}, },
style: { style: {
fontSize: 10, fontSize: 10,
fill: '#666', fill: '#666',
}, },
}, },
}, },
label: { label: {
text: (d: { value: number }) => d.value.toString(), text: (d: { value: number }) => d.value.toString(),
position: 'inside', position: 'inside',
style: { style: {
fill: '#fff', fill: '#fff',
pointerEvents: 'none', pointerEvents: 'none',
}, },
}, },
scale: { scale: {
size: { range: [14, 14] }, size: { range: [14, 14] },
color: { range: ['#dddddd', '#9ec8e0', '#5fa4cd', '#2e7ab6', '#114d90'] }, color: { range: ['#dddddd', '#9ec8e0', '#5fa4cd', '#2e7ab6', '#114d90'] },
}, },
tooltip: { tooltip: {
title: (d: { name: any; time: any }) => `${d.name} - ${d.time}`, title: (d: { name: any; time: any }) => `${d.name} - ${d.time}`,
field: 'value', field: 'value',
valueFormatter: (v: number) => v.toString(), valueFormatter: (v: number) => v.toString(),
domStyles: { domStyles: {
'g2-tooltip': { 'g2-tooltip': {
padding: '8px 12px', padding: '8px 12px',
borderRadius: '4px', borderRadius: '4px',
}, },
}, },
}, },
interactions: [{ type: 'element-active' }], interactions: [{ type: 'element-active' }],
responsive: true, responsive: true,
}; };
}; };
export const getIndustryChartConfig = (currentIndustryData: any[], type: string): ChartConfig => ({ export const getIndustryChartConfig = (currentIndustryData: any[], type: string): ChartConfig => ({
data: currentIndustryData, data: currentIndustryData,
height: 200, height: 200,
xField: 'date', xField: 'date',
yField: 'value', yField: 'value',
seriesField: 'category', seriesField: 'category',
xAxis: { xAxis: {
type: 'cat', type: 'cat',
label: { label: {
formatter: (text: string) => text, formatter: (text: string) => text,
}, },
}, },
yAxis: { yAxis: {
label: { label: {
formatter: (val: string) => `${val}${type === '招聘增长率' ? '%' : ''}`, formatter: (val: string) => `${val}${type === '招聘增长率' ? '%' : ''}`,
}, },
}, },
point: { point: {
size: 4, size: 4,
shape: 'circle', shape: 'circle',
}, },
animation: { animation: {
appear: { appear: {
animation: 'path-in', animation: 'path-in',
duration: 1000, duration: 1000,
}, },
}, },
smooth: true, smooth: true,
interactions: [ interactions: [
{ {
type: 'tooltip', type: 'tooltip',
cfg: { cfg: {
render: (e, { title, items }) => { render: (e, { title, items }) => {
const list = items.filter((item) => item.value); const list = items.filter((item) => item.value);
return ( return (
<div key={title} style={{ padding: '8px 12px' }}> <div key={title} style={{ padding: '8px 12px' }}>
<h4 style={{ marginBottom: 8 }}>{title}</h4> <h4 style={{ marginBottom: 8 }}>{title}</h4>
{list.map((item, index) => { {list.map((item, index) => {
const { name, value, color } = item; const { name, value, color } = item;
return ( return (
<div <div
key={index} key={index}
style={{ margin: '4px 0', display: 'flex', justifyContent: 'space-between' }} style={{ margin: '4px 0', display: 'flex', justifyContent: 'space-between' }}
> >
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<span <span
style={{ style={{
display: 'inline-block', display: 'inline-block',
width: 8, width: 8,
height: 8, height: 8,
borderRadius: '50%', borderRadius: '50%',
backgroundColor: color, backgroundColor: color,
marginRight: 8, marginRight: 8,
}} }}
></span> ></span>
<span>{name}</span> <span>{name}</span>
</div> </div>
<b> <b>
{value} {value}
{type === '招聘增长率' ? '%' : ''} {type === '招聘增长率' ? '%' : ''}
</b> </b>
</div> </div>
); );
})} })}
</div> </div>
); );
}, },
}, },
}, },
], ],
legend: false, legend: false,
tooltip: { tooltip: {
showTitle: undefined, showTitle: undefined,
title: undefined, title: undefined,
customContent: undefined, customContent: undefined,
}, },
}); });
export const getSalaryChartConfig = (currentSalaryData: any[]): ChartConfig => ({ export const getSalaryChartConfig = (currentSalaryData: any[]): ChartConfig => ({
data: currentSalaryData, data: currentSalaryData,
height: 240, height: 240,
xField: 'date', xField: 'date',
yField: 'value', yField: 'value',
seriesField: 'category', seriesField: 'category',
xAxis: { xAxis: {
type: 'cat', type: 'cat',
label: { label: {
formatter: (text: string) => text, formatter: (text: string) => text,
}, },
}, },
yAxis: { yAxis: {
label: { label: {
formatter: (val: string) => `${val}`, formatter: (val: string) => `${val}`,
}, },
}, },
point: { point: {
size: 4, size: 4,
shape: 'circle', shape: 'circle',
}, },
animation: { animation: {
appear: { appear: {
animation: 'path-in', animation: 'path-in',
duration: 1000, duration: 1000,
}, },
}, },
smooth: true, smooth: true,
interactions: [ interactions: [
{ {
type: 'tooltip', type: 'tooltip',
cfg: { cfg: {
render: (e, { title, items }) => { render: (e, { title, items }) => {
const list = items.filter((item) => item.value); const list = items.filter((item) => item.value);
return ( return (
<div key={title} style={{ padding: '8px 12px' }}> <div key={title} style={{ padding: '8px 12px' }}>
<h4 style={{ marginBottom: 8 }}>{title}</h4> <h4 style={{ marginBottom: 8 }}>{title}</h4>
{list.map((item, index) => { {list.map((item, index) => {
const { name, value, color } = item; const { name, value, color } = item;
return ( return (
<div <div
key={index} key={index}
style={{ margin: '4px 0', display: 'flex', justifyContent: 'space-between' }} style={{ margin: '4px 0', display: 'flex', justifyContent: 'space-between' }}
> >
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<span <span
style={{ style={{
display: 'inline-block', display: 'inline-block',
width: 8, width: 8,
height: 8, height: 8,
borderRadius: '50%', borderRadius: '50%',
backgroundColor: color, backgroundColor: color,
marginRight: 8, marginRight: 8,
}} }}
></span> ></span>
<span>{name}</span> <span>{name}</span>
</div> </div>
<b>{value}</b> <b>{value}</b>
</div> </div>
); );
})} })}
</div> </div>
); );
}, },
}, },
}, },
], ],
legend: false, legend: false,
tooltip: { tooltip: {
showTitle: undefined, showTitle: undefined,
title: undefined, title: undefined,
customContent: undefined, customContent: undefined,
}, },
}); });
export const getWorkYearPieConfig = (workYearData: any[], selectedWorkYearRange: string) => { export const getWorkYearPieConfig = (workYearData: any[], selectedWorkYearRange: string) => {
const filteredData = workYearData const filteredData = workYearData
.filter((item) => item.category === selectedWorkYearRange) .filter((item) => selectedWorkYearRange === '全部' || item.category === selectedWorkYearRange)
.map((item) => ({ .map((item) => ({
...item, ...item,
date: formatDateForDisplay(item.date, '月'), date: formatDateForDisplay(item.date, '月'),
value: item.value || 0, value: item.value || 0,
type: `${formatDateForDisplay(item.date, '月')} ${item.category}`, type: `${formatDateForDisplay(item.date, '月')} ${item.category}`,
})); }));
return { return {
data: filteredData, data: filteredData,
angleField: 'value', angleField: 'value',
colorField: 'type', colorField: 'type',
radius: 0.2, radius: 0.2,
innerRadius: 0.5, innerRadius: 0.5,
label: { label: {
text: (d: { type: any; value: any }) => `${d.type}\n ${d.value}`, text: (d: { type: any; value: any }) => `${d.type}\n ${d.value}`,
position: 'spider', position: 'spider',
}, },
legend: false, legend: false,
tooltip: { tooltip: {
showTitle: true, showTitle: true,
title: '工作经验分布', title: '工作经验分布',
fields: ['type', 'value'], fields: ['type', 'value'],
formatter: (datum: { type: any; value: any }) => ({ formatter: (datum: { type: any; value: any }) => ({
name: datum.type, name: datum.type,
value: datum.value, value: datum.value,
}), }),
}, },
interactions: [{ type: 'element-active' }], interactions: [{ type: 'element-active' }],
padding: 'auto', padding: 'auto',
autoFit: true, autoFit: true,
}; };
}; };
export const getEducationBarConfig = (educationData: any[], selectedEducationLevel: string) => { export const getEducationBarConfig = (educationData: any[], selectedEducationLevel: string) => {
const educationLevelOrder = [ const educationLevelOrder = [
'不限', '不限',
'初中及以下', '初中及以下',
'中专/中技', '中专/中技',
'高中', '高中',
'大专', '大专',
'本科', '本科',
'硕士', '硕士',
'博士', '博士',
'MBA/EMBA', 'MBA/EMBA',
'留学-学士', '留学-学士',
'留学-硕士', '留学-硕士',
'留学-博士', '留学-博士',
]; ];
const educationColorMap: Record<string, string> = { const educationColorMap: Record<string, string> = {
: '#8884d8', : '#8884d8',
: '#82ca9d', : '#82ca9d',
'中专/中技': '#ffc658', '中专/中技': '#ffc658',
: '#ff8042', : '#ff8042',
: '#0088FE', : '#0088FE',
: '#00C49F', : '#00C49F',
: '#FFBB28', : '#FFBB28',
: '#FF8042', : '#FF8042',
'MBA/EMBA': '#8884d8', 'MBA/EMBA': '#8884d8',
'留学-学士': '#82ca9d', '留学-学士': '#82ca9d',
'留学-硕士': '#ffc658', '留学-硕士': '#ffc658',
'留学-博士': '#ff8042', '留学-博士': '#ff8042',
}; };
const cleanEducationData = educationData const cleanEducationData = educationData
.filter((item) => item && item.category && item.date && !isNaN(item.value)) .filter((item) => item && item.category && item.date && !isNaN(item.value))
.map((item) => ({ .map((item) => ({
...item, ...item,
date: formatDateForDisplay(item.date, '月'), date: formatDateForDisplay(item.date, '月'),
category: item.category || '不限', category: item.category || '不限',
value: Number(item.value) || 0, value: Number(item.value) || 0,
})); }));
if (!selectedEducationLevel) { if (!selectedEducationLevel) {
const educationSummary: Record<string, number> = {}; const educationSummary: Record<string, number> = {};
cleanEducationData.forEach((item) => { cleanEducationData.forEach((item) => {
if (!educationSummary[item.category]) { if (!educationSummary[item.category]) {
educationSummary[item.category] = 0; educationSummary[item.category] = 0;
} }
educationSummary[item.category] += item.value; educationSummary[item.category] += item.value;
}); });
const barData = Object.entries(educationSummary) const barData = Object.entries(educationSummary)
.filter(([_, value]) => value > 0) .filter(([_, value]) => value > 0)
.map(([name, value]) => ({ .map(([name, value]) => ({
name, name,
value, value,
color: educationColorMap[name] || '#999', color: educationColorMap[name] || '#999',
})) }))
.sort((a, b) => educationLevelOrder.indexOf(a.name) - educationLevelOrder.indexOf(b.name)); .sort((a, b) => educationLevelOrder.indexOf(a.name) - educationLevelOrder.indexOf(b.name));
return { return {
data: barData, data: barData,
height: 200, height: 200,
xField: 'value', xField: 'value',
yField: 'name', yField: 'name',
seriesField: 'name', seriesField: 'name',
color: ({ name }: { name: string }) => educationColorMap[name] || '#999', color: ({ name }: { name: string }) => educationColorMap[name] || '#999',
meta: { meta: {
name: { alias: '学历要求' }, name: { alias: '学历要求' },
value: { alias: '岗位数量' }, value: { alias: '岗位数量' },
}, },
xAxis: { xAxis: {
label: { label: {
formatter: (val: string) => `${val}`, formatter: (val: string) => `${val}`,
}, },
grid: { grid: {
line: { line: {
style: { style: {
stroke: '#f0f0f0', stroke: '#f0f0f0',
lineDash: [4, 4], lineDash: [4, 4],
}, },
}, },
}, },
}, },
yAxis: { yAxis: {
label: { label: {
formatter: (text: string) => text, formatter: (text: string) => text,
}, },
}, },
barStyle: { barStyle: {
radius: [2, 2, 0, 0], radius: [2, 2, 0, 0],
}, },
tooltip: { tooltip: {
showTitle: true, showTitle: true,
title: '学历要求分布', title: '学历要求分布',
fields: ['name', 'value'], fields: ['name', 'value'],
formatter: (datum: { name: any; value: any }) => ({ formatter: (datum: { name: any; value: any }) => ({
name: datum.name, name: datum.name,
value: datum.value, value: datum.value,
}), }),
domStyles: { domStyles: {
'g2-tooltip': { 'g2-tooltip': {
background: 'rgba(255, 255, 255, 0.9)', background: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
borderRadius: '4px', borderRadius: '4px',
}, },
}, },
}, },
interactions: [{ type: 'element-active' }], interactions: [{ type: 'element-active' }],
legend: false, legend: false,
animation: { animation: {
appear: { appear: {
animation: 'scale-in-y', animation: 'scale-in-y',
duration: 1000, duration: 1000,
}, },
}, },
}; };
} }
const timeData = cleanEducationData const timeData = cleanEducationData
.filter((item) => item.category === selectedEducationLevel) .filter((item) => selectedEducationLevel === '全部' || item.category === selectedEducationLevel)
.sort((a, b) => { .sort((a, b) => {
const dateA = dayjs(a.date, 'YYYY年MM月').valueOf(); const dateA = dayjs(a.date, 'YYYY年MM月').valueOf();
const dateB = dayjs(b.date, 'YYYY年MM月').valueOf(); const dateB = dayjs(b.date, 'YYYY年MM月').valueOf();
return dateA - dateB; return dateA - dateB;
}); });
return { return {
data: timeData, data: timeData,
height: 200, height: 200,
xField: 'date', xField: 'date',
yField: 'value', yField: 'value',
seriesField: 'category', seriesField: 'category',
color: educationColorMap[selectedEducationLevel] || '#999', color: educationColorMap[selectedEducationLevel] || '#999',
meta: { meta: {
date: { date: {
alias: '时间', alias: '时间',
type: 'cat', type: 'cat',
values: timeData values: timeData
.map((item) => item.date) .map((item) => item.date)
.sort((a, b) => { .sort((a, b) => {
const dateA = dayjs(a, 'YYYY年MM月').valueOf(); const dateA = dayjs(a, 'YYYY年MM月').valueOf();
const dateB = dayjs(b, 'YYYY年MM月').valueOf(); const dateB = dayjs(b, 'YYYY年MM月').valueOf();
return dateA - dateB; return dateA - dateB;
}), }),
}, },
value: { alias: '岗位数量' }, value: { alias: '岗位数量' },
}, },
barStyle: { barStyle: {
radius: [2, 2, 0, 0], radius: [2, 2, 0, 0],
}, },
tooltip: { tooltip: {
showTitle: true, showTitle: true,
title: `${selectedEducationLevel}趋势`, title: `${selectedEducationLevel}趋势`,
fields: ['date', 'value'], fields: ['date', 'value'],
formatter: (datum: { date: any; value: any }) => ({ name: datum.date, value: datum.value }), formatter: (datum: { date: any; value: any }) => ({ name: datum.date, value: datum.value }),
domStyles: { domStyles: {
'g2-tooltip': { 'g2-tooltip': {
background: 'rgba(255, 255, 255, 0.9)', background: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
borderRadius: '4px', borderRadius: '4px',
}, },
}, },
}, },
interactions: [{ type: 'element-active' }], interactions: [{ type: 'element-active' }],
legend: false, legend: false,
animation: { animation: {
appear: { appear: {
animation: 'scale-in-y', animation: 'scale-in-y',
duration: 1000, duration: 1000,
}, },
}, },
xAxis: { xAxis: {
type: 'cat', type: 'cat',
label: { label: {
formatter: (text: string) => text, formatter: (text: string) => text,
}, },
}, },
}; };
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,197 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Table, Card, Select } from 'antd';
import { getUserStaticsByArea } from '@/services/analysis/user';
import { Pie, Column } from '@ant-design/charts';
export default function AreaStatics({ data }) {
const [selectedArea, setSelectedArea] = useState('');
const [byArea, setByArea] = useState({});
useEffect(() => {
if (data.length > 0) {
setSelectedArea(data[0].dictValue);
fetchAreaStatics(data[0].dictValue);
}
}, [data]);
const fetchAreaStatics = async (areaCode) => {
const resData = await getUserStaticsByArea(areaCode);
if (resData.code === 200) {
const data = {
gender: transformNum(resData.data.gender),
education: transformNum(resData.data.education),
position: transformNum(resData.data.position),
salary: transformNum(resData.data.salary),
age: transformNum(resData.data.age),
};
console.log(data.education);
setByArea(data);
}
};
const transformNum = useCallback((datas) => {
return datas.map((item) => ({ ...item, data: parseInt(item.data) }));
}, []);
const handleAreaChange = (value) => {
setSelectedArea(value);
fetchAreaStatics(value);
};
const educationColumns = [
{ title: '学历', dataIndex: 'name', key: 'name' },
{ title: '数量', dataIndex: 'data', key: 'data' },
];
const genderColumns = [
{ title: '性别', dataIndex: 'name', key: 'name' },
{ title: '数量', dataIndex: 'data', key: 'data' },
];
const positionColumns = [
{ title: '求职岗位', dataIndex: 'name', key: 'name' },
{ title: '数量', dataIndex: 'data', key: 'data' },
];
const salaryColumns = [
{ title: '期望薪资', dataIndex: 'name', key: 'name' },
{ title: '数量', dataIndex: 'data', key: 'data' },
];
const ageColumns = [
{ title: '年龄阶段', dataIndex: 'name', key: 'name' },
{ title: '数量', dataIndex: 'data', key: 'data' },
];
return (
<div style={{ padding: '8px' }}>
<Card title="区域用户统计" style={{ marginBottom: '8px' }}>
<div
style={{
display: 'flex',
gap: '16px',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<Select
style={{ width: '200px', marginBottom: '8px' }}
placeholder="选择区域"
onChange={handleAreaChange}
value={selectedArea}
>
{data.map((item) => (
<Select.Option key={item.dictValue} value={item.dictValue}>
{item.dictLabel}
</Select.Option>
))}
</Select>
</div>
<div>
{byArea.gender && byArea.gender.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'nowrap', gap: '16px' }}>
<div style={{ display: 'flex', flexWrap: 'nowrap' }}>
<h4>: </h4>&nbsp; {byArea.gender.reduce((acc, cur) => acc + cur.data, 0)}
</div>
<div>: &nbsp;{byArea.gender.find((item) => item.name === '男')?.data || 0}</div>
<div>: &nbsp;{byArea.gender.find((item) => item.name === '女')?.data || 0}</div>
</div>
) : (
<div></div>
)}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}>
<Card title="学历分布" style={{ marginBottom: '8px' }}>
<Column
data={byArea.education}
xField="name"
yField="data"
label={{
text: (d) => `${d.data}`,
textBaseline: 'bottom',
}}
axis={{
y: {
label: {
formatter: (v) => `${v}k`,
},
},
}}
style={{
radiusTopLeft: 3,
radiusTopRight: 3,
columnWidthRatio: 0.6,
inset: 0.5,
}}
/>
</Card>
<Card
title="求职岗位排行"
style={{ marginBottom: '8px', height: '600px', overflowY: 'auto' }}
>
<Table
columns={positionColumns}
dataSource={byArea.position}
pagination={false}
rowKey="name"
size="small"
scroll={{ y: 440 }}
/>
</Card>
<Card title="期望薪资阶段" style={{ marginBottom: '8px' }}>
<Column
data={byArea.salary}
xField="name"
yField="data"
label={{
text: (d) => `${d.data}`,
textBaseline: 'bottom',
}}
axis={{
y: {
label: {
formatter: (v) => `${v}k`,
},
},
}}
style={{
radiusTopLeft: 3,
radiusTopRight: 3,
columnWidthRatio: 0.6,
inset: 0.5,
}}
/>
</Card>
<Card title="年龄阶段分布">
<Column
data={byArea.age}
xField="name"
yField="data"
label={{
text: (d) => `${d.data}`,
textBaseline: 'bottom',
}}
axis={{
y: {
label: {
formatter: (v) => `${v}k`,
},
},
}}
style={{
radiusTopLeft: 5,
radiusTopRight: 5,
columnWidthRatio: 0.3,
inset: 0.5,
}}
/>
</Card>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Column } from '@ant-design/charts';
import { getKeyWordSearchRank } from '@/services/analysis/user';
interface KeywordChartProps {
data?: any[];
}
export default function KeywordChart({ data: initialData }: KeywordChartProps) {
const [data, setData] = useState(initialData || []);
const fetchData = useCallback(
async (params?: { searchDate?: string; searchCount?: number; searchType?: 1 | 2 | 3 }) => {
const resData = await getKeyWordSearchRank({
searchDate: params?.searchDate || '2025-11-03',
// searchCount: params?.searchCount || 4,
searchType: params?.searchType || 3,
});
if (resData.code === 200) {
setData(resData.rows);
}
},
[],
);
useEffect(() => {
fetchData();
}, [fetchData]);
const config = {
data,
xField: 'keyWord',
yField: 'searchCount',
height: 350,
label: {
position: 'middle',
style: {
fill: '#FFFFFF',
opacity: 0.6,
},
},
xAxis: {
label: {
autoHide: true,
autoRotate: false,
},
},
meta: {
keyWord: { alias: '关键词' },
searchCount: { alias: '搜索次数' },
},
};
return (
<>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ marginRight: 16 }}></h3>
<select
onChange={(e) => {
fetchData({
searchDate: '2025-11-03',
searchCount: 10,
searchType: Number(e.target.value) as 1 | 2 | 3,
});
}}
defaultValue="3"
>
<option value="1"></option>
<option value="2"></option>
<option value="3"></option>
</select>
</div>
<div
style={{
height: '400px',
overflowY: 'auto',
border: '1px solid #eee',
borderRadius: '4px',
padding: '8px',
}}
>
{data?.map((item, index) => (
<div
key={index}
style={{
padding: '8px',
borderBottom: '1px solid #eee',
display: 'flex',
justifyContent: 'space-around',
}}
>
<span>{item.keyWord}</span>
<span>{item.searchCount}</span>
</div>
))}
</div>
</>
);
}

View File

@@ -0,0 +1,84 @@
import React, { useState, useEffect, useCallback } from 'react';
import { getWantedPositionStaticsCount } from '@/services/analysis/user';
import { Card, Table } from 'antd';
interface PositionStatics {
jobName: string;
count: number;
}
interface getWantedPositionStaticsCountStaticsResult {
male: PositionStatics[];
female: PositionStatics[];
}
const WantedPositionStatics: React.FC = () => {
const [staticsData, setStaticsData] = useState<StaticsResult>({ male: [], female: [] });
const fetchStatics = useCallback(async () => {
const resData = await getWantedPositionStaticsCount({ sexCode: 0 });
if (resData.code === 200) {
const malePositions: Record<string, number> = {};
const femalePositions: Record<string, number> = {};
resData.data.forEach((item) => {
if (item.sexCode === '男') {
malePositions[item.jobName] = (malePositions[item.jobName] || 0) + 1;
} else if (item.sexCode === '女') {
femalePositions[item.jobName] = (femalePositions[item.jobName] || 0) + 1;
}
});
const result: StaticsResult = {
male: Object.entries(malePositions).map(([jobName, count]) => ({ jobName, count })),
female: Object.entries(femalePositions).map(([jobName, count]) => ({ jobName, count })),
};
setStaticsData(result);
}
}, []);
useEffect(() => {
fetchStatics();
}, [fetchStatics]);
const columns = [
{
title: '岗位名称',
dataIndex: 'jobName',
key: 'jobName',
},
{
title: '数量',
dataIndex: 'count',
key: 'count',
},
];
return (
<div style={{ padding: '8px', display: 'flex', width: '100%', height: '100%' }}>
<Card title="男性用户期望岗位统计" style={{ width: '50%' }}>
<Table
columns={columns}
dataSource={staticsData.male}
pagination={false}
rowKey="jobName"
size="small"
scroll={{ y: 440 }}
/>
</Card>
<Card title="女性用户期望岗位统计" style={{ width: '50%' }}>
<Table
columns={columns}
dataSource={staticsData.female}
pagination={false}
rowKey="jobName"
size="small"
scroll={{ y: 440 }}
/>
</Card>
</div>
);
};
export default WantedPositionStatics;

View File

@@ -1 +1,60 @@
<button>111111111111</button> import React, { useCallback, useEffect, useState } from 'react';
import {
getUserLoginCountStatics,
getWantedPositionStaticsCount,
getUserStaticsByArea,
getPercentOfResumeCompletionRate,
} from '@/services/analysis/user';
import KeywordChart from './components/KeywordChart';
import { getDictDataList } from '@/services/system/dictdata';
import AreaStatics from './components/AreaStatics';
import WantedPositionStatics from './components/WantedPositionStatics';
export default function userAnalysis() {
const [data, setData] = useState([]);
const [PofrcRate, setPofrcRate] = useState([]);
const [areaDict, setAreaDict] = useState([]);
useEffect(() => {
// loginCounts();
dictDataList();
percentOfResumeCompletionRate();
}, []);
const loginCounts = useCallback(async () => {
let resData = await getUserLoginCountStatics();
console.log(resData);
}, []);
const dictDataList = useCallback(async () => {
let resData = await getDictDataList({ dictType: 'area' });
if (resData.code === 200) {
setAreaDict(resData.rows);
}
}, []);
const percentOfResumeCompletionRate = useCallback(async () => {
let resData = await getPercentOfResumeCompletionRate();
if (resData.code === 200) {
console.log(resData.data);
setPofrcRate(resData.data);
}
}, []);
return (
<div style={{ padding: '16px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '16px' }}>
<div style={{ width: '100%' }}>
<KeywordChart />
</div>
<div style={{ width: '100%' }}>
<WantedPositionStatics />
</div>
</div>
<div style={{ width: '100%' }}>
<AreaStatics data={areaDict} />
</div>
</div>
);
}

View File

@@ -70,7 +70,7 @@ const SubWayEdit: React.FC<ListFormProps> = (props) => {
company: string; company: string;
stationOrder: number; stationOrder: number;
}> }>
title={`${props.values ? '编辑' : '新增'}站点`} title={`${props.values ? '编辑' : '新增'}商圈`}
form={form} form={form}
// layout="inline" // layout="inline"
autoFocusFirstInput autoFocusFirstInput

View File

@@ -47,7 +47,7 @@ const SubWayEdit: React.FC<ListFormProps> = (props) => {
name: string; name: string;
company: string; company: string;
}> }>
title={`${props.values ? '编辑' : '新增'}线路`} title={`${props.values ? '编辑' : '新增'}区域`}
form={form} form={form}
// layout="inline" // layout="inline"
autoFocusFirstInput autoFocusFirstInput

View File

@@ -1,8 +1,8 @@
import React, { Fragment, useEffect, useRef, useState } from 'react'; import React, { Fragment, useEffect, useRef, useState } from 'react';
import { FormattedMessage, useAccess } from '@umijs/max'; import { FormattedMessage, useAccess } from '@umijs/max';
import { Button, FormInstance, message, Modal, Descriptions } from 'antd'; import { Button, Descriptions, FormInstance, message, Modal } from 'antd';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components'; import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { DeleteOutlined, FormOutlined, PlusOutlined, EyeOutlined, AlignLeftOutlined } from '@ant-design/icons'; import { AlignLeftOutlined, DeleteOutlined, FormOutlined, PlusOutlined } from '@ant-design/icons';
import EditCompanyListRow from './edit'; import EditCompanyListRow from './edit';
import { import {
addCmsCompanyList, addCmsCompanyList,
@@ -15,14 +15,14 @@ import { getDictValueEnum } from '@/services/system/dict';
import DictTag from '@/components/DictTag'; import DictTag from '@/components/DictTag';
// 详情查看组件 // 详情查看组件
const CompanyDetailModal = ({ const CompanyDetailModal = ({
visible, visible,
onCancel, onCancel,
record, record,
scaleEnum scaleEnum,
}: { }: {
visible: boolean; visible: boolean;
onCancel: () => void; onCancel: () => void;
record?: API.CompanyList.Company; record?: API.CompanyList.Company;
scaleEnum: Record<string, any>; scaleEnum: Record<string, any>;
}) => { }) => {
@@ -107,12 +107,13 @@ function ManagementList() {
valueType: 'text', valueType: 'text',
align: 'center', align: 'center',
}, },
{ // {
title: '公司行业', // title: '公司行业',
dataIndex: 'industry', // hideInSearch: true,
valueType: 'text', // dataIndex: 'industry',
align: 'center', // valueType: 'text',
}, // align: 'center',
// },
{ {
title: '公司规模', title: '公司规模',
dataIndex: 'scale', dataIndex: 'scale',
@@ -276,4 +277,4 @@ function ManagementList() {
); );
} }
export default ManagementList; export default ManagementList;

View File

@@ -106,13 +106,13 @@ function ManagementList() {
align: 'center', align: 'center',
}, },
{ {
title: '最小薪资', title: '最小薪资(元/月)',
dataIndex: 'minSalary', dataIndex: 'minSalary',
valueType: 'text', valueType: 'text',
align: 'center', align: 'center',
}, },
{ {
title: '最大薪资', title: '最大薪资(元/月)',
dataIndex: 'maxSalary', dataIndex: 'maxSalary',
valueType: 'text', valueType: 'text',
align: 'center', align: 'center',

View File

@@ -0,0 +1,43 @@
import { request } from '@umijs/max';
// 行业
export async function getUserLoginCountStatics(params?: API.Analysis.IndustryParams) {
return request<API.Analysis.User>('/api/cms/statics/getUserLoginCountStatics', {
method: 'GET',
params,
});
}
// 查询关键词搜索排行
export async function getKeyWordSearchRank(params?: {
searchDate?: string;
searchCount?: number;
searchType?: 1 | 2 | 3;
}) {
return request<API.Analysis.User>('/api/cms/statics/getKeyWordSearchRank', {
method: 'GET',
params,
});
}
// 统计男性和女性用户偏好的期望岗位
export async function getWantedPositionStaticsCount(params?: API.Analysis.IndustryParams) {
return request<API.Analysis.User>('/api/cms/statics/getWantedPositionStaticsCount', {
method: 'GET',
params,
});
}
// 获取区域内的用户类别统计
export async function getUserStaticsByArea(area: string) {
return request<API.Analysis.User>(`/api/cms/statics/getUserStaticsByArea/${area}`, {
method: 'GET',
});
}
// 获取区域内的用户类别统计
export async function getPercentOfResumeCompletionRate(params?: API.Analysis.IndustryParams) {
return request<API.Analysis.User>(`/api/app/user/getPercentOfResumeCompletionRate`, {
method: 'GET',
params,
});
}