后端查看效果,不用拉取

This commit is contained in:
yy
2025-05-30 14:26:32 +08:00
parent f7e4ccc608
commit 4136170101
7 changed files with 911 additions and 499 deletions

View File

@@ -1,49 +1,49 @@
/** /**
* @name 代理的配置 * @name 代理的配置
* @see 在生产环境 代理是无法生效的,所以这里没有生产环境的配置 * @see 在生产环境 代理是无法生效的,所以这里没有生产环境的配置
* ------------------------------- * -------------------------------
* The agent cannot take effect in the production environment * The agent cannot take effect in the production environment
* so there is no configuration of the production environment * so there is no configuration of the production environment
* For details, please see * For details, please see
* https://pro.ant.design/docs/deploy * https://pro.ant.design/docs/deploy
* *
* @doc https://umijs.org/docs/guides/proxy * @doc https://umijs.org/docs/guides/proxy
*/ */
export default { export default {
// 如果需要自定义本地开发服务器 请取消注释按需调整 // 如果需要自定义本地开发服务器 请取消注释按需调整
dev: { dev: {
// localhost:8000/api/** -> https://preview.pro.ant.design/api/** // localhost:8000/api/** -> https://preview.pro.ant.design/api/**
'/api/': { '/api/': {
// 要代理的地址 // 要代理的地址
target: 'http://39.98.44.136:8080', target: 'https://qd.zhaopinzao8dian.com/api',
// 配置了这个可以从 http 代理到 https // 配置了这个可以从 http 代理到 https
// 依赖 origin 的功能可能需要这个,比如 cookie // 依赖 origin 的功能可能需要这个,比如 cookie
changeOrigin: true, changeOrigin: true,
pathRewrite: { '^/api': '' }, pathRewrite: { '^/api': '' },
}, },
'/profile/avatar/': { '/profile/avatar/': {
target: 'http://39.98.44.136:8080', target: 'https://qd.zhaopinzao8dian.com/api',
changeOrigin: true, changeOrigin: true,
} }
}, },
/** /**
* @name 详细的代理配置 * @name 详细的代理配置
* @doc https://github.com/chimurai/http-proxy-middleware * @doc https://github.com/chimurai/http-proxy-middleware
*/ */
test: { test: {
// localhost:8000/api/** -> https://preview.pro.ant.design/api/** // localhost:8000/api/** -> https://preview.pro.ant.design/api/**
'/api/': { '/api/': {
target: 'https://proapi.azurewebsites.net', target: 'https://proapi.azurewebsites.net',
changeOrigin: true, changeOrigin: true,
pathRewrite: { '^': '' }, pathRewrite: { '^': '' },
}, },
}, },
pre: { pre: {
'/api/': { '/api/': {
target: 'your pre url', target: 'your pre url',
changeOrigin: true, changeOrigin: true,
pathRewrite: { '^': '' }, pathRewrite: { '^': '' },
}, },
}, },
}; };

View File

@@ -56,6 +56,7 @@
"@amap/amap-jsapi-loader": "^1.0.1", "@amap/amap-jsapi-loader": "^1.0.1",
"@ant-design/charts": "^2.3.0", "@ant-design/charts": "^2.3.0",
"@ant-design/icons": "^5.5.0", "@ant-design/icons": "^5.5.0",
"@ant-design/maps": "^1.0.0",
"@ant-design/plots": "^2.3.2", "@ant-design/plots": "^2.3.2",
"@ant-design/pro-components": "^2.8.7", "@ant-design/pro-components": "^2.8.7",
"@ant-design/use-emotion-css": "1.0.4", "@ant-design/use-emotion-css": "1.0.4",

View File

