新增 用户行为记录 新增岗位信息管理

This commit is contained in:
bin
2025-12-02 14:05:46 +08:00
parent 484aceb6f9
commit a2e532389e
9 changed files with 759 additions and 9 deletions

View File

@@ -55,7 +55,7 @@ export default function AreaStatics({ data }) {
height: 220,
autoFit: true,
style:{
columnWidthRatio: 0.4, // 统一柱状图宽度比例
columnWidthRatio: 0.4,
},
color: color,
xAxis: {

View File

@@ -8,6 +8,7 @@ import { EyeOutlined, UserOutlined } from '@ant-design/icons';
import { getDictValueEnum } from '@/services/system/dict';
import DictTag from '@/components/DictTag';
import ResumeDetail from '../components/detail';
import BehaviorLogList from '../components/logList';
function MobileUserList() {
const access = useAccess();
@@ -19,13 +20,18 @@ function MobileUserList() {
const [modalVisible, setModalVisible] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
// 用户行为
const [behaviorModalVisible, setBehaviorModalVisible] = useState<boolean>(false);
const [selectedUserId, setSelectedUserId] = useState<string | number>('');
const [selectedUserName, setSelectedUserName] = useState<string>('');
// 字典枚举值
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);
@@ -59,10 +65,11 @@ function MobileUserList() {
}
};
// 查看用户行为(待定功能)
const handleViewBehavior = (userId: any) => {
message.info('用户行为查看功能开发中');
// TODO: 待后续开发
// 查看用户行为(
const handleViewBehavior = (userId: any, userName: string) => {
setSelectedUserId(userId);
setSelectedUserName(userName || '未知用户');
setBehaviorModalVisible(true);
};
const columns: ProColumns<API.MobileUser.ListRow>[] = [
@@ -72,6 +79,13 @@ function MobileUserList() {
valueType: 'text',
align: 'center',
},
{
title: '手机号',
dataIndex: 'phone',
valueType: 'text',
align: 'center',
hideInSearch: true,
},
{
title: '出生日期',
dataIndex: 'birthDate',
@@ -155,7 +169,7 @@ function MobileUserList() {
key="behavior"
icon={<UserOutlined />}
hidden={!access.hasPerms('mobileusers:list:viewBehavior')}
onClick={() => handleViewBehavior(userId)}
onClick={() => handleViewBehavior(userId, record.name)}
>
</Button>
@@ -191,6 +205,8 @@ function MobileUserList() {
}}
/>
</div>
{/* 简历详情弹窗 */}
<ResumeDetail
open={modalVisible}
values={currentRow}
@@ -199,6 +215,18 @@ function MobileUserList() {
setCurrentRow(undefined);
}}
/>
{/* 用户行为记录弹窗 */}
<BehaviorLogList
userId={selectedUserId}
userName={selectedUserName}
open={behaviorModalVisible}
onCancel={() => {
setBehaviorModalVisible(false);
setSelectedUserId('');
setSelectedUserName('');
}}
/>
</Fragment>
);
}

View File

@@ -0,0 +1,288 @@
import React, { useState, useEffect } from 'react';
import { Modal, Table, Card, Tag, Space, Typography, Spin, Empty } from 'antd';
import { getBehaviorLog } from '@/services/mobileusers/list';
const { Text } = Typography;
export type BehaviorLogProps = {
userId: string | number;
userName: string;
open: boolean;
onCancel: () => void;
};
interface BehaviorLogItem {
id: string;
opTime: string;
content: string;
userId: string;
[key: string]: any;
}
const BehaviorLogList: React.FC<BehaviorLogProps> = (props) => {
const { userId, userName, open, onCancel } = props;
const [loading, setLoading] = useState<boolean>(false);
const [dataSource, setDataSource] = useState<BehaviorLogItem[]>([]);
const [total, setTotal] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
// 获取行为日志数据
const fetchBehaviorLog = async (page: number = 1, size: number = 10) => {
if (!userId) return;
setLoading(true);
try {
const params = {
userId: String(userId),
current: page,
pageSize: size,
};
const res = await getBehaviorLog(params);
if (res.code === 200) {
setDataSource(res.rows || []);
setTotal(Number(res.total) || 0);
} else {
setDataSource([]);
setTotal(0);
}
} catch (error) {
console.error('获取用户行为日志失败:', error);
setDataSource([]);
setTotal(0);
} finally {
setLoading(false);
}
};
// 当userId或open变化时重新获取数据
useEffect(() => {
if (open && userId) {
fetchBehaviorLog(currentPage, pageSize);
}
}, [open, userId, currentPage, pageSize]);
// 处理分页变化
const handleTableChange = (pagination: any) => {
setCurrentPage(pagination.current);
setPageSize(pagination.pageSize);
};
// 格式化时间显示
const formatTime = (timeStr: string) => {
if (!timeStr) return '-';
try {
const date = new Date(timeStr);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
} catch (error) {
return timeStr;
}
};
// 根据内容获取标签颜色
const getTagColor = (content: string) => {
if (!content) return 'default';
const lowerContent = content.toLowerCase();
if (lowerContent.includes('登录') || lowerContent.includes('登出')) {
return 'blue';
} else if (lowerContent.includes('浏览') || lowerContent.includes('查看')) {
return 'green';
} else if (lowerContent.includes('提交') || lowerContent.includes('申请')) {
return 'purple';
} else if (lowerContent.includes('修改') || lowerContent.includes('更新')) {
return 'orange';
} else if (lowerContent.includes('删除') || lowerContent.includes('取消')) {
return 'red';
} else {
return 'default';
}
};
// 表格列定义
const columns = [
{
title: '序号',
key: 'index',
width: 100,
align: 'center',
render: (_: any, __: any, index: number) => {
return (currentPage - 1) * pageSize + index + 1;
},
},
{
title: '操作时间',
dataIndex: 'opTime',
key: 'opTime',
align: 'center',
width: 180,
render: (text: string) => (
<Text type="secondary" style={{ fontSize: 12 }}>
{formatTime(text)}
</Text>
),
sorter: (a: BehaviorLogItem, b: BehaviorLogItem) => {
return new Date(a.opTime).getTime() - new Date(b.opTime).getTime();
},
},
{
title: '行为类型',
key: 'actionType',
align: 'center',
width: 180,
render: (_: any, record: BehaviorLogItem) => {
const content = record.content || '';
let type = '其他';
if (content.includes('登录')) type = '登录';
else if (content.includes('浏览')) type = '浏览';
else if (content.includes('提交')) type = '提交';
else if (content.includes('修改')) type = '修改';
else if (content.includes('删除')) type = '删除';
return (
<Tag color={getTagColor(content)} style={{ margin: 0 }}>
{type}
</Tag>
);
},
},
{
title: '操作行为',
dataIndex: 'content',
key: 'content',
render: (text: string) => (
<div>
<Text type="secondary" style={{ lineHeight: 1.5 }}>
{text}
</Text>
</div>
),
},
];
return (
<Modal
title={
<Space>
<span></span>
<Text type="secondary" style={{ fontSize: 14 }}>
({userName || '未知用户'})
</Text>
</Space>
}
open={open}
onCancel={onCancel}
width={1100}
footer={null}
bodyStyle={{ padding: 0 }}
>
<Spin spinning={loading}>
<Card
size="small"
style={{ border: 'none' }}
styles={{
body: { padding: '16px 0 0 0' },
}}
>
{/* 统计信息 */}
{dataSource.length > 0 && (
<Card
size="small"
style={{
marginBottom: 16,
backgroundColor: '#fafafa',
border: '1px solid #f0f0f0',
}}
>
<Space size={24} wrap>
<div>
<Text type="secondary">ID</Text>
<Text strong>{userId}</Text>
</div>
<div>
<Text type="secondary"></Text>
<Text strong>{userName}</Text>
</div>
<div>
<Text type="secondary"></Text>
<Text strong style={{ color: '#1890ff' }}>
{total}
</Text>
</div>
<div>
<Text type="secondary"></Text>
<Text strong>
{dataSource.length > 0 ? formatTime(dataSource[0].opTime) : '暂无'}
</Text>
</div>
</Space>
</Card>
)}
{dataSource.length > 0 ? (
<Table
columns={columns}
dataSource={dataSource}
rowKey="id"
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${total} 条记录,显示 ${range[0]}-${range[1]}`,
pageSizeOptions: ['10', '20', '30', '50'],
}}
onChange={handleTableChange}
size="middle"
scroll={{ y: 400 }}
locale={{
emptyText: (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无行为记录" />
),
}}
onRow={(record) => {
return {
style: {
cursor: 'pointer',
transition: 'all 0.2s',
},
onMouseEnter: (e) => {
e.currentTarget.style.backgroundColor = '#f5f5f5';
},
onMouseLeave: (e) => {
e.currentTarget.style.backgroundColor = 'inherit';
},
};
}}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div>
<p></p>
<Text type="secondary"></Text>
</div>
}
style={{ margin: '40px 0' }}
/>
)}
</Card>
</Spin>
</Modal>
);
};
export default BehaviorLogList;

View File

@@ -0,0 +1,119 @@
import React, { useEffect } from 'react';
import { Modal, Descriptions,Button } from 'antd';
import { DictValueEnumObj } from '@/components/DictTag';
export type DetailModalProps = {
onCancel: () => void;
open: boolean;
values?: Partial<API.ManagementList.Manage>;
educationEnum?: DictValueEnumObj;
experienceEnum?: DictValueEnumObj;
areaEnum?: DictValueEnumObj;
};
const DetailModal: React.FC<DetailModalProps> = (props) => {
const { values } = props;
// 可以在这里获取字典数据,如果传入的话
// 或者通过 useEffect 请求
// 如果没有传入字典数据,可以在这里获取
useEffect(() => {
if (props.open && !props.educationEnum) {
// 这里可以获取字典数据
// getDictValueEnum('education', true, true).then(setEducationEnum);
}
}, [props.open]);
const handleCancel = () => {
props.onCancel();
};
// 如果没有数据,显示加载状态
if (!values) {
return null;
}
return (
<Modal
title="岗位详情"
open={props.open}
width={800}
onCancel={handleCancel}
footer={[
<Button key="close" onClick={handleCancel}>
</Button>
]}
destroyOnHidden
>
<Descriptions column={2} bordered>
<Descriptions.Item label="岗位名称" span={2}>
{values.jobTitle || '-'}
</Descriptions.Item>
<Descriptions.Item label="公司名称">
{values.companyName || '-'}
</Descriptions.Item>
<Descriptions.Item label="薪资范围">
{`${values.minSalary || 0} - ${values.maxSalary || 0} 元/月`}
</Descriptions.Item>
<Descriptions.Item label="学历要求">
{props.educationEnum ? (
<span>{props.educationEnum[values.education as string] || values.education || '-'}</span>
) : (
values.education || '-'
)}
</Descriptions.Item>
<Descriptions.Item label="工作经验">
{props.experienceEnum ? (
<span>{props.experienceEnum[values.experience as string] || values.experience || '-'}</span>
) : (
values.experience || '-'
)}
</Descriptions.Item>
<Descriptions.Item label="工作区县">
{props.areaEnum ? (
<span>{props.areaEnum[values.jobLocationAreaCode as string] || values.jobLocationAreaCode || '-'}</span>
) : (
values.jobLocationAreaCode || '-'
)}
</Descriptions.Item>
<Descriptions.Item label="招聘人数">
{values.vacancies || 0}
</Descriptions.Item>
<Descriptions.Item label="工作地点" span={2}>
{values.jobLocation || '-'}
</Descriptions.Item>
<Descriptions.Item label="岗位描述" span={2}>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{values.description || '-'}
</div>
</Descriptions.Item>
<Descriptions.Item label="浏览量">
{values.view || 0}
</Descriptions.Item>
<Descriptions.Item label="发布时间">
{values.createTime || '-'}
</Descriptions.Item>
{values.isPublish !== undefined && (
<Descriptions.Item label="发布状态">
{values.isPublish === 1 ? '已发布' : '未发布'}
</Descriptions.Item>
)}
</Descriptions>
</Modal>
);
};
export default DetailModal;

View File

@@ -0,0 +1,286 @@
import React, { Fragment, useRef, useState, useEffect } from 'react';
import { history, useAccess } from '@umijs/max';
import { getCmsJobList, getJobTrend } from '@/services/Management/list';
import { Button, DatePicker, FormInstance, message, Space, Card } from 'antd';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { AlignLeftOutlined } from '@ant-design/icons';
import { Line } from '@ant-design/charts';
import dayjs from 'dayjs';
import { getDictValueEnum } from '@/services/system/dict';
import DictTag from '@/components/DictTag';
import DetailModal from './detail';
const { RangePicker } = DatePicker;
function JobStatistics() {
const access = useAccess();
const formTableRef = useRef<FormInstance>();
const actionRef = useRef<ActionType>();
const [chartData, setChartData] = useState<any[]>([]);
const [currentRow, setCurrentRow] = useState<API.ManagementList.Manage>();
const [detailVisible, setDetailVisible] = useState<boolean>(false);
const [educationEnum, setEducationEnum] = useState<any>([]);
const [experienceEnum, setExperienceEnum] = useState<any>([]);
const [areaEnum, setAreaEnum] = useState<any>([]);
const [isPublishEnum, setIsPublishEnum] = useState<any>([]);
const [params, setParams] = useState({
beginTime: dayjs().subtract(13, 'day').format('YYYY-MM-DD'),
endTime: dayjs().format('YYYY-MM-DD'),
});
const getPickerValue = () => {
return [dayjs(params.beginTime), dayjs(params.endTime)];
};
const disabledDate = (current: dayjs.Dayjs) => {
const now = dayjs();
return current.isAfter(now.endOf('day'));
};
// 初始化图表数据
useEffect(() => {
getChartData();
}, [params.beginTime, params.endTime]);
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('is_publish', true).then((data) => {
setIsPublishEnum(data);
});
}, []);
const handleDateRangeChange = (dates: any, dateStrings: [string, string]) => {
if (dates && dates[0] && dates[1]) {
setParams(() => ({
beginTime: dateStrings[0],
endTime: dateStrings[1],
}));
}
};
async function getChartData() {
let { data } = await getJobTrend(params);
setChartData(data); // 暂时使用模拟数据
}
// 图表配置
const chartConfig = {
data: chartData,
xField: 'storageTime',
yField: 'insertCount',
style: {
columnWidthRatio: 0.6,
},
xAxis: {
label: {
autoHide: true,
autoRotate: false,
},
},
point: {
size: 4,
shape: 'circle',
},
line: {
size: 3,
style: {
lineCap: 'round',
},
},
tooltip: {
items: [
{
field: 'insertCount',
name: '录入数量',
valueFormatter: (v) => `${v}`,
title: '日期',
},
],
},
};
const columns: ProColumns<API.ManagementList.Manage>[] = [
{
title: '岗位采集日期',
dataIndex: 'collectionDate1',
hideInTable: true,
valueType: 'dateRange',
search: {
transform: (value) => {
return {
beginTime: value[0],
endTime: value[1],
};
},
},
},
{
title: '岗位名称',
dataIndex: 'jobTitle',
valueType: 'text',
align: 'center',
},
{
title: '最小薪资(元/月)',
dataIndex: 'minSalary',
valueType: 'text',
align: 'center',
hideInSearch: true,
},
{
title: '最大薪资(元/月)',
dataIndex: 'maxSalary',
valueType: 'text',
align: 'center',
hideInSearch: true,
},
{
title: '单位名称',
dataIndex: 'companyName',
valueType: 'text',
align: 'center',
},
{
title: '学历要求',
dataIndex: 'education',
valueType: 'select',
align: 'center',
valueEnum: educationEnum,
render: (_, record) => {
return <DictTag enums={educationEnum} value={record.education} />;
},
hideInSearch: true,
},
{
title: '经验要求',
dataIndex: 'experience',
valueType: 'select',
align: 'center',
valueEnum: experienceEnum,
render: (_, record) => {
return <DictTag enums={experienceEnum} value={record.experience} />;
},
hideInSearch: true,
},
{
title: '是否发布',
dataIndex: 'isPublish',
valueType: 'select',
align: 'center',
valueEnum: isPublishEnum,
render: (_, record) => {
return <DictTag enums={isPublishEnum} value={record.isPublish} />;
},
hideInSearch: true,
},
{
title: '招聘人数',
dataIndex: 'vacancies',
valueType: 'text',
align: 'center',
hideInSearch: true,
},
{
title: '浏览量',
dataIndex: 'view',
valueType: 'text',
align: 'center',
hideInSearch: true,
},
{
title: '操作',
hideInSearch: true,
align: 'center',
dataIndex: 'jobId',
width: 100,
render: (jobId, record) => (
<Button
type="link"
size="small"
icon={<AlignLeftOutlined />}
onClick={() => {
setCurrentRow(record);
setDetailVisible(true);
}}
>
</Button>
),
},
];
return (
<Fragment>
<Card
title={<span>📊 </span>}
style={{
marginBottom: 16,
}}
extra={
<div>
<span></span>
<RangePicker
picker="date"
value={getPickerValue()}
onChange={handleDateRangeChange}
disabledDate={disabledDate}
/>
</div>
}
>
<div style={{ height: 300 }}>
<Line {...chartConfig} />
</div>
</Card>
<div style={{ background: '#fff', padding: 24, borderRadius: 8 }}>
<ProTable<API.ManagementList.Manage>
actionRef={actionRef}
formRef={formTableRef}
rowKey="jobId"
key="jobStatisticsList"
columns={columns}
request={(params) => {
return getCmsJobList(params as API.ManagementList.ListParams).then((res) => {
const result = {
data: res.rows,
total: res.total,
success: true,
};
return result;
});
}}
search={{
labelWidth: 120,
}}
toolBarRender={false}
pagination={{
pageSize: 10,
}}
/>
</div>
<DetailModal
open={detailVisible}
onCancel={() => {
setDetailVisible(false);
setCurrentRow(undefined);
}}
values={currentRow}
/>
</Fragment>
);
}
export default JobStatistics;

View File

@@ -182,7 +182,7 @@ function ResumeList() {
hidden={!access.hasPerms('resumeLibrary:resumeList:view')}
onClick={() => handleViewDetail(userId)}
>
</Button>
</div>
),

View File

@@ -51,3 +51,9 @@ export async function exportCmsJobCandidates(ids: string) {
},
});
}
export async function getJobTrend(params) {
return request(`/api/cms/jobTrend/list`, {
method: 'GET',
params: params
});
}

View File

@@ -14,6 +14,12 @@ export async function getCmsAppUser(userId?: string) {
method: 'GET',
});
}
export async function getBehaviorLog(params?:API.MobileUser.LogParams) {
return request<API.MobileUser.LogResult>(`/api/cms/behaviorLog/getList`, {
method: 'GET',
params
});
}
export async function exportCmsAppUserExport(params?: API.MobileUser.ListParams) {
return downLoadXlsx(

View File

@@ -35,4 +35,21 @@ declare namespace API.MobileUser {
education?: string;
politicalAffiliation?: string;
}
export interface LogParams {
userId?: string;
current?: string;
pageSize?: string;
}
export interface LogRow {
opTime?: string;
content?: string;
}
export interface LogResult {
total: number;
rows: LogRow[];
code: number;
msg: string;
}
}