岗位分析/ 用户分析 style

用户管理查看简历
This commit is contained in:
bin
2025-12-01 17:35:58 +08:00
parent e6543185ab
commit 1ea8f6b609
9 changed files with 1711 additions and 651 deletions

View File

@@ -1,7 +1,9 @@
import React from 'react';
import { Card, Empty, Select, Spin } from 'antd';
import { Card, Empty, Select, Spin, Tag } from 'antd';
import { Bar, Heatmap, Line, Pie } from '@ant-design/charts';
const { Option } = Select;
export const IndustryTrendCard = ({
loading,
currentIndustryData,
@@ -11,17 +13,33 @@ export const IndustryTrendCard = ({
onIndustryChange,
}) => (
<Card
title="行业趋势分析"
style={{ marginBottom: 16 }}
bodyStyle={{ padding: '12px', height: 250 }}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>📊 </span>
<Tag color="blue" style={{ marginLeft: 'auto' }}>
</Tag>
</div>
}
style={{
borderRadius: 12,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
bodyStyle={{
padding: '16px',
height: 280,
background: 'linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%)',
borderRadius: 8
}}
extra={
<Select
value={selectedIndustry}
onChange={onIndustryChange}
style={{ width: 180 }}
style={{ width: 200 }}
loading={loading}
placeholder="选择行业"
disabled={availableIndustries.length === 0}
suffixIcon="🏢"
>
{availableIndustries.map((industry: any) => (
<Option key={industry} value={industry}>
@@ -31,7 +49,7 @@ export const IndustryTrendCard = ({
</Select>
}
>
<Spin spinning={loading}>
<Spin spinning={loading} tip="数据加载中...">
{currentIndustryData.length > 0 ? (
<Line {...config} />
) : (
@@ -40,6 +58,7 @@ export const IndustryTrendCard = ({
description={
loading ? '数据加载中...' : selectedIndustry ? '当前时间段无数据' : '请先选择行业'
}
style={{ margin: '40px 0' }}
/>
)}
</Spin>
@@ -48,23 +67,36 @@ export const IndustryTrendCard = ({
export const AreaAnalysisCard = ({ loading, areaData, config }) => (
<Card
title="区域分析"
style={{ marginBottom: 16 }}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>🌍 </span>
<Tag color="green"></Tag>
</div>
}
style={{
borderRadius: 12,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
bodyStyle={{
padding: 12,
height: 250,
padding: 16,
height: 280,
background: 'linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%)',
borderRadius: 8,
position: 'relative',
}}
>
{loading ? (
<Spin tip="数据加载中..." size="large" />
) : areaData.length > 0 ? (
<div style={{ height: '100%', width: '100%', minHeight: 300 }}>
<Heatmap {...config} />
</div>
) : (
<Empty description="暂无区域数据" />
)}
<Spin spinning={loading} tip="数据加载中...">
{areaData.length > 0 ? (
<div style={{ height: '100%', width: '100%' }}>
<Heatmap {...config} />
</div>
) : (
<Empty
description="暂无区域数据"
style={{ margin: '40px 0' }}
/>
)}
</Spin>
</Card>
);
@@ -77,9 +109,22 @@ export const SalaryTrendCard = ({
onSalaryRangeChange,
}) => (
<Card
title="薪资区间趋势分析"
style={{ marginBottom: 16 }}
bodyStyle={{ padding: '12px', height: 250 }}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>💰 </span>
<Tag color="orange"></Tag>
</div>
}
style={{
borderRadius: 12,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
bodyStyle={{
padding: '16px',
height: 280,
background: 'linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%)',
borderRadius: 8
}}
extra={
<Select
value={selectedSalaryRange}
@@ -88,6 +133,7 @@ export const SalaryTrendCard = ({
loading={loading}
placeholder="选择薪资区间"
disabled={availableSalaryRanges.length === 0}
suffixIcon="💵"
>
{availableSalaryRanges.map((range) => (
<Option key={range} value={range}>
@@ -97,7 +143,7 @@ export const SalaryTrendCard = ({
</Select>
}
>
<Spin spinning={loading}>
<Spin spinning={loading} tip="数据加载中...">
{currentSalaryData.length > 0 ? (
<Line {...config} />
) : (
@@ -110,6 +156,7 @@ export const SalaryTrendCard = ({
? '当前时间段无数据'
: '请先选择薪资区间'
}
style={{ margin: '40px 0' }}
/>
)}
</Spin>
@@ -125,11 +172,21 @@ export const WorkYearCard = ({
onWorkYearRangeChange,
}) => (
<Card
title="工作经验要求分布"
style={{ marginBottom: 16 }}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span> </span>
<Tag color="purple"></Tag>
</div>
}
style={{
borderRadius: 12,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
bodyStyle={{
padding: '12px',
height: 250,
padding: '16px',
height: 280,
background: 'linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%)',
borderRadius: 8,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
@@ -139,10 +196,11 @@ export const WorkYearCard = ({
<Select
value={selectedWorkYearRange}
onChange={onWorkYearRangeChange}
style={{ width: 180 }}
style={{ width: 140 }}
loading={loading}
placeholder="选择经验要求"
disabled={availableWorkYearRanges.length === 0}
suffixIcon="👔"
>
{availableWorkYearRanges.map((range: any) => (
<Option key={range} value={range}>
@@ -152,7 +210,7 @@ export const WorkYearCard = ({
</Select>
}
>
<Spin spinning={loading}>
<Spin spinning={loading} tip="数据加载中...">
{workYearData && workYearData.length > 0 ? (
<div style={{ width: '100%', height: '100%', padding: 8 }}>
<Pie {...config} />
@@ -161,6 +219,7 @@ export const WorkYearCard = ({
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={loading ? '数据加载中...' : '暂无工作经验数据'}
style={{ margin: '40px 0' }}
/>
)}
</Spin>
@@ -176,17 +235,31 @@ export const EducationCard = ({
onEducationLevelChange,
}) => (
<Card
title="学历要求分布"
style={{ marginBottom: 16 }}
bodyStyle={{ padding: '12px', height: 250 }}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>🎓 </span>
<Tag color="red"></Tag>
</div>
}
style={{
borderRadius: 12,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
bodyStyle={{
padding: '16px',
height: 280,
background: 'linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%)',
borderRadius: 8
}}
extra={
<Select
value={selectedEducationLevel}
onChange={onEducationLevelChange}
style={{ width: 180 }}
style={{ width: 140 }}
loading={loading}
placeholder="选择学历要求"
disabled={availableEducationLevels.length === 0}
suffixIcon="📚"
>
{availableEducationLevels.map((level) => (
<Option key={level} value={level}>
@@ -196,15 +269,16 @@ export const EducationCard = ({
</Select>
}
>
<Spin spinning={loading}>
<Spin spinning={loading} tip="数据加载中...">
{educationData.length > 0 ? (
<Bar {...config} />
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={loading ? '数据加载中...' : '暂无学历数据'}
style={{ margin: '40px 0' }}
/>
)}
</Spin>
</Card>
);
);

View File

@@ -2,6 +2,20 @@ import { ChartConfig } from '@/types/analysis/industry';
import dayjs from 'dayjs';
import { formatDateForDisplay } from '../utils';
// 统一的颜色方案
const COLOR_PALETTE = [
'#1890ff',
'#36cfc9',
'#faad14',
'#f5222d',
'#722ed1',
'#fa8c16',
'#52c41a',
'#eb2f96',
'#13c2c2',
'#eb2f96',
];
export const getHeatmapConfig = (areaData: any[], timeDimension: string) => {
const sortedData = [...areaData].sort((a, b) => {
if (timeDimension === '年') {
@@ -19,17 +33,20 @@ export const getHeatmapConfig = (areaData: any[], timeDimension: string) => {
return {
data: sortedData,
height: 240,
height: 280,
autoFit: true,
xField: 'name',
yField: 'time',
colorField: 'value',
shapeField: 'square',
sizeField: 'value',
colorField: 'value',
shapeField: 'square',
sizeField: 'value',
xAxis: {
title: {
text: '区域',
style: { fontSize: 12 },
style: {
fontSize: 12,
fill: '#666',
},
},
label: {
style: {
@@ -44,7 +61,10 @@ export const getHeatmapConfig = (areaData: any[], timeDimension: string) => {
yAxis: {
title: {
text: '时间',
style: { fontSize: 12 },
style: {
fontSize: 12,
fill: '#666',
},
},
label: {
formatter: (text: string) => {
@@ -59,39 +79,56 @@ export const getHeatmapConfig = (areaData: any[], timeDimension: string) => {
},
},
label: {
text: (d: { value: number }) => d.value.toString(),
text: (d: { value: number }) => (d.value > 0 ? d.value.toString() : ''),
position: 'inside',
style: {
fill: '#fff',
pointerEvents: 'none',
fontSize: 10,
fontWeight: 'bold',
},
},
// 关键配置:值越大,颜色越深;值越大,方块越大
scale: {
size: { range: [14, 14] },
color: { range: ['#dddddd', '#9ec8e0', '#5fa4cd', '#2e7ab6', '#114d90'] },
size: {
range: [12, 20], // 小值对应小方块,大值对应大方块
},
color: {
range: [
'#b7c2c3ff', // 浅色,对应小值
'#afd4fbff',
'#8ecaf0ff',
'#40a9ff',
'#1890ff',
'#0050b3', // 深色,对应大值
],
},
},
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',
},
},
title: '区域岗位分布',
items: [
{ field: 'name', name: '区域' },
{ field: 'time', name: '时间' },
{ field: 'value', name: '岗位数量', valueFormatter: (v) => `${v}` },
],
},
interactions: [{ type: 'element-active' }],
responsive: true,
animation: {
appear: {
animation: 'scale-in',
duration: 1000,
},
},
};
};
export const getIndustryChartConfig = (currentIndustryData: any[], type: string): ChartConfig => ({
data: currentIndustryData,
height: 200,
height: 280,
xField: 'date',
yField: 'value',
seriesField: 'category',
color: COLOR_PALETTE,
xAxis: {
type: 'cat',
label: {
@@ -107,66 +144,37 @@ export const getIndustryChartConfig = (currentIndustryData: any[], type: string)
size: 4,
shape: 'circle',
},
line: {
size: 3,
style: {
lineCap: 'round',
},
},
animation: {
appear: {
animation: 'path-in',
duration: 1000,
duration: 1500,
},
},
smooth: true,
interactions: [
{
type: 'tooltip',
cfg: {
render: (e, { title, items }) => {
const list = items.filter((item) => item.value);
return (
<div key={title} style={{ padding: '8px 12px' }}>
<h4 style={{ marginBottom: 8 }}>{title}</h4>
{list.map((item, index) => {
const { name, value, color } = item;
return (
<div
key={index}
style={{ margin: '4px 0', display: 'flex', justifyContent: 'space-between' }}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: color,
marginRight: 8,
}}
></span>
<span>{name}</span>
</div>
<b>
{value}
{type === '招聘增长率' ? '%' : ''}
</b>
</div>
);
})}
</div>
);
},
},
},
],
legend: false,
tooltip: {
showTitle: undefined,
title: undefined,
customContent: undefined,
title: '行业趋势',
items: [
{ field: 'category', name: '行业' },
{ field: 'date', name: '时间' },
{
field: 'value',
name: type === '招聘增长率' ? '增长率' : '岗位数量',
valueFormatter: (v) => `${v}${type === '招聘增长率' ? '%' : ''}`,
},
],
},
});
export const getSalaryChartConfig = (currentSalaryData: any[]): ChartConfig => ({
data: currentSalaryData,
height: 240,
height: 280,
xField: 'date',
yField: 'value',
seriesField: 'category',
@@ -252,26 +260,41 @@ export const getWorkYearPieConfig = (workYearData: any[], selectedWorkYearRange:
return {
data: filteredData,
angleField: 'value',
colorField: 'type',
colorField: 'category',
radius: 0.2,
innerRadius: 0.5,
label: {
text: (d: { type: any; value: any }) => `${d.type}\n ${d.value}`,
position: 'spider',
transform: [
{
type: 'overlapDodgeY',
},
],
},
legend: false,
tooltip: {
showTitle: true,
title: '工作经验分布',
fields: ['type', 'value'],
formatter: (datum: { type: any; value: any }) => ({
name: datum.type,
value: datum.value,
}),
items: [
{ field: 'date', name: '时间' },
{ field: 'category', name: '工作经验' },
{
field: 'value',
name: '岗位数量',
valueFormatter: (v) => `${v}`,
},
],
},
interactions: [{ type: 'element-active' }],
padding: 'auto',
padding: [20, 0, 40, 0],
autoFit: true,
color: COLOR_PALETTE,
animation: {
appear: {
animation: 'wave-in',
duration: 1000,
},
},
};
};
@@ -285,172 +308,86 @@ export const getEducationBarConfig = (educationData: any[], selectedEducationLev
'本科',
'硕士',
'博士',
'MBA/EMBA',
'留学-学士',
'留学-硕士',
'留学-博士',
];
const educationColorMap: Record<string, string> = {
: '#8884d8',
: '#82ca9d',
'中专/中技': '#ffc658',
: '#ff8042',
: '#0088FE',
: '#00C49F',
: '#FFBB28',
: '#FF8042',
'MBA/EMBA': '#8884d8',
'留学-学士': '#82ca9d',
'留学-硕士': '#ffc658',
'留学-博士': '#ff8042',
};
// 汇总数据
const summaryData = educationData.reduce((acc, item) => {
const existing = acc.find((d: any) => d.name === item.category);
if (existing) {
existing.value += item.value;
} else {
acc.push({
name: item.category,
value: item.value,
});
}
return acc;
}, [] as any[]);
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<string, number> = {};
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) => selectedEducationLevel === '全部' || 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;
});
const barData = summaryData
.filter((item) => item.value > 0)
.sort((a, b) => educationLevelOrder.indexOf(a.name) - educationLevelOrder.indexOf(b.name));
return {
data: timeData,
height: 200,
xField: 'date',
yField: 'value',
seriesField: 'category',
color: educationColorMap[selectedEducationLevel] || '#999',
data: barData,
height: 280,
xField: 'value',
yField: 'name',
seriesField: 'name',
color: COLOR_PALETTE,
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;
}),
name: {
alias: '学历要求',
},
value: {
alias: '岗位数量',
},
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',
xAxis: {
label: {
style: {
fill: '#666',
fontSize: 11,
},
},
grid: {
line: {
style: {
stroke: '#f0f0f0',
lineDash: [3, 3],
},
},
},
},
yAxis: {
label: {
style: {
fill: '#666',
fontSize: 11,
},
},
},
barStyle: {
radius: [4, 4, 0, 0],
},
tooltip: {
title: '学历要求分布',
items: [
{ field: 'name', name: '学历要求' },
{
field: 'value',
name: '岗位数量',
valueFormatter: (v) => `${v}`,
},
],
},
interactions: [{ type: 'element-active' }],
legend: false,
animation: {
appear: {
animation: 'scale-in-y',
animation: 'scale-in-x',
duration: 1000,
},
},
xAxis: {
type: 'cat',
label: {
formatter: (text: string) => text,
},
},
};
};

View File

@@ -64,7 +64,8 @@ const IndustryTrendPage: React.FC = () => {
const [params, setParams] = useState<IndustryTrendState>({
timeDimension: '月',
type: '岗位发布数量',
startTime: dayjs().subtract(5, 'month').format('YYYY-MM'),
// startTime: dayjs().subtract(5, 'month').format('YYYY-MM'),
startTime:dayjs().month(0).format('YYYY-MM'),
endTime: dayjs().format('YYYY-MM'),
selectedIndustry: '',
selectedSalaryRange: '',
@@ -374,7 +375,8 @@ const IndustryTrendPage: React.FC = () => {
let newStartTime = '';
if (value === '月') {
newStartTime = now.subtract(5, 'month').format('YYYY-MM');
// newStartTime = now.subtract(5, 'month').format('YYYY-MM');
newStartTime = dayjs().month(0).format('YYYY-MM');
} else if (value === '季度') {
newStartTime = now.subtract(6, 'quarter').format('YYYY-Q');
} else {
@@ -451,14 +453,14 @@ const IndustryTrendPage: React.FC = () => {
}, [params.timeDimension, params.startTime, params.endTime, params.type]);
return (
<div style={{ padding: 16 }} ref={containerRef}>
<Card title="趋势分析" style={{ marginBottom: 24 }}>
<div style={{ marginBottom: 24 }}>
<div ref={containerRef} style={{borderRadius:'20px', padding:'15px', background: 'linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%)' }}>
<div style={{ marginBottom: 8, padding: '12px 16px', background: 'white', borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<Space size="middle" wrap>
<Select
value={params.timeDimension}
onChange={handleTimeDimensionChange}
style={{ width: 100 }}
size="middle"
>
<Option value="月"></Option>
<Option value="季度"></Option>
@@ -475,16 +477,18 @@ const IndustryTrendPage: React.FC = () => {
}
value={getPickerValue()}
onChange={handleDateRangeChange}
style={{ width: 180 }}
style={{ width: 220 }}
disabled={industryLoading || areaLoading}
allowClear={false}
disabledDate={disabledDate}
size="middle"
/>
<Select
value={params.type}
onChange={(value) => setParams((p) => ({ ...p, type: value }))}
style={{ width: 100 }}
style={{ width: 120 }}
size="middle"
>
<Option value="岗位发布数量"></Option>
<Option value="招聘增长率"></Option>
@@ -497,13 +501,19 @@ const IndustryTrendPage: React.FC = () => {
fetchAreaData();
}}
loading={industryLoading || areaLoading}
size="middle"
style={{
background: 'linear-gradient(45deg, #1890ff, #36cfc9)',
border: 'none',
borderRadius: 6
}}
>
</Button>
</Space>
</div>
<Row gutter={[16, 16]}>
<Row gutter={[8, 8]}>
{/* 行业趋势图表 - 全宽显示 */}
<Col xs={24} lg={24}>
<IndustryTrendCard
@@ -516,9 +526,13 @@ const IndustryTrendPage: React.FC = () => {
/>
</Col>
{/* 区域分析和薪资趋势 - 中等屏幕下分成两列 */}
{/* 区域分析和薪资趋势 */}
<Col xs={24} md={12} lg={12}>
<AreaAnalysisCard loading={areaLoading} areaData={areaData} config={heatmapConfig} />
<AreaAnalysisCard
loading={areaLoading}
areaData={areaData}
config={heatmapConfig}
/>
</Col>
<Col xs={24} md={12} lg={12}>
<SalaryTrendCard
@@ -532,7 +546,8 @@ const IndustryTrendPage: React.FC = () => {
}
/>
</Col>
{/* 工作经验和学历要求 - 中等屏幕下分成两列 */}
{/* 工作经验和学历要求 */}
<Col xs={24} md={12} lg={12}>
<WorkYearCard
loading={workYearLoading}
@@ -556,7 +571,7 @@ const IndustryTrendPage: React.FC = () => {
/>
</Col>
</Row>
</Card>
</div>
);
};

View File

@@ -1,10 +1,14 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Table, Card, Select } from 'antd';
import { Card, Select, Row, Col, Statistic, Tag, Empty, Spin } from 'antd';
import { getUserStaticsByArea } from '@/services/analysis/user';
import { Pie, Column } from '@ant-design/charts';
import { Column, Pie } from '@ant-design/charts';
const { Option } = Select;
export default function AreaStatics({ data }) {
const [selectedArea, setSelectedArea] = useState('');
const [byArea, setByArea] = useState({});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (data.length > 0) {
@@ -14,22 +18,28 @@ export default function AreaStatics({ data }) {
}, [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);
setLoading(true);
try {
const resData = await getUserStaticsByArea(areaCode);
if (resData.code === 200) {
const processedData = {
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),
};
setByArea(processedData);
}
} catch (error) {
console.error('获取区域数据失败:', error);
} finally {
setLoading(false);
}
};
const transformNum = useCallback((datas) => {
return datas.map((item) => ({ ...item, data: parseInt(item.data) }));
return (datas || []).map((item) => ({ ...item, data: parseInt(item.data) || 0 }));
}, []);
const handleAreaChange = (value) => {
@@ -37,161 +47,249 @@ export default function AreaStatics({ data }) {
fetchAreaStatics(value);
};
const educationColumns = [
{ title: '学历', dataIndex: 'name', key: 'name' },
{ title: '数量', dataIndex: 'data', key: 'data' },
];
// 统一的柱状图配置
const getColumnConfig = (data: any[], color: string, title: string) => ({
data: data || [],
xField: 'name',
yField: 'data',
height: 220,
autoFit: true,
style:{
columnWidthRatio: 0.4, // 统一柱状图宽度比例
},
color: color,
xAxis: {
label: {
style: {
fontSize: 12,
fill: '#666',
},
formatter: (text: string) => {
return text.length > 4 ? `${text.substring(0, 4)}...` : text;
},
},
grid: {
line: {
style: {
stroke: '#e8e8e8',
lineWidth: 1,
lineDash: [3, 3],
},
},
alignTick: true,
},
},
yAxis: {
label: {
style: {
fontSize: 12,
fill: '#666',
},
},
grid: {
line: {
style: {
stroke: '#e8e8e8',
lineWidth: 1,
lineDash: [3, 3],
},
},
alignTick: true,
},
},
tooltip: {
title: title,
items: [
{ field: 'name', name: '分类' },
{ field: 'data', name: '人数', valueFormatter: (v) => `${v}` },
],
},
interactions: [{ type: 'element-active' }],
animation: {
appear: {
animation: 'scale-in-y',
duration: 1000,
},
},
});
const genderColumns = [
{ title: '性别', dataIndex: 'name', key: 'name' },
{ title: '数量', dataIndex: 'data', key: 'data' },
];
// 教育程度柱状图配置
const educationConfig = getColumnConfig(byArea.education, '#36cfc9', '学历分布');
const positionColumns = [
{ title: '求职岗位', dataIndex: 'name', key: 'name' },
{ title: '数量', dataIndex: 'data', key: 'data' },
];
// 期望薪资柱状图配置
const salaryConfig = getColumnConfig(byArea.salary, '#faad14', '期望薪资分布');
const salaryColumns = [
{ title: '期望薪资', dataIndex: 'name', key: 'name' },
{ title: '数量', dataIndex: 'data', key: 'data' },
];
// 年龄分布柱状图配置
const ageConfig = getColumnConfig(byArea.age, '#722ed1', '年龄分布');
const ageColumns = [
{ title: '年龄阶段', dataIndex: 'name', key: 'name' },
{ title: '数量', dataIndex: 'data', key: 'data' },
];
// 性别饼图配置
const genderConfig = {
data: byArea.gender || [],
angleField: 'data',
colorField: 'name',
radius: 0.6,
height: 220,
label: {
text: (d) => `${d.name}\n${d.data}`,
position: 'spider',
style: {
fontSize: 12,
textAlign: 'center',
},
transform: [
{
type: 'overlapDodgeY',
},
],
},
tooltip: {
title: '性别分布',
items: [
{ field: 'name', name: '性别' },
{ field: 'data', name: '人数', valueFormatter: (v) => `${v}` },
],
},
interactions: [{ type: 'element-active' }],
animation: {
appear: {
animation: 'wave-in',
duration: 1000,
},
},
};
// 计算用户总数
const totalUsers = byArea.gender ? byArea.gender.reduce((acc, cur) => acc + cur.data, 0) : 0;
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)}
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>🌍 </span>
<Tag color="green"></Tag>
</div>
}
style={{
borderRadius: 12,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
bodyStyle={{
padding: '16px',
background: 'linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%)',
borderRadius: 8
}}
>
<Spin spinning={loading} tip="数据加载中...">
<div style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]} align="middle">
<Col xs={24} md={8}>
<Select
style={{ width: '100%' }}
placeholder="选择区域"
onChange={handleAreaChange}
value={selectedArea}
suffixIcon="📍"
>
{data.map((item) => (
<Option key={item.dictValue} value={item.dictValue}>
{item.dictLabel}
</Option>
))}
</Select>
</Col>
<Col xs={24} md={16}>
{byArea.gender && byArea.gender.length > 0 ? (
<Row gutter={[16, 16]}>
<Col xs={8}>
<Card size="small" style={{ background: 'rgba(24, 144, 255, 0.1)' }}>
<Statistic
title="总用户数"
value={totalUsers}
suffix="人"
/>
</Card>
</Col>
<Col xs={8}>
<Card size="small" style={{ background: 'rgba(64, 169, 255, 0.1)' }}>
<Statistic
title="男性用户"
value={byArea.gender.find(item => item.name === '男')?.data || 0}
suffix="人"
/>
</Card>
</Col>
<Col xs={8}>
<Card size="small" style={{ background: 'rgba(235, 47, 150, 0.1)' }}>
<Statistic
title="女性用户"
value={byArea.gender.find(item => item.name === '女')?.data || 0}
suffix="人"
/>
</Card>
</Col>
</Row>
) : (
<div style={{ textAlign: 'center', padding: 20 }}>
</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>
)}
</Col>
</Row>
</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>
{byArea.education && byArea.education.length > 0 ? (
<Row gutter={[16, 16]}>
{/* 学历分布 */}
<Col xs={24} md={14}>
<Card
title="🎓 学历分布"
size="small"
style={{ height: '100%' }}
>
<Column {...educationConfig} />
</Card>
</Col>
<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>
{/* 性别分布 */}
<Col xs={24} md={5}>
<Card
title="👥 性别分布"
size="small"
style={{ height: '100%' }}
>
<Pie {...genderConfig} />
</Card>
</Col>
<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>
{/* 年龄分布 */}
<Col xs={24} md={5}>
<Card
title="📊 年龄分布"
size="small"
style={{ height: '100%' }}
>
<Column {...ageConfig} />
</Card>
</Col>
{/* 期望薪资 */}
<Col xs={24}>
<Card
title="💰 期望薪资分布"
size="small"
style={{ marginTop: 16 }}
>
<Column {...salaryConfig} />
</Card>
</Col>
</Row>
) : (
<Empty
description={selectedArea ? "该区域暂无用户数据" : "请选择区域"}
style={{ margin: '40px 0' }}
/>
)}
</Spin>
</Card>
);
}
}

View File

@@ -1,98 +1,239 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Column } from '@ant-design/charts';
import { Column, WordCloud } from '@ant-design/charts';
import { getKeyWordSearchRank } from '@/services/analysis/user';
import { Card, Select, Empty, Spin, Tag, Row, Col, Statistic } from 'antd';
interface KeywordChartProps {
data?: any[];
const { Option } = Select;
interface KeywordData {
keyWord: string;
searchCount: number;
rank?: number;
}
export default function KeywordChart({ data: initialData }: KeywordChartProps) {
const [data, setData] = useState(initialData || []);
export default function KeywordChart() {
const [data, setData] = useState<KeywordData[]>([]);
const [loading, setLoading] = useState(false);
const [timeType, setTimeType] = useState<'1' | '2' | '3'>('3');
const [displayMode, setDisplayMode] = useState<'column' | 'wordcloud'>('column');
const [totalSearches, setTotalSearches] = useState(0);
const fetchData = useCallback(
async (params?: { searchDate?: string; searchCount?: number; searchType?: 1 | 2 | 3 }) => {
const fetchData = useCallback(async (searchType: '1' | '2' | '3' = '3') => {
setLoading(true);
try {
const resData = await getKeyWordSearchRank({
searchDate: params?.searchDate || '2025-11-03',
// searchCount: params?.searchCount || 4,
searchType: params?.searchType || 3,
searchDate: '2025-11-03',
searchType: searchType,
});
if (resData.code === 200) {
setData(resData.rows);
const formattedData = resData.rows
.sort((a, b) => b.searchCount - a.searchCount)
.slice(0, 15)
.map((item, index) => ({
...item,
searchCount: Number(item.searchCount) || 0,
rank: index + 1,
}));
setData(formattedData);
// 计算总搜索次数
const total = formattedData.reduce((sum, item) => sum + item.searchCount, 0);
setTotalSearches(total);
}
},
[],
);
} catch (error) {
console.error('获取关键词数据失败:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const config = {
data,
fetchData(timeType);
}, [timeType]);
//
const columnConfig = {
data: data,
stack:true,
xField: 'keyWord',
yField: 'searchCount',
height: 350,
label: {
position: 'middle',
style: {
fill: '#FFFFFF',
opacity: 0.6,
},
height: 280,
autoFit: true,
style:{
columnWidthRatio:0.3,
},
// labels: [
// {
// text: 'searchCount',
// style: {
// fill: '#666',
// fontSize: 12,
// fontWeight: 600,
// textBaseline: 'bottom',
// dy: -6, // 向上偏移,避免与柱状图顶部重叠
// },
// position: 'top',
// }
// ],
xAxis: {
label: {
autoHide: true,
autoRotate: false,
style: {
fontSize: 12,
fill: '#666',
},
formatter: (text: string) => {
return text.length > 6 ? `${text.substring(0, 6)}...` : text;
},
},
title: {
text: '关键词',
style: {
fontSize: 14,
fill: '#333',
fontWeight: 'bold',
},
},
grid: {
line: {
style: {
stroke: '#e8e8e8',
lineWidth: 1,
lineDash: [3, 3],
},
},
alignTick: true,
},
},
meta: {
keyWord: { alias: '关键词' },
searchCount: { alias: '搜索次数' },
yAxis: {
title: {
text: '搜索次数',
style: {
fontSize: 14,
fill: '#333',
fontWeight: 'bold',
},
},
label: {
style: {
fontSize: 12,
fill: '#666',
},
},
grid: {
line: {
style: {
stroke: '#e8e8e8',
lineWidth: 1,
lineDash: [3, 3],
},
},
alignTick: true,
},
},
tooltip: {
title: '关键词搜索统计',
items: [
{ field: 'keyWord', name: '关键词' },
{ field: 'rank', name: '排名' },
{ field: 'searchCount', name: '搜索次数', valueFormatter: (v) => `${v}` },
],
},
interactions: [{ type: 'element-active' }],
animation: {
appear: {
animation: 'scale-in-y',
duration: 1000,
},
},
};
const getTimeText = () => {
switch(timeType) {
case '1': return '今日';
case '2': return '本周';
case '3': return '本月';
default: return '';
}
};
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>
</>
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>🔍 </span>
<Tag color="purple"></Tag>
</div>
}
style={{
borderRadius: 12,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
bodyStyle={{
padding: '16px',
borderRadius: 8
}}
extra={
<Row gutter={[8, 8]} align="middle">
<Col>
<Select
value={timeType}
onChange={setTimeType}
style={{ width: 100 }}
>
<Option value="1"></Option>
<Option value="2"></Option>
<Option value="3"></Option>
</Select>
</Col>
</Row>
}
>
<Spin spinning={loading} tip="数据加载中...">
{/* 统计信息 */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={8}>
<Card size="small" style={{ background: 'rgba(24, 144, 255, 0.1)', textAlign: 'center' }}>
<Statistic
title="总搜索次数"
value={totalSearches}
suffix="次"
/>
</Card>
</Col>
<Col xs={8}>
<Card size="small" style={{ background: 'rgba(250, 173, 20, 0.1)', textAlign: 'center' }}>
<Statistic
title="关键词数量"
value={data.length}
suffix="个"
/>
</Card>
</Col>
<Col xs={8}>
<Card size="small" style={{ background: 'rgba(114, 46, 209, 0.1)', textAlign: 'center' }}>
<Statistic
title="统计时段"
value={getTimeText()}
/>
</Card>
</Col>
</Row>
{/* 图表显示区域 */}
{data.length > 0 ? (
<Column {...columnConfig} />
) : (
<Empty
description="暂无搜索关键词数据"
style={{ margin: '80px 0' }}
/>
)}
</Spin>
</Card>
);
}
}

View File

@@ -1,84 +1,447 @@
import React, { useState, useEffect, useCallback } from 'react';
import { getWantedPositionStaticsCount } from '@/services/analysis/user';
import { Card, Table } from 'antd';
import { Card, Row, Col, Select, Empty, Spin, Tag, Statistic } from 'antd';
import { Column } from '@ant-design/charts';
interface PositionStatics {
const { Option } = Select;
interface PositionItem {
jobId: number;
jobName: string;
count: number;
sexCode?: string; // 可能为 '男' 或 '女'
staticsCount?: number;
}
interface getWantedPositionStaticsCountStaticsResult {
male: PositionStatics[];
female: PositionStatics[];
interface PositionStatics {
jobId: number;
jobName: string;
count: number;
percentage: number;
}
const WantedPositionStatics: React.FC = () => {
const [staticsData, setStaticsData] = useState<StaticsResult>({ male: [], female: [] });
const [malePositions, setMalePositions] = useState<PositionStatics[]>([]);
const [femalePositions, setFemalePositions] = useState<PositionStatics[]>([]);
const [loading, setLoading] = useState(false);
const [topCount, setTopCount] = useState<10 | 15 | 20>(10);
const [totalMale, setTotalMale] = useState(0);
const [totalFemale, setTotalFemale] = useState(0);
const [allPositions, setAllPositions] = useState<PositionItem[]>([]);
const fetchStatics = useCallback(async () => {
const resData = await getWantedPositionStaticsCount({ sexCode: 0 });
if (resData.code === 200) {
const malePositions: Record<string, number> = {};
const femalePositions: Record<string, number> = {};
setLoading(true);
try {
const resData = await getWantedPositionStaticsCount();
if (resData.code === 200) {
const positions = resData.data as PositionItem[];
setAllPositions(positions);
// 统计每个岗位的男女数量
const maleCounts: Record<number, number> = {};
const femaleCounts: Record<number, number> = {};
positions.forEach((item,index) => {
if (item.sexCode === '男') {
maleCounts[item.jobId] = (maleCounts[item.jobId] || 0) + 1;
} else if (item.sexCode === '女') {
femaleCounts[item.jobId] = (femaleCounts[item.jobId] || 0) + 1;
}
});
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 maleTotal = Object.values(maleCounts).reduce((a, b) => a + b, 0);
const femaleTotal = Object.values(femaleCounts).reduce((a, b) => a + b, 0);
setTotalMale(maleTotal);
setTotalFemale(femaleTotal);
const result: StaticsResult = {
male: Object.entries(malePositions).map(([jobName, count]) => ({ jobName, count })),
female: Object.entries(femalePositions).map(([jobName, count]) => ({ jobName, count })),
};
// 获取所有岗位的唯一列表
const uniqueJobs = Array.from(
new Map(positions.map(item => [item.jobId, item])).values()
);
setStaticsData(result);
// 处理男性数据
const processedMale = uniqueJobs
.map(job => ({
jobId: job.jobId,
jobName: job.jobName,
count: maleCounts[job.jobId] || 0,
percentage: maleTotal > 0 ? Math.round(((maleCounts[job.jobId] || 0) / maleTotal) * 100) : 0
}))
.filter(item => item.count > 0) // 只显示有数据的岗位
.sort((a, b) => b.count - a.count)
.slice(0, topCount);
// 处理女性数据
const processedFemale = uniqueJobs
.map(job => ({
jobId: job.jobId,
jobName: job.jobName,
count: femaleCounts[job.jobId] || 0,
percentage: femaleTotal > 0 ? Math.round(((femaleCounts[job.jobId] || 0) / femaleTotal) * 100) : 0
}))
.filter(item => item.count > 0) // 只显示有数据的岗位
.sort((a, b) => b.count - a.count)
.slice(0, topCount);
console.log(processedMale,processedFemale,'++++++++')
setMalePositions(processedMale);
setFemalePositions(processedFemale);
}
} catch (error) {
console.error('获取期望岗位数据失败:', error);
} finally {
setLoading(false);
}
}, []);
}, [topCount]);
useEffect(() => {
fetchStatics();
}, [fetchStatics]);
const columns = [
{
title: '岗位名称',
dataIndex: 'jobName',
key: 'jobName',
// 横向柱状图配置 - 男性
const maleColumnConfig = {
data: malePositions,
xField: 'count',
yField: 'jobName',
seriesField: 'jobName',
isGroup: false,
isStack: false,
height: 280,
autoFit: true,
columnWidthRatio: 0.6,
color: '#1890ff',
labels: [
{
text: 'count',
style: {
fill: '#666',
fontSize: 12,
fontWeight: 600,
},
position: 'right',
formatter: (datum: any) => `${datum.count}`,
}
],
xAxis: {
title: {
text: '人数',
style: {
fontSize: 14,
fill: '#333',
fontWeight: 'bold',
},
},
label: {
style: {
fontSize: 12,
fill: '#666',
},
},
grid: {
line: {
style: {
stroke: '#e8e8e8',
lineWidth: 1,
lineDash: [3, 3],
},
},
alignTick: true,
},
},
{
title: '数量',
dataIndex: 'count',
key: 'count',
yAxis: {
title: null,
label: {
style: {
fontSize: 12,
fill: '#666',
},
formatter: (text: string) => {
return text.length > 10 ? `${text.substring(0, 10)}...` : text;
},
},
grid: {
line: {
style: {
stroke: '#e8e8e8',
lineWidth: 1,
lineDash: [3, 3],
},
},
alignTick: true,
},
},
];
tooltip: {
title: '男性期望岗位',
items: [
{ field: 'jobName', name: '岗位名称' },
{ field: 'count', name: '人数', valueFormatter: (v) => `${v}` },
{ field: 'percentage', name: '占比', valueFormatter: (v) => `${v}%` },
],
},
interactions: [{ type: 'element-active' }],
animation: {
appear: {
animation: 'scale-in-x',
duration: 1000,
},
},
};
// 横向柱状图配置 - 女性
const femaleColumnConfig = {
data: femalePositions,
xField: 'count',
yField: 'jobName',
seriesField: 'jobName',
isGroup: false,
isStack: false,
height: 280,
autoFit: true,
columnWidthRatio: 0.6,
color: '#eb2f96',
labels: [
{
text: 'count',
style: {
fill: '#666',
fontSize: 12,
fontWeight: 600,
},
position: 'right',
formatter: (datum: any) => `${datum.count}`,
}
],
xAxis: {
title: {
text: '人数',
style: {
fontSize: 14,
fill: '#333',
fontWeight: 'bold',
},
},
label: {
style: {
fontSize: 12,
fill: '#666',
},
},
grid: {
line: {
style: {
stroke: '#e8e8e8',
lineWidth: 1,
lineDash: [3, 3],
},
},
alignTick: true,
},
},
yAxis: {
title: null,
label: {
style: {
fontSize: 12,
fill: '#666',
},
formatter: (text: string) => {
return text.length > 10 ? `${text.substring(0, 10)}...` : text;
},
},
grid: {
line: {
style: {
stroke: '#e8e8e8',
lineWidth: 1,
lineDash: [3, 3],
},
},
alignTick: true,
},
},
tooltip: {
title: '女性期望岗位',
items: [
{ field: 'jobName', name: '岗位名称' },
{ field: 'count', name: '人数', valueFormatter: (v) => `${v}` },
{ field: 'percentage', name: '占比', valueFormatter: (v) => `${v}%` },
],
},
interactions: [{ type: 'element-active' }],
animation: {
appear: {
animation: 'scale-in-x',
duration: 1000,
},
},
};
// 如果没有数据,显示所有岗位的占位图
const showPlaceholder = malePositions.length === 0 && femalePositions.length === 0;
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 }}
/>
<Spin spinning={loading} tip="数据加载中...">
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>🎯 </span>
<Tag color="orange"></Tag>
</div>
}
style={{
borderRadius: 12,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
marginBottom: 16
}}
bodyStyle={{
padding: '16px',
background: 'linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%)',
borderRadius: 8
}}
extra={
<Select
value={topCount}
onChange={setTopCount}
style={{ width: 120 }}
size="middle"
>
<Option value={10}>10</Option>
<Option value={15}>15</Option>
<Option value={20}>20</Option>
</Select>
}
>
{/* 统计数据行 */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={8} md={8} lg={8}>
<Card size="small" style={{ background: 'rgba(24, 144, 255, 0.1)', textAlign: 'center' }}>
<Statistic
title="男性用户"
value={totalMale}
suffix="人"
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col xs={8} md={8} lg={8}>
<Card size="small" style={{ background: 'rgba(235, 47, 150, 0.1)', textAlign: 'center' }}>
<Statistic
title="女性用户"
value={totalFemale}
suffix="人"
valueStyle={{ color: '#eb2f96' }}
/>
</Card>
</Col>
<Col xs={8} md={8} lg={8}>
<Card size="small" style={{ background: 'rgba(82, 196, 26, 0.1)', textAlign: 'center' }}>
<Statistic
title="总用户数"
value={totalMale + totalFemale}
suffix="人"
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
</Row>
{showPlaceholder ? (
<Row gutter={[16, 16]}>
<Col xs={24} md={12} lg={12}>
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: '#1890ff' }}>👨 </span>
<Tag color="blue">TOP {topCount}</Tag>
</div>
}
size="small"
style={{ height: '100%' }}
>
<Empty
description="暂无男性期望岗位数据"
style={{ margin: '40px 0' }}
/>
</Card>
</Col>
<Col xs={24} md={12} lg={12}>
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: '#eb2f96' }}>👩 </span>
<Tag color="pink">TOP {topCount}</Tag>
</div>
}
size="small"
style={{ height: '100%' }}
>
<Empty
description="暂无女性期望岗位数据"
style={{ margin: '40px 0' }}
/>
</Card>
</Col>
</Row>
) : (
<Row gutter={[16, 16]}>
{/* 男性期望岗位 - 横向柱状图 */}
<Col xs={24} md={12} lg={12}>
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: '#1890ff' }}>👨 </span>
<Tag color="blue">TOP {topCount}</Tag>
</div>
}
size="small"
style={{ height: '100%' }}
bodyStyle={{ padding: '12px', height: '100%' }}
>
{malePositions.length > 0 ? (
<div style={{ height: 250 }}>
<Column {...maleColumnConfig} />
</div>
) : (
<Empty
description="暂无男性期望岗位数据"
style={{ margin: '40px 0' }}
/>
)}
</Card>
</Col>
{/* 女性期望岗位 - 横向柱状图 */}
<Col xs={24} md={12} lg={12}>
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: '#eb2f96' }}>👩 </span>
<Tag color="pink">TOP {topCount}</Tag>
</div>
}
size="small"
style={{ height: '100%' }}
bodyStyle={{ padding: '12px', height: '100%' }}
>
{femalePositions.length > 0 ? (
<div style={{ height:250 }}>
<Column {...femaleColumnConfig} />
</div>
) : (
<Empty
description="暂无女性期望岗位数据"
style={{ margin: '40px 0' }}
/>
)}
</Card>
</Col>
</Row>
)}
</Card>
<Card title="女性用户期望岗位统计" style={{ width: '50%' }}>
<Table
columns={columns}
dataSource={staticsData.female}
pagination={false}
rowKey="jobName"
size="small"
scroll={{ y: 440 }}
/>
</Card>
</div>
</Spin>
);
};
export default WantedPositionStatics;
export default WantedPositionStatics;

View File

@@ -43,18 +43,19 @@ export default function userAnalysis() {
}, []);
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={{borderRadius:'20px', padding:'15px', background: 'linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%)' }}>
<div style={{ width: '100%' }}>
<AreaStatics data={areaDict} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '16px' ,marginTop:'15px'}}>
<div style={{ width: '100%' }}>
<KeywordChart />
</div>
<div style={{ width: '100%'}}>
<WantedPositionStatics />
</div>
</div>
</div>
);
}

View File

@@ -1,81 +1,77 @@
import React, { Fragment, useEffect, useRef, useState } from 'react';
import { FormattedMessage, useAccess } from '@umijs/max';
import React, { Fragment, useRef, useState, useEffect } from 'react';
import { useAccess } from '@umijs/max';
import { getCmsAppUserList } from '@/services/mobileusers/list';
import { getResumeDetail } from '@/services/resumeLibrary/resumeList'; // 简历库的简历详情接口
import { Button, FormInstance, message } from 'antd';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { PlusOutlined } from '@ant-design/icons';
import { EyeOutlined, UserOutlined } from '@ant-design/icons';
import { getDictValueEnum } from '@/services/system/dict';
import DictTag from '@/components/DictTag';
import { exportCmsAppUserExport, getCmsAppUserList } from '@/services/mobileusers/list';
import ResumeDetail from '../components/detail';
const handleExport = async (values: API.MobileUser.ListParams) => {
const hide = message.loading('正在导出');
try {
await exportCmsAppUserExport(values);
hide();
message.success('导出成功');
return true;
} catch (error) {
hide();
message.error('导出失败,请重试');
return false;
}
};
function ManagementList() {
function MobileUserList() {
const access = useAccess();
const formTableRef = useRef<FormInstance>();
const actionRef = useRef<ActionType>();
const [educationEnum, setEducationEnum] = useState<any>([]);
const [experienceEnum, setExperienceEnum] = useState<any>([]);
const [areaEnum, setAreaEnum] = useState<any>([]);
const [sexEnum, setSexEnum] = useState<any>([]);
const [hotEnum, setHotEnum] = useState<any>([]);
const [politicalEnum, setPoliticalEnum] = useState<any>([]);
const [currentRow, setCurrentRow] = useState<API.MobileUser.ListRow>();
const [modalVisible, setModalVisible] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
// 字典枚举值
const [sexEnum, setSexEnum] = useState<any>([]);
const [educationEnum, setEducationEnum] = useState<any>([]);
const [politicalEnum, setPoliticalEnum] = useState<any>([]);
const [areaEnum, setAreaEnum] = useState<any>([]);
// 获取字典数据
useEffect(() => {
getDictValueEnum('education', true, true).then((data) => {
setEducationEnum(data);
});
getDictValueEnum('experience', true, true).then((data) => {
setExperienceEnum(data);
});
getDictValueEnum('area', true, true).then((data) => {
setAreaEnum(data);
});
getDictValueEnum('sys_user_sex', true).then((data) => {
setSexEnum(data);
});
getDictValueEnum('education', true, true).then((data) => {
setEducationEnum(data);
});
getDictValueEnum('political_affiliation', true, true).then((data) => {
setPoliticalEnum(data);
});
// getDictValueEnum('job_hot',true).then((data) => {
// setHotEnum(data)
// })
getDictValueEnum('area', true, true).then((data) => {
setAreaEnum(data);
});
}, []);
// 查看简历详情
const handleViewResume = async (userId: any) => {
setLoading(true);
try {
const res = await getResumeDetail(userId);
if (res.code === 200) {
setCurrentRow(res.data);
setModalVisible(true);
} else {
message.error(res.msg);
}
} catch (error) {
message.error('获取简历详情失败');
} finally {
setLoading(false);
}
};
// 查看用户行为(待定功能)
const handleViewBehavior = (userId: any) => {
message.info('用户行为查看功能开发中');
// TODO: 待后续开发
};
const columns: ProColumns<API.MobileUser.ListRow>[] = [
{
{
title: '用户名',
dataIndex: 'name',
valueType: 'text',
align: 'center',
},
{
title: '期望薪资',
dataIndex: 'minSalary',
valueType: 'text',
hideInSearch: true,
align: 'center',
render: (_, record) => (
<>
{record.salaryMin}-{record.salaryMax}
</>
),
},
{
title: '出生日期',
dataIndex: 'birthDate',
@@ -122,13 +118,49 @@ function ManagementList() {
return <DictTag enums={politicalEnum} value={record.politicalAffiliation} />;
},
},
{
title: '期望薪资',
dataIndex: 'minSalary',
valueType: 'text',
hideInSearch: true,
align: 'center',
render: (_, record) => (
<span style={{ color: '#ff4d4f', fontWeight: 'bold' }}>
{record.salaryMin || '面议'} - {record.salaryMax || '面议'}
</span>
),
},
{
title: '操作',
hideInSearch: true,
align: 'center',
dataIndex: 'jobId',
width: 200,
render: (jobId, record) => [],
dataIndex: 'userId',
width: 240,
render: (userId, record) => (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8 }}>
<Button
type="primary"
size="small"
key="resume"
icon={<EyeOutlined />}
loading={loading && currentRow?.userId === userId}
hidden={!access.hasPerms('mobileusers:list:viewResume')}
onClick={() => handleViewResume(userId)}
>
</Button>
<Button
type="default"
size="small"
key="behavior"
icon={<UserOutlined />}
hidden={!access.hasPerms('mobileusers:list:viewBehavior')}
onClick={() => handleViewBehavior(userId)}
>
</Button>
</div>
),
},
];
@@ -136,42 +168,39 @@ function ManagementList() {
<Fragment>
<div style={{ width: '100%', float: 'right' }}>
<ProTable<API.MobileUser.ListRow>
// params 是需要自带的参数
// 这个参数优先级更高,会覆盖查询表单的参数
actionRef={actionRef}
formRef={formTableRef}
rowKey="jobId"
key="index"
rowKey="userId"
key="mobileUserIndex"
columns={columns}
request={(params) =>
getCmsAppUserList({ ...params } as API.MobileUser.ListParams).then((res) => {
console.log(params);
const result = {
data: res.rows,
total: res.total,
success: true,
};
return result;
})
}
toolBarRender={() => [
<Button
type="primary"
key="export"
hidden={!access.hasPerms('system:user:export')}
onClick={async () => {
const searchVal = formTableRef.current && formTableRef.current.getFieldsValue();
handleExport(searchVal as API.MobileUser.ListParams);
}}
>
<PlusOutlined />
<FormattedMessage id="pages.searchTable.export" defaultMessage="导出" />
</Button>,
]}
search={{
labelWidth: 120,
}}
request={async (
params: API.MobileUser.ListParams & {
pageSize?: number;
current?: number;
},
) => {
const res = await getCmsAppUserList({ ...params } as API.MobileUser.ListParams);
return {
data: res.rows,
total: res.total,
success: true,
};
}}
/>
</div>
<ResumeDetail
open={modalVisible}
values={currentRow}
onCancel={() => {
setModalVisible(false);
setCurrentRow(undefined);
}}
/>
</Fragment>
);
}
export default ManagementList;
export default MobileUserList;

View File

@@ -0,0 +1,402 @@
import { Modal, Card, Row, Col, Divider, Tag, Typography, Avatar, Empty } from 'antd';
import React, { useEffect, useState } from 'react';
import { getDictValueEnum } from '@/services/system/dict';
import DictTag from '@/components/DictTag';
const { Text, Paragraph, Title } = Typography;
export type ResumeDetailProps = {
onCancel: (flag?: boolean, formVals?: unknown) => void;
open: boolean;
values?: any;
};
const ResumeDetail: React.FC<ResumeDetailProps> = (props) => {
const [sexEnum, setSexEnum] = useState<any>([]);
const [educationEnum, setEducationEnum] = useState<any>([]);
const [politicalEnum, setPoliticalEnum] = useState<any>([]);
const [areaEnum, setAreaEnum] = useState<any>([]);
// 获取字典数据
useEffect(() => {
getDictValueEnum('sys_user_sex', true).then((data) => {
setSexEnum(data);
});
getDictValueEnum('education', true, true).then((data) => {
setEducationEnum(data);
});
getDictValueEnum('political_affiliation', true, true).then((data) => {
setPoliticalEnum(data);
});
getDictValueEnum('area', true, true).then((data) => {
setAreaEnum(data);
});
}, []);
const handleCancel = () => {
props.onCancel();
};
const { values } = props;
// 格式化时间显示
const formatTimeRange = (start: string, end: string) => {
if (!start && !end) return '';
return `${start || ''} - ${end || '至今'}`;
};
// 渲染空状态
const renderEmpty = (description: string) => (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={description}
style={{ margin: '20px 0' }}
/>
);
return (
<Modal
title={
<div style={{ textAlign: 'center', fontSize: 18, fontWeight: 'bold' }}>
{values?.name || '用户'} -
</div>
}
open={props.open}
width={1100}
onCancel={handleCancel}
footer={null}
bodyStyle={{ maxHeight: '80vh', overflowY: 'auto', padding: '24px' }}
>
{/* 头部基本信息卡片 */}
<Card
style={{
marginBottom: 24,
borderRadius: 8,
border: '1px solid #f0f0f0',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
}}
>
<Row gutter={24}>
{/* 头像区域 */}
<Col span={6}>
<div style={{ textAlign: 'center' }}>
{values?.avatar ? (
<Avatar
size={120}
src={values.avatar}
style={{
borderRadius: '50%',
border: '4px solid #e8f4fd',
marginBottom: 16,
}}
/>
) : (
<Avatar
size={120}
style={{
backgroundColor: '#1890ff',
fontSize: 36,
marginBottom: 16,
borderRadius: '50%',
border: '4px solid #e8f4fd',
}}
>
{values?.name?.charAt(0) || 'U'}
</Avatar>
)}
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#1890ff' }}>
{values?.name || '未填写'}
</div>
<div style={{ color: '#666', marginTop: 4 }}>
{values?.jobTitle?.join(' / ') || '暂无期望岗位'}
</div>
</div>
</Col>
{/* 详细信息区域 */}
<Col span={18}>
<Row gutter={[16, 16]}>
<Col span={8}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ color: '#999', minWidth: 80 }}></div>
<div style={{ fontWeight: 'bold' }}>
<DictTag enums={sexEnum} value={values?.sex} />
</div>
</div>
</Col>
<Col span={8}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ color: '#999', minWidth: 80 }}></div>
<div style={{ fontWeight: 'bold' }}>{values?.age || '未填写'}</div>
</div>
</Col>
<Col span={8}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ color: '#999', minWidth: 80 }}></div>
<div style={{ fontWeight: 'bold' }}>
<DictTag enums={educationEnum} value={values?.education} />
</div>
</div>
</Col>
<Col span={8}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ color: '#999', minWidth: 80 }}></div>
<div style={{ fontWeight: 'bold' }}>{values?.phone || '未填写'}</div>
</div>
</Col>
<Col span={8}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ color: '#999', minWidth: 80 }}></div>
<div style={{ fontWeight: 'bold' }}>
<DictTag enums={politicalEnum} value={values?.politicalAffiliation} />
</div>
</div>
</Col>
<Col span={8}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ color: '#999', minWidth: 80 }}></div>
<div style={{ fontWeight: 'bold' }}>{values?.birthDate || '未填写'}</div>
</div>
</Col>
<Col span={8}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ color: '#999', minWidth: 80 }}></div>
<div style={{ fontWeight: 'bold' }}>{values?.workYears || '未填写'}</div>
</div>
</Col>
<Col span={8}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ color: '#999', minWidth: 80 }}></div>
<div style={{ fontWeight: 'bold' }}>
<DictTag enums={areaEnum} value={values?.area} />
</div>
</div>
</Col>
<Col span={8}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ color: '#999', minWidth: 80 }}></div>
<div style={{ fontWeight: 'bold', color: '#ff4d4f' }}>
{values?.salaryMin || '面议'} - {values?.salaryMax || '面议'} /
</div>
</div>
</Col>
</Row>
{/* 附加信息 */}
<Divider style={{ margin: '16px 0' }} />
<Row gutter={16}>
<Col span={12}>
<div style={{ display: 'flex' }}>
<div style={{ color: '#999', minWidth: 80 }}></div>
<div>{values?.graduationSchool || '未填写'}</div>
</div>
</Col>
<Col span={12}>
<div style={{ display: 'flex' }}>
<div style={{ color: '#999', minWidth: 80 }}></div>
<div>{values?.major || '未填写'}</div>
</div>
</Col>
<Col span={24} style={{ marginTop: 8 }}>
<div style={{ display: 'flex' }}>
<div style={{ color: '#999', minWidth: 80 }}></div>
<div>{values?.residenceAddress || '未填写'}</div>
</div>
</Col>
</Row>
</Col>
</Row>
</Card>
{/* 个人介绍与求职意向 - 新布局 */}
<Card
title={
<div style={{ fontSize: 16, fontWeight: 'bold', color: '#1890ff' }}>
📋
</div>
}
style={{
marginBottom: 24,
borderRadius: 8,
border: '1px solid #f0f0f0',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
}}
>
<Row gutter={24}>
<Col span={24} style={{ marginBottom: 20 }}>
<Title level={5} style={{ color: '#666', marginBottom: 12 }}>
<span style={{ color: '#1890ff', marginRight: 8 }}></span>
</Title>
<Paragraph
style={{
padding: 16,
backgroundColor: '#f8f9fa',
borderRadius: 6,
lineHeight: 1.8,
minHeight: 60,
color: '#333',
}}
>
{values?.introduction || '暂无个人介绍'}
</Paragraph>
</Col>
<Col span={24} style={{ marginBottom: 20 }}>
<Title level={5} style={{ color: '#666', marginBottom: 12 }}>
<span style={{ color: '#1890ff', marginRight: 8 }}></span>
</Title>
<div style={{ padding: '0 16px' }}>
{values?.jobTitle?.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{values.jobTitle.map((job: string, index: number) => (
<Tag
key={index}
color="blue"
style={{
padding: '4px 12px',
borderRadius: 4,
fontSize: 14,
marginBottom: 8,
}}
>
{job}
</Tag>
))}
</div>
) : (
<Paragraph
style={{
padding: 16,
backgroundColor: '#f8f9fa',
borderRadius: 6,
lineHeight: 1.8,
color: '#999',
}}
>
{values?.jobIntention || '暂无求职意向'}
</Paragraph>
)}
</div>
</Col>
<Col span={24}>
<Title level={5} style={{ color: '#666', marginBottom: 12 }}>
<span style={{ color: '#1890ff', marginRight: 8 }}></span>
</Title>
<Paragraph
style={{
padding: 16,
backgroundColor: '#f8f9fa',
borderRadius: 6,
lineHeight: 1.8,
minHeight: 60,
color: '#333',
}}
>
{values?.selfEvaluation || '暂无自我评价'}
</Paragraph>
</Col>
</Row>
</Card>
{/* 工作经历 - 新布局 */}
<Card
title={
<div style={{ fontSize: 16, fontWeight: 'bold', color: '#1890ff' }}>
💼 ({values?.workExp?.length || 0})
</div>
}
style={{
marginBottom: 24,
borderRadius: 8,
border: '1px solid #f0f0f0',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
}}
>
{values?.workExp?.length > 0 ? (
<div style={{ maxHeight: 400, overflowY: 'auto' }}>
{values.workExp.map((work: any, index: number) => (
<div
key={index}
style={{
padding: '20px',
marginBottom: 16,
border: '1px solid #e8e8e8',
borderRadius: 8,
backgroundColor: index % 2 === 0 ? '#fafafa' : '#fff',
transition: 'all 0.3s',
':hover': {
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
transform: 'translateY(-2px)',
},
}}
>
<Row justify="space-between" align="top">
<Col span={16}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
<Title level={5} style={{ margin: 0, marginRight: 12 }}>
{work.company || '未填写公司'}
</Title>
{work.isFullTime !== null && (
<Tag
color={work.isFullTime ? 'blue' : 'orange'}
style={{ fontSize: 12, height: 20, lineHeight: '18px' }}
>
{work.isFullTime ? '全职' : '兼职'}
</Tag>
)}
</div>
<div style={{ marginBottom: 8 }}>
<Text strong style={{ marginRight: 16 }}>
{work.position || '未填写职位'}
</Text>
<Text type="secondary">{work.department || '未填写'}</Text>
</div>
{work.duty && (
<Paragraph
style={{
color: '#666',
lineHeight: 1.6,
marginBottom: 8,
fontSize: 14,
}}
>
{work.duty}
</Paragraph>
)}
</Col>
<Col span={8}>
<div style={{ textAlign: 'right' }}>
<div
style={{
color: '#1890ff',
fontSize: 14,
fontWeight: 'bold',
marginBottom: 4,
}}
>
{formatTimeRange(work.startTime, work.endTime)}
</div>
{work.salary && (
<div style={{ color: '#ff4d4f', fontSize: 14, fontWeight: 'bold' }}>
{work.salary}
</div>
)}
</div>
</Col>
</Row>
</div>
))}
</div>
) : (
renderEmpty('暂无工作经历')
)}
</Card>
</Modal>
);
};
export default ResumeDetail;