@@ -1,271 +1,271 @@
import { AvatarDropdown, AvatarName, Footer, SelectLang } from '@/components'; import { AvatarDropdown, AvatarName, Footer, SelectLang } from '@/components';
import type { Settings as LayoutSettings } from '@ant-design/pro-components'; import type { Settings as LayoutSettings } from '@ant-design/pro-components';
import { SettingDrawer } from '@ant-design/pro-components'; import { SettingDrawer } from '@ant-design/pro-components';
import type { RunTimeLayoutConfig } from '@umijs/max'; import type { RunTimeLayoutConfig } from '@umijs/max';
import { history } from '@umijs/max'; import { history } from '@umijs/max';
import defaultSettings from '../config/defaultSettings'; import defaultSettings from '../config/defaultSettings';
import { errorConfig } from './requestErrorConfig'; import { errorConfig } from './requestErrorConfig';
import { clearSessionToken, getAccessToken, getRefreshToken, getTokenExpireTime } from './access'; import { clearSessionToken, getAccessToken, getRefreshToken, getTokenExpireTime } from './access';
import { import {
getRemoteMenu, getRemoteMenu,
getRoutersInfo, getRoutersInfo,
getUserInfo, getUserInfo,
patchRouteWithRemoteMenus, patchRouteWithRemoteMenus,
setRemoteMenu, setRemoteMenu,
} from './services/session'; } from './services/session';
import { PageEnum } from './enums/pagesEnums'; import { PageEnum } from './enums/pagesEnums';
import { stringify } from 'querystring'; import { stringify } from 'querystring';
import { message } from 'antd'; import { message } from 'antd';
const isDev = process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === 'development';
const loginOut = async () => { const loginOut = async () => {
clearSessionToken(); clearSessionToken();
setRemoteMenu(null); setRemoteMenu(null);
const { search, pathname } = window.location; const { search, pathname } = window.location;
const urlParams = new URL(window.location.href).searchParams; const urlParams = new URL(window.location.href).searchParams;
/** 此方法会跳转到 redirect 参数所在的位置 */ /** 此方法会跳转到 redirect 参数所在的位置 */
const redirect = urlParams.get('redirect'); const redirect = urlParams.get('redirect');
// Note: There may be security issues, please note // Note: There may be security issues, please note
console.log('redirect', window.location.pathname, redirect); console.log('redirect', window.location.pathname, redirect);
if (window.location.pathname !== '/qingdao/user/login' && !redirect) { if (window.location.pathname !== '/qingdao/user/login' && !redirect) {
history.replace({ history.replace({
pathname: '/user/login', pathname: '/user/login',
search: stringify({ search: stringify({
redirect: pathname.replace('/qingdao', '') + search, redirect: pathname.replace('/qingdao', '') + search,
}), }),
}); });
} }
}; };
/** /**
* @see https://umijs.org/zh-CN/plugins/plugin-initial-state * @see https://umijs.org/zh-CN/plugins/plugin-initial-state
* */ * */
export async function getInitialState(): Promise<{ export async function getInitialState(): Promise<{
settings?: Partial<LayoutSettings>; settings?: Partial<LayoutSettings>;
currentUser?: API.CurrentUser; currentUser?: API.CurrentUser;
loading?: boolean; loading?: boolean;
fetchUserInfo?: () => Promise<API.CurrentUser | undefined>; fetchUserInfo?: () => Promise<API.CurrentUser | undefined>;
}> { }> {
const fetchUserInfo = async () => { const fetchUserInfo = async () => {
try { try {
const response = await getUserInfo({ const response = await getUserInfo({
skipErrorHandler: true, skipErrorHandler: true,
}); });
if (response.user.avatar === '') { if (response.user.avatar === '') {
response.user.avatar = response.user.avatar =
'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png'; 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png';
} }
return { return {
...response.user, ...response.user,
permissions: response.permissions, permissions: response.permissions,
roles: response.roles, roles: response.roles,
} as API.CurrentUser; } as API.CurrentUser;
} catch (error) { } catch (error) {
console.log(error); console.log(error);
history.push(PageEnum.LOGIN); history.push(PageEnum.LOGIN);
} }
return undefined; return undefined;
}; };
// 如果不是登录页面,执行 // 如果不是登录页面,执行
const { location } = history; const { location } = history;
if (location.pathname !== PageEnum.LOGIN) { if (location.pathname !== PageEnum.LOGIN) {
const currentUser = await fetchUserInfo(); const currentUser = await fetchUserInfo();
return { return {
fetchUserInfo, fetchUserInfo,
currentUser, currentUser,
settings: defaultSettings as Partial<LayoutSettings>, settings: defaultSettings as Partial<LayoutSettings>,
}; };
} }
return { return {
fetchUserInfo, fetchUserInfo,
settings: defaultSettings as Partial<LayoutSettings>, settings: defaultSettings as Partial<LayoutSettings>,
}; };
} }
// ProLayout 支持的api https://procomponents.ant.design/components/layout // ProLayout 支持的api https://procomponents.ant.design/components/layout
export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) => { export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) => {
return { return {
// actionsRender: () => [<Question key="doc" />, <SelectLang key="SelectLang" />], // actionsRender: () => [<Question key="doc" />, <SelectLang key="SelectLang" />],
actionsRender: () => [<SelectLang key="SelectLang" />], actionsRender: () => [<SelectLang key="SelectLang" />],
avatarProps: { avatarProps: {
src: initialState?.currentUser?.avatar, src: initialState?.currentUser?.avatar,
title: <AvatarName />, title: <AvatarName />,
render: (_, avatarChildren) => { render: (_, avatarChildren) => {
return <AvatarDropdown menu="True">{avatarChildren}</AvatarDropdown>; return <AvatarDropdown menu="True">{avatarChildren}</AvatarDropdown>;
}, },
}, },
waterMarkProps: { waterMarkProps: {
// content: initialState?.currentUser?.nickName, // content: initialState?.currentUser?.nickName,
}, },
// actionRef: layoutActionRef, // actionRef: layoutActionRef,
menu: { menu: {
locale: false, locale: false,
// // 每当 initialState?.currentUser?.userid 发生修改时重新执行 request // // 每当 initialState?.currentUser?.userid 发生修改时重新执行 request
params: { params: {
userId: initialState?.currentUser?.userId, userId: initialState?.currentUser?.userId,
}, },
request: async () => { request: async () => {
if (!initialState?.currentUser?.userId) { if (!initialState?.currentUser?.userId) {
return []; return [];
} }
return getRemoteMenu(); return getRemoteMenu();
}, },
}, },
footerRender: () => <Footer />, footerRender: () => <Footer />,
onPageChange: () => { onPageChange: () => {
const { location } = history; const { location } = history;
// 如果没有登录,重定向到 login // 如果没有登录,重定向到 login
if (!initialState?.currentUser && location.pathname !== PageEnum.LOGIN) { if (!initialState?.currentUser && location.pathname !== PageEnum.LOGIN) {
history.push(PageEnum.LOGIN); history.push(PageEnum.LOGIN);
} }
}, },
layoutBgImgList: [ layoutBgImgList: [
{ {
src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/D2LWSqNny4sAAAAAAAAAAAAAFl94AQBr', src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/D2LWSqNny4sAAAAAAAAAAAAAFl94AQBr',
left: 85, left: 85,
bottom: 100, bottom: 100,
height: '303px', height: '303px',
}, },
{ {
src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/C2TWRpJpiC0AAAAAAAAAAAAAFl94AQBr', src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/C2TWRpJpiC0AAAAAAAAAAAAAFl94AQBr',
bottom: -68, bottom: -68,
right: -45, right: -45,
height: '303px', height: '303px',
}, },
{ {
src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/F6vSTbj8KpYAAAAAAAAAAAAAFl94AQBr', src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/F6vSTbj8KpYAAAAAAAAAAAAAFl94AQBr',
bottom: 0, bottom: 0,
left: 0, left: 0,
width: '331px', width: '331px',
}, },
], ],
pure: false, pure: false,
// links: isDev // links: isDev
// ? [ // ? [
// <Link key="openapi" to="/umi/plugin/openapi" target="_blank"> // <Link key="openapi" to="/umi/plugin/openapi" target="_blank">
// <LinkOutlined /> // <LinkOutlined />
// <span>OpenAPI 文档</span> // <span>OpenAPI 文档</span>
// </Link>, // </Link>,
// ] // ]
// : [], // : [],
menuHeaderRender: undefined, menuHeaderRender: undefined,
// 自定义 403 页面 // 自定义 403 页面
// unAccessible: <div>unAccessible</div>, // unAccessible: <div>unAccessible</div>,
// 增加一个 loading 的状态 // 增加一个 loading 的状态
childrenRender: (children) => { childrenRender: (children) => {
// if (initialState?.loading) return <PageLoading />; // if (initialState?.loading) return <PageLoading />;
return ( return (
<> <>
{children} {children}
<SettingDrawer <SettingDrawer
disableUrlParams disableUrlParams
enableDarkTheme enableDarkTheme
settings={initialState?.settings} settings={initialState?.settings}
onSettingChange={(settings) => { onSettingChange={(settings) => {
setInitialState((preInitialState) => ({ setInitialState((preInitialState) => ({
...preInitialState, ...preInitialState,
settings, settings,
})); }));
}} }}
/> />
</> </>
); );
}, },
...initialState?.settings, ...initialState?.settings,
}; };
}; };
export async function onRouteChange({ clientRoutes, location }) { export async function onRouteChange({ clientRoutes, location }) {
const menus = getRemoteMenu(); const menus = getRemoteMenu();
// console.log('onRouteChange', clientRoutes, location, menus); // console.log('onRouteChange', clientRoutes, location, menus);
if (menus === null && location.pathname !== PageEnum.LOGIN) { if (menus === null && location.pathname !== PageEnum.LOGIN) {
console.log('refresh'); console.log('refresh');
// history.go(0); // history.go(0);
} }
} }
// export function patchRoutes({ routes, routeComponents }) { // export function patchRoutes({ routes, routeComponents }) {
// console.log('patchRoutes', routes, routeComponents); // console.log('patchRoutes', routes, routeComponents);
// } // }
export async function patchClientRoutes({ routes }) { export async function patchClientRoutes({ routes }) {
// console.log('patchClientRoutes', routes); // console.log('patchClientRoutes', routes);
patchRouteWithRemoteMenus(routes); patchRouteWithRemoteMenus(routes);
} }
export async function render(oldRender: () => void) { export async function render(oldRender: () => void) {
console.log('render get routers', oldRender); console.log('render get routers', oldRender);
const token = getAccessToken(); const token = getAccessToken();
if (!token || token?.length === 0) { if (!token || token?.length === 0) {
oldRender(); oldRender();
return; return;
} }
await getRoutersInfo().then((res) => { await getRoutersInfo().then((res) => {
console.log('render get routers', 123); console.log('render get routers', 123);
setRemoteMenu(res); setRemoteMenu(res);
oldRender(); oldRender();
}); });
} }
/** /**
* @name request 配置,可以配置错误处理 * @name request 配置,可以配置错误处理
* 它基于 axios 和 ahooks 的 useRequest 提供了一套统一的网络请求和错误处理方案。 * 它基于 axios 和 ahooks 的 useRequest 提供了一套统一的网络请求和错误处理方案。
* @doc https://umijs.org/docs/max/request#配置 * @doc https://umijs.org/docs/max/request#配置
*/ */
const checkRegion = 5 * 60 * 1000; const checkRegion = 5 * 60 * 1000;
export const request = { export const request = {
...errorConfig, ...errorConfig,
baseURL: process.env.NODE_ENV === 'development' ? '' : 'http://39.98.44.136:8080', baseURL: process.env.NODE_ENV === 'development' ? '' : 'https://qd.zhaopinzao8dian.com/api',
// baseURL: 'http://39.98.44.136:8080', // baseURL: 'http://39.98.44.136:8080',
requestInterceptors: [ requestInterceptors: [
(url: any, options: { headers: any }) => { (url: any, options: { headers: any }) => {
const headers = options.headers ? options.headers : []; const headers = options.headers ? options.headers : [];
console.log('request ====>:', url); console.log('request ====>:', url);
const authHeader = headers['Authorization']; const authHeader = headers['Authorization'];
const isToken = headers['isToken']; const isToken = headers['isToken'];
if (!authHeader && isToken !== false) { if (!authHeader && isToken !== false) {
const expireTime = getTokenExpireTime(); const expireTime = getTokenExpireTime();
if (expireTime) { if (expireTime) {
const left = Number(expireTime) - new Date().getTime(); const left = Number(expireTime) - new Date().getTime();
const refreshToken = getRefreshToken(); const refreshToken = getRefreshToken();
if (left < checkRegion && refreshToken) { if (left < checkRegion && refreshToken) {
if (left < 0) { if (left < 0) {
clearSessionToken(); clearSessionToken();
} }
} else { } else {
const accessToken = getAccessToken(); const accessToken = getAccessToken();
if (accessToken) { if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`; headers['Authorization'] = `Bearer ${accessToken}`;
} }
} }
} else { } else {
clearSessionToken(); clearSessionToken();
} }
} }
if (process.env.NODE_ENV !== 'development') { if (process.env.NODE_ENV !== 'development') {
if (url.startsWith('/api')) { if (url.startsWith('/api')) {
url = url.replace(/^\/api/, ''); url = url.replace(/^\/api/, '');
} }
} }
return { url, options }; return { url, options };
}, },
], ],
responseInterceptors: [ responseInterceptors: [
(response) => { (response) => {
// 不再需要异步处理读取返回体内容可直接在data中读出部分字段可在 config 中找到 // 不再需要异步处理读取返回体内容可直接在data中读出部分字段可在 config 中找到
const { data = {} as any, config } = response; const { data = {} as any, config } = response;
switch (data.code) { switch (data.code) {
case 401: case 401:
loginOut(); loginOut();
break; break;
} }
if (data.code !== 200 && data.msg) { if (data.code !== 200 && data.msg) {
message.info(data.msg); message.info(data.msg);
} }
// console.log('data: ', data) // console.log('data: ', data)
// console.log('config: ', config) // console.log('config: ', config)
return response; return response;
}, },
], ],
}; };

View File

@@ -1,24 +1,45 @@
import React, { useEffect, useState, useMemo } from 'react'; import React, { useEffect, useState, useMemo, useRef } from 'react';
import { Card, Select, Button, Space, Spin, Empty, Row, Col, message, DatePicker } from 'antd'; import { Card, Select, Button, Space, Spin, Empty, Row, Col, message, DatePicker, Tabs } from 'antd';
import { Line } from '@ant-design/charts'; import { Line, Heatmap } from '@ant-design/charts';
import { getIndustryTrend } from '@/services/analysis/industry'; import { getIndustryTrend, getIndustryAreaTrend, getSalaryTrend } from '@/services/analysis/industry';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useRequest } from '@umijs/max'; import { useRequest } from '@umijs/max';
import { import {
TimeDimension, TimeDimension,
AnalysisType, AnalysisType,
IndustryTrendState, IndustryTrendState,
IndustryDataItem, IndustryDataItem,
ChartConfig ChartConfig,
} from '@/types/analysis/industry'; } from '@/types/analysis/industry';
import { import { formatQuarter, formatDateForDisplay, convertApiData, convertSalaryData } from './utils';
formatQuarter,
formatDateForDisplay,
convertApiData
} from './utils';
const { Option } = Select; const { Option } = Select;
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
const { TabPane } = Tabs;
const flattenAreaData = (apiResponse: any) => {
if (!apiResponse || typeof apiResponse !== 'object') {
return [];
}
const flattenedData = [];
for (const month in apiResponse) {
if (apiResponse.hasOwnProperty(month)) {
const areas = apiResponse[month];
areas.forEach((area) => {
flattenedData.push({
name: area.name,
time: area.time,
value: parseInt(area.data) || 0,
});
});
}
}
return flattenedData;
};
const IndustryTrendPage: React.FC = () => { const IndustryTrendPage: React.FC = () => {
const [params, setParams] = useState<IndustryTrendState>({ const [params, setParams] = useState<IndustryTrendState>({
@@ -26,26 +47,33 @@ const IndustryTrendPage: React.FC = () => {
type: '岗位发布数量', type: '岗位发布数量',
startTime: dayjs().subtract(5, 'month').format('YYYY-MM'), startTime: dayjs().subtract(5, 'month').format('YYYY-MM'),
endTime: dayjs().format('YYYY-MM'), endTime: dayjs().format('YYYY-MM'),
selectedIndustry: '' selectedIndustry: '',
selectedSalaryRange: '',
analysisCategory: 'industry', // 默认显示行业分析
}); });
const [allData, setAllData] = useState<IndustryDataItem[]>([]); const [allData, setAllData] = useState<IndustryDataItem[]>([]);
const [areaData, setAreaData] = useState<any[]>([]);
const [salaryData, setSalaryData] = useState<any[]>([]);
const [availableIndustries, setAvailableIndustries] = useState<string[]>([]); const [availableIndustries, setAvailableIndustries] = useState<string[]>([]);
const [availableSalaryRanges, setAvailableSalaryRanges] = useState<string[]>([]);
const heatmapRef = useRef(null);
const { loading, run: fetchData } = useRequest( // 获取行业趋势数据
const { loading: industryLoading, run: fetchIndustryData } = useRequest(
async () => { async () => {
let { startTime, endTime, timeDimension, type } = params; let { startTime, endTime, timeDimension, type } = params;
if (timeDimension === '季度') { if (timeDimension === '季度') {
startTime = formatQuarter(startTime); startTime = formatQuarter(startTime);
endTime = formatQuarter(endTime); endTime = formatQuarter(endTime);
} }
return await getIndustryTrend({ return await getIndustryTrend({
timeDimension, timeDimension,
type, type,
startTime, startTime,
endTime endTime,
}); });
}, },
{ {
@@ -53,25 +81,273 @@ const IndustryTrendPage: React.FC = () => {
onSuccess: (data) => { onSuccess: (data) => {
const formattedData = convertApiData(data); const formattedData = convertApiData(data);
setAllData(formattedData); setAllData(formattedData);
const industries = Array.from( const industries = Array.from(new Set(formattedData.map((item: any) => item.category)))
new Set(formattedData.map((item: { category: any; }) => item.category)) .filter(Boolean)
).filter(Boolean).sort(); .sort();
setAvailableIndustries(industries); setAvailableIndustries(industries);
if (industries.length > 0 && !industries.includes(params.selectedIndustry)) { if (industries.length > 0 && !industries.includes(params.selectedIndustry)) {
setParams(p => ({ ...p, selectedIndustry: industries[0] })); setParams((p) => ({ ...p, selectedIndustry: industries[0] }));
} }
}, },
onError: () => message.error('数据加载失败') onError: (error) => {
} message.error('行业数据加载失败');
},
},
); );
// 获取地区趋势数据
const { loading: areaLoading, run: fetchAreaData } = useRequest(
async () => {
let { startTime, endTime, timeDimension, type } = params;
if (timeDimension === '季度') {
startTime = formatQuarter(startTime);
endTime = formatQuarter(endTime);
}
return await getIndustryAreaTrend({
timeDimension,
type,
startTime,
endTime,
});
},
{
manual: true,
onSuccess: (res) => {
const formattedData = flattenAreaData(res);
setAreaData(formattedData);
},
onError: (error) => {
message.error('地区数据加载失败');
},
},
);
// 获取薪资趋势数据
const { loading: salaryLoading, run: fetchSalaryData } = useRequest(
async () => {
let { startTime, endTime, timeDimension, type } = params;
if (timeDimension === '季度') {
startTime = formatQuarter(startTime);
endTime = formatQuarter(endTime);
}
return await getSalaryTrend({
timeDimension,
type,
startTime,
endTime,
});
},
{
manual: true,
onSuccess: (data) => {
const formattedData = convertSalaryData(data);
setSalaryData(formattedData);
const ranges = Array.from(new Set(formattedData.map((item: any) => item.category)))
.filter(Boolean)
.sort();
setAvailableSalaryRanges(ranges);
if (ranges.length > 0 && !ranges.includes(params.selectedSalaryRange)) {
setParams((p) => ({ ...p, selectedSalaryRange: ranges[0] }));
}
},
onError: (error) => {
message.error('薪资数据加载失败');
},
},
);
// 根据分析类别获取当前数据
const currentData = useMemo(() => {
if (params.analysisCategory === 'industry') {
return allData
.filter((item) => item.category === params.selectedIndustry)
.map((item) => ({
...item,
originalDate: item.date,
date: formatDateForDisplay(item.date, params.timeDimension),
}))
.sort((a, b) => {
if (params.timeDimension === '季度') {
const [yearA, quarterA] = a.originalDate.split('-');
const [yearB, quarterB] = b.originalDate.split('-');
const quarterToNumber = (q: string) => {
if (q.includes('第一')) return 1;
if (q.includes('第二')) return 2;
if (q.includes('第三')) return 3;
if (q.includes('第四')) return 4;
return 0;
};
return yearA === yearB
? quarterToNumber(quarterA) - quarterToNumber(quarterB)
: parseInt(yearA) - parseInt(yearB);
} else if (params.timeDimension === '年') {
return parseInt(a.originalDate) - parseInt(b.originalDate);
} else {
return dayjs(a.originalDate).valueOf() - dayjs(b.originalDate).valueOf();
}
});
} else if (params.analysisCategory === 'salary') {
return salaryData
.filter((item) => item.category === params.selectedSalaryRange)
.map((item) => ({
...item,
originalDate: item.date,
date: formatDateForDisplay(item.date, params.timeDimension),
}))
.sort((a, b) => {
if (params.timeDimension === '季度') {
const [yearA, quarterA] = a.originalDate.split('-');
const [yearB, quarterB] = b.originalDate.split('-');
const quarterToNumber = (q: string) => {
if (q.includes('第一')) return 1;
if (q.includes('第二')) return 2;
if (q.includes('第三')) return 3;
if (q.includes('第四')) return 4;
return 0;
};
return yearA === yearB
? quarterToNumber(quarterA) - quarterToNumber(quarterB)
: parseInt(yearA) - parseInt(yearB);
} else if (params.timeDimension === '年') {
return parseInt(a.originalDate) - parseInt(b.originalDate);
} else {
return dayjs(a.originalDate).valueOf() - dayjs(b.originalDate).valueOf();
}
});
}
return [];
}, [allData, salaryData, params]);
// 热力图配置
const heatmapConfig = useMemo(() => {
const sortedData = [...areaData].sort((a, b) => {
if (params.timeDimension === '年') {
return parseInt(a.time) - parseInt(b.time);
} else if (params.timeDimension === '季度') {
const [yearA, quarterA] = a.time.split('-Q');
const [yearB, quarterB] = b.time.split('-Q');
return yearA === yearB
? parseInt(quarterA) - parseInt(quarterB)
: parseInt(yearA) - parseInt(yearB);
} else {
return dayjs(a.time).valueOf() - dayjs(b.time).valueOf();
}
});
// 计算最大值和阈值
const maxValue = Math.max(...sortedData.map((item) => item.value), 1);
const hotThreshold = maxValue * 0.8; // 热门阈值(前20%)
const growthThreshold = maxValue * 0.5; // 增长阈值(前50%)
return {
data: sortedData,
height: 300,
autoFit: false,
xField: 'name',
yField: 'time',
colorField: 'value',
mark: 'cell',
style: {
inset: 1,
fillOpacity: 0.9,
},
cellSize: [100, 40],
color: ['#5B8FF9', '#5AD8A6', '#5D7092', '#F6BD16', '#E8684A'],
label: {
text: (d: { value: number }) => {
if (d.value >= 10000) return (d.value / 10000).toFixed(0) + 'w';
if (d.value >= 1000) return (d.value / 1000).toFixed(0) + 'k';
return d.value;
},
style: {
fill: '#fff',
fontSize: 12,
fontWeight: 'bold',
textShadow: '0 0 3px rgba(0,0,0,0.7)',
},
position: 'inside',
},
xAxis: {
title: {
text: '区域',
style: { fontSize: 12 },
},
label: {
autoRotate: true,
style: {
fontSize: 11,
fill: '#666',
},
},
},
yAxis: {
title: {
text: '时间',
style: { fontSize: 12 },
},
label: {
formatter: (text) => {
if (params.timeDimension === '年') return `${text}`;
if (params.timeDimension === '季度') return text.replace('-Q', '年Q');
return text.replace('-', '年').replace('-', '月');
},
style: {
fontSize: 11,
fill: '#666',
},
},
sortable: false,
},
tooltip: {
title: (d) => `${d.name} - ${d.time}`,
field: 'value',
valueFormatter: (v) => {
if (v >= 10000) return (v / 10000).toFixed(1) + '万';
if (v >= 1000) return (v / 1000).toFixed(1) + '千';
return v;
},
pointerEvents: 'none',
domStyles: {
'g2-tooltip': {
padding: '8px 12px',
borderRadius: '4px',
},
},
},
interactions: [{ type: 'element-active' }],
legend: {
position: 'bottom',
layout: 'horizontal',
slidable: true,
title: false,
itemName: {
style: {
fill: '#666',
fontSize: 12,
},
},
},
};
}, [areaData, params.timeDimension]);
const handleTimeDimensionChange = (value: TimeDimension) => { const handleTimeDimensionChange = (value: TimeDimension) => {
const now = dayjs(); const now = dayjs();
let newStartTime = ''; let newStartTime = '';
if (value === '月') { if (value === '月') {
newStartTime = now.subtract(5, 'month').format('YYYY-MM'); newStartTime = now.subtract(5, 'month').format('YYYY-MM');
} else if (value === '季度') { } else if (value === '季度') {
@@ -79,31 +355,35 @@ const IndustryTrendPage: React.FC = () => {
} else { } else {
newStartTime = now.subtract(5, 'year').format('YYYY'); newStartTime = now.subtract(5, 'year').format('YYYY');
} }
setParams(p => ({ setParams((p) => ({
...p, ...p,
timeDimension: value, timeDimension: value,
startTime: newStartTime, startTime: newStartTime,
endTime: value === '月' ? now.format('YYYY-MM') : endTime:
value === '季度' ? now.format('YYYY-Q') : value === '月'
now.format('YYYY'), ? now.format('YYYY-MM')
selectedIndustry: '' : value === '季度'
? now.format('YYYY-Q')
: now.format('YYYY'),
selectedIndustry: params.analysisCategory === 'industry' ? '' : p.selectedIndustry,
selectedSalaryRange: params.analysisCategory === 'salary' ? '' : p.selectedSalaryRange,
})); }));
}; };
const handleDateRangeChange = (dates: any, dateStrings: [string, string]) => { const handleDateRangeChange = (dates: any, dateStrings: [string, string]) => {
if (dates && dates[0] && dates[1]) { if (dates && dates[0] && dates[1]) {
setParams(p => ({ setParams((p) => ({
...p, ...p,
startTime: dateStrings[0], startTime: dateStrings[0],
endTime: dateStrings[1] endTime: dateStrings[1],
})); }));
} }
}; };
const disabledDate = (current: dayjs.Dayjs) => { const disabledDate = (current: dayjs.Dayjs) => {
const now = dayjs(); const now = dayjs();
if (params.timeDimension === '月') { if (params.timeDimension === '月') {
return current.isAfter(now.endOf('month')); return current.isAfter(now.endOf('month'));
} else if (params.timeDimension === '季度') { } else if (params.timeDimension === '季度') {
@@ -113,68 +393,41 @@ const IndustryTrendPage: React.FC = () => {
} }
}; };
const currentIndustryData = useMemo(() => {
if (!params.selectedIndustry || allData.length === 0) return [];
return allData
.filter(item => item.category === params.selectedIndustry)
.map(item => ({
...item,
originalDate: item.date,
date: formatDateForDisplay(item.date, params.timeDimension)
}))
.sort((a, b) => {
if (params.timeDimension === '季度') {
// 处理中文季度格式,如 "2024-第一季度" -> ["2024", "第一"]
const [yearA, quarterA] = a.originalDate.split('-');
const [yearB, quarterB] = b.originalDate.split('-');
// 转换中文季度为数字("第一" -> 1, "第二" -> 2...
const quarterToNumber = (q: string) => {
if (q.includes('第一')) return 1;
if (q.includes('第二')) return 2;
if (q.includes('第三')) return 3;
if (q.includes('第四')) return 4;
return 0;
};
return yearA === yearB
? quarterToNumber(quarterA) - quarterToNumber(quarterB)
: parseInt(yearA) - parseInt(yearB);
} else if (params.timeDimension === '年') {
return parseInt(a.originalDate) - parseInt(b.originalDate);
} else {
return dayjs(a.originalDate).valueOf() - dayjs(b.originalDate).valueOf();
}
});
}, [allData, params.selectedIndustry, params.timeDimension]);
const getPickerValue = () => { const getPickerValue = () => {
try { try {
return [ return [
dayjs(params.startTime, params.timeDimension === '年' ? 'YYYY' : dayjs(
params.timeDimension === '季度' ? 'YYYY-Q' : 'YYYY-MM'), params.startTime,
dayjs(params.endTime, params.timeDimension === '年' ? 'YYYY' : params.timeDimension === '年'
params.timeDimension === '季度' ? 'YYYY-Q' : 'YYYY-MM') ? 'YYYY'
: params.timeDimension === '季度'
? 'YYYY-Q'
: 'YYYY-MM',
),
dayjs(
params.endTime,
params.timeDimension === '年'
? 'YYYY'
: params.timeDimension === '季度'
? 'YYYY-Q'
: 'YYYY-MM',
),
]; ];
} catch (e) { } catch (e) {
console.error('日期解析错误:', e);
return null; return null;
} }
}; };
const chartConfig: ChartConfig = { const chartConfig: ChartConfig = {
data: currentIndustryData, data: currentData,
height: 180, height: 180,
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: {
@@ -205,7 +458,10 @@ const IndustryTrendPage: React.FC = () => {
{list.map((item, index) => { {list.map((item, index) => {
const { name, value, color } = item; const { name, value, color } = item;
return ( return (
<div key={index} style={{ margin: '4px 0', display: 'flex', justifyContent: 'space-between' }}> <div
key={index}
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={{
@@ -219,7 +475,10 @@ const IndustryTrendPage: React.FC = () => {
></span> ></span>
<span>{name}</span> <span>{name}</span>
</div> </div>
<b>{value}{params.type === '招聘增长率' ? '%' : ''}</b> <b>
{value}
{params.type === '招聘增长率' ? '%' : ''}
</b>
</div> </div>
); );
})} })}
@@ -233,31 +492,71 @@ const IndustryTrendPage: React.FC = () => {
tooltip: { tooltip: {
showTitle: undefined, showTitle: undefined,
title: undefined, title: undefined,
customContent: undefined customContent: undefined,
} },
}; };
useEffect(() => { useEffect(() => {
fetchData(); if (params.analysisCategory === 'industry') {
}, [params.timeDimension, params.startTime, params.endTime, params.type]); fetchIndustryData();
fetchAreaData();
} else if (params.analysisCategory === 'salary') {
fetchSalaryData();
fetchAreaData();
}
}, [params.timeDimension, params.startTime, params.endTime, params.type, params.analysisCategory]);
return ( return (
<div style={{ padding: 16 }}> <div style={{ padding: 16 }}>
<Card title="行业趋势分析" style={{ marginBottom: 24 }}> <Card title="趋势分析" style={{ marginBottom: 24 }}>
<Tabs
activeKey={params.analysisCategory}
onChange={(key) => setParams(p => ({
...p,
analysisCategory: key as 'industry' | 'salary' | 'area',
selectedIndustry: key === 'industry' && availableIndustries.length > 0 ? availableIndustries[0] : p.selectedIndustry,
selectedSalaryRange: key === 'salary' && availableSalaryRanges.length > 0 ? availableSalaryRanges[0] : p.selectedSalaryRange
}))}
>
<TabPane tab="行业趋势" key="industry" />
<TabPane tab="薪资趋势" key="salary" />
</Tabs>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<Space size="middle" wrap> <Space size="middle" wrap>
<Select {params.analysisCategory === 'industry' && (
value={params.selectedIndustry} <Select
onChange={(value) => setParams(p => ({ ...p, selectedIndustry: value }))} value={params.selectedIndustry}
style={{ width: 150 }} onChange={(value) => setParams((p) => ({ ...p, selectedIndustry: value }))}
loading={loading} style={{ width: 150 }}
placeholder="选择行业" loading={industryLoading}
disabled={availableIndustries.length === 0} placeholder="选择行业"
> disabled={availableIndustries.length === 0}
{availableIndustries.map(industry => ( >
<Option key={industry} value={industry}>{industry}</Option> {availableIndustries.map((industry) => (
))} <Option key={industry} value={industry}>
</Select> {industry}
</Option>
))}
</Select>
)}
{params.analysisCategory === 'salary' && (
<Select
value={params.selectedSalaryRange}
onChange={(value) => setParams((p) => ({ ...p, selectedSalaryRange: value }))}
style={{ width: 150 }}
loading={salaryLoading}
placeholder="选择薪资区间"
disabled={availableSalaryRanges.length === 0}
>
{availableSalaryRanges.map((range) => (
<Option key={range} value={range}>
{range}
</Option>
))}
</Select>
)}
<Select <Select
value={params.timeDimension} value={params.timeDimension}
@@ -270,19 +569,24 @@ const IndustryTrendPage: React.FC = () => {
</Select> </Select>
<RangePicker <RangePicker
picker={params.timeDimension === '月' ? 'month' : picker={
params.timeDimension === '季度' ? 'quarter' : 'year'} params.timeDimension === '月'
? 'month'
: params.timeDimension === '季度'
? 'quarter'
: 'year'
}
value={getPickerValue()} value={getPickerValue()}
onChange={handleDateRangeChange} onChange={handleDateRangeChange}
style={{ width: 180 }} style={{ width: 180 }}
disabled={loading} disabled={industryLoading || areaLoading || salaryLoading}
allowClear={false} allowClear={false}
disabledDate={disabledDate} disabledDate={disabledDate}
/> />
<Select <Select
value={params.type} value={params.type}
onChange={(value) => setParams(p => ({ ...p, type: value }))} onChange={(value) => setParams((p) => ({ ...p, type: value }))}
style={{ width: 100 }} style={{ width: 100 }}
> >
<Option value="岗位发布数量"></Option> <Option value="岗位发布数量"></Option>
@@ -291,89 +595,76 @@ const IndustryTrendPage: React.FC = () => {
<Button <Button
type="primary" type="primary"
onClick={() => fetchData()} onClick={() => {
loading={loading} if (params.analysisCategory === 'industry') {
fetchIndustryData();
fetchAreaData();
} else if (params.analysisCategory === 'salary') {
fetchSalaryData();
fetchAreaData();
}
}}
loading={industryLoading || areaLoading || salaryLoading}
> >
</Button> </Button>
</Space> </Space>
</div> </div>
<Card
title={`${params.selectedIndustry || '请选择行业'}趋势 (${params.type})`}
style={{ marginBottom: 24 }}
bodyStyle={{ padding: '24px 12px' }}
>
<Spin spinning={loading}>
{currentIndustryData.length > 0 ? (
<Line {...chartConfig} />
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
loading ? '数据加载中...' :
params.selectedIndustry ? '当前时间段无数据' : '请先选择行业'
}
/>
)}
</Spin>
</Card>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={8} lg={8} xl={6}> {params.analysisCategory !== 'area' && (
<Card <Col span={24}>
title="区域分析" <Card
bodyStyle={{ title={
params.analysisCategory === 'industry'
? `${params.selectedIndustry || '请选择行业'}趋势 (${params.type})`
: `${params.selectedSalaryRange || '请选择薪资区间'}趋势 (${params.type})`
}
style={{ marginBottom: 24 }}
bodyStyle={{ padding: '24px 12px', height: 210 }}
>
<Spin spinning={params.analysisCategory === 'industry' ? industryLoading : salaryLoading}>
{currentData.length > 0 ? (
<Line {...chartConfig} />
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
industryLoading || salaryLoading
? '数据加载中...'
: params.analysisCategory === 'industry'
? params.selectedIndustry
? '当前时间段无数据'
: '请先选择行业'
: params.selectedSalaryRange
? '当前时间段无数据'
: '请先选择薪资区间'
}
/>
)}
</Spin>
</Card>
</Col>
)}
<Col span={24}>
<Card
title="区域分析"
bodyStyle={{
padding: 12, padding: 12,
height: 300, height: 400,
display: 'flex', position: 'relative',
justifyContent: 'center',
alignItems: 'center'
}} }}
> >
<Empty description="热力地图预留位置" /> {areaLoading ? (
</Card> <Spin tip="数据加载中..." size="large" />
</Col> ) : areaData.length > 0 ? (
<Col xs={24} sm={12} md={8} lg={8} xl={6}> <div style={{ height: '100%', width: '100%' }}>
<Card <Heatmap {...heatmapConfig} />
title="薪资趋势" </div>
bodyStyle={{ ) : (
padding: 12, <Empty description="暂无区域数据" />
height: 300, )}
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}
>
<Empty description="薪资分析预留位置" />
</Card>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={6}>
<Card
title="经验要求"
bodyStyle={{
padding: 12,
height: 300,
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}
>
<Empty description="经验分析预留位置" />
</Card>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={6}>
<Card
title="技能需求"
bodyStyle={{
padding: 12,
height: 300,
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}
>
<Empty description="技能分析预留位置" />
</Card> </Card>
</Col> </Col>
</Row> </Row>

View File

@@ -1,5 +1,4 @@
import { TimeDimension, QuarterFormat, DateFormatter, QuarterFormatter, ApiDataConverter, IndustryDataItem, AreaDataItem } from '@/types/analysis/industry';
import { TimeDimension, QuarterFormat, DateFormatter, QuarterFormatter, ApiDataConverter, IndustryDataItem } from '@/types/analysis/industry';
export const formatQuarter: QuarterFormatter = (dateStr: string): QuarterFormat => { export const formatQuarter: QuarterFormatter = (dateStr: string): QuarterFormat => {
if (dateStr.includes('第')) return dateStr as QuarterFormat; if (dateStr.includes('第')) return dateStr as QuarterFormat;
@@ -77,4 +76,86 @@ export const convertApiData: ApiDataConverter = (apiData: any) => {
console.error('数据转换错误:', error); console.error('数据转换错误:', error);
return []; return [];
} }
};
export const convertAreaApiData = (apiData: any): AreaDataItem[] => {
if (!apiData?.data) {
console.warn('convertAreaApiData: apiData.data 为空');
return [];
}
try {
const result: AreaDataItem[] = [];
// 处理嵌套的月份数据
if (typeof apiData.data === 'object' && !Array.isArray(apiData.data)) {
Object.entries(apiData.data).forEach(([time, items]) => {
if (Array.isArray(items)) {
items.forEach((item: any) => {
if (!item) return;
const uniqueName = items.filter((i: any) => i.name === item.name).length > 1
? `${item.name}_${index}`
: item.name;
result.push({
name: item.name || '未知区域',
value: Number(item.data) || 0,
time: item.time || time,
x:uniqueName,
y:time,
category: item.name,
originalData: item
});
});
}
});
return result;
}
if (Array.isArray(apiData.data)) {
return apiData.data.map((item: any) => ({
name: item.name || '未知区域',
value: Number(item.data) || 0,
time: item.time || '未知时间',
x: item.name,
y: item.time,
category: item.name,
originalData: item
}));
}
return [];
} catch (error) {
console.error('数据转换错误:', error);
return [];
}
};
export const convertSalaryData: ApiDataConverter = (apiData: any) => {
if (!apiData) return [];
try {
if (Array.isArray(apiData)) {
return apiData.map(item => ({
date: item.time || item.date,
category: item.name || item.category || '未知薪资区间',
value: Number(item.data || item.value) || 0
}));
}
if (typeof apiData === 'object') {
const result: IndustryDataItem[] = [];
Object.entries(apiData).forEach(([date, items]) => {
if (Array.isArray(items)) {
items.forEach((item: any) => {
result.push({
date,
category: item.name || item.category || '未知薪资区间',
value: Number(item.data || item.value) || 0
});
});
}
});
return result;
}
return [];
} catch (error) {
console.error('薪资数据转换错误:', error);
return [];
}
}; };

View File

@@ -5,4 +5,28 @@ export async function getIndustryTrend(params?: API.Analysis.IndustryParams) {
method: 'GET', method: 'GET',
params params
}); });
}
export async function getIndustryAreaTrend(params: any) {
try {
const response = await request('/api/cms/statics/industryArea', {
method: 'GET',
params,
headers: {
'Content-Type': 'application/json'
}
});
console.log('接口原始响应:', response); // 调试日志
return response; // 兼容不同后端响应格式
} catch (error) {
console.error('接口请求异常:', error);
throw error;
}
}
export async function getSalaryTrend(params?: API.Analysis.IndustryParams) {
return request<API.Analysis.IndustryResult>('/api/cms/statics/salary', {
method: 'GET',
params
});
} }

View File

@@ -4,18 +4,25 @@ import { ReactNode } from 'react';
export type TimeDimension = '月' | '季度' | '年'; export type TimeDimension = '月' | '季度' | '年';
export type AnalysisType = '岗位发布数量' | '招聘增长率'; export type AnalysisType = '岗位发布数量' | '招聘增长率';
export type QuarterFormat = `${number}-${'Q1'|'Q2'|'Q3'|'Q4'|'第一季度'|'第二季度'|'第三季度'|'第四季度'}`; export type QuarterFormat = `${number}-${'Q1'|'Q2'|'Q3'|'Q4'|'第一季度'|'第二季度'|'第三季度'|'第四季度'}`;
export type AnalysisCategory = 'industry' | 'area' | 'salary';
export type SalaryRange = '3k-5k' | '5k-8k' | '8k-10k' | '10k+';
export interface IndustryDataItem { export interface IndustryDataItem {
date: string; date: string;
category: string; category: string;
value: number; value: number;
} }
export interface AreaDataItem {
name: string;
value: number;
time: string;
}
export interface IndustryTrendParams { export interface IndustryTrendParams {
timeDimension: TimeDimension; timeDimension: TimeDimension;
type: AnalysisType; type: AnalysisType;
startTime: string; startTime: string;
endTime: string; endTime: string;
selectedIndustry: string;
} }
export interface IndustryTrendState extends IndustryTrendParams { export interface IndustryTrendState extends IndustryTrendParams {
@@ -80,7 +87,15 @@ export interface ChartConfig {
}>; }>;
legend?: boolean; legend?: boolean;
} }
export interface IndustryTrendState {
timeDimension: TimeDimension;
type: AnalysisType;
startTime: string;
endTime: string;
selectedIndustry: string;
selectedSalaryRange: string;
analysisCategory: AnalysisCategory; // 新增分析类别
}
export type DateFormatter = (dateStr: string, dimension: TimeDimension) => string; export type DateFormatter = (dateStr: string, dimension: TimeDimension) => string;
export type QuarterFormatter = (dateStr: string) => QuarterFormat; export type QuarterFormatter = (dateStr: string) => QuarterFormat;
export type ApiDataConverter = (apiData: any) => IndustryDataItem[]; export type ApiDataConverter = (apiData: any) => IndustryDataItem[];