简历集成、简历预览、简历编辑、修改密码等功能开发
Some checks failed
Node CI / build (14.x, macOS-latest) (push) Has been cancelled
Node CI / build (14.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (14.x, windows-latest) (push) Has been cancelled
Node CI / build (16.x, macOS-latest) (push) Has been cancelled
Node CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (16.x, windows-latest) (push) Has been cancelled
coverage CI / build (push) Has been cancelled
Node pnpm CI / build (16.x, macOS-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, windows-latest) (push) Has been cancelled
Some checks failed
Node CI / build (14.x, macOS-latest) (push) Has been cancelled
Node CI / build (14.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (14.x, windows-latest) (push) Has been cancelled
Node CI / build (16.x, macOS-latest) (push) Has been cancelled
Node CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (16.x, windows-latest) (push) Has been cancelled
coverage CI / build (push) Has been cancelled
Node pnpm CI / build (16.x, macOS-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, windows-latest) (push) Has been cancelled
This commit is contained in:
@@ -22,15 +22,20 @@ const InterviewInvite: React.FC<InterviewInviteProps> = (props) => {
|
||||
|
||||
const [interviewForm] = Form.useForm();
|
||||
const [interviewMethod, setInterviewMethod] = useState<string>('online');
|
||||
const [meetingType, setMeetingType] = useState<string>('shz_video');
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
const [alreadyInvited, setAlreadyInvited] = useState<boolean>(false);
|
||||
const [checkingInvitation, setCheckingInvitation] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && userId && jobId) {
|
||||
checkExistingInvitation();
|
||||
if (open && userId) {
|
||||
if (jobId) {
|
||||
checkExistingInvitation();
|
||||
}
|
||||
interviewForm.resetFields();
|
||||
setInterviewMethod('online');
|
||||
setMeetingType('shz_video');
|
||||
interviewForm.setFieldsValue({ interviewMethod: 'online', meetingType: 'shz_video', meetingLink: 'https://mc.repohub.cn/pro.html' });
|
||||
}
|
||||
}, [open, userId, jobId]);
|
||||
|
||||
@@ -74,6 +79,7 @@ const InterviewInvite: React.FC<InterviewInviteProps> = (props) => {
|
||||
? dayjs(values.interviewTime).format('YYYY-MM-DD HH:mm:ss')
|
||||
: undefined,
|
||||
interviewMethod: values.interviewMethod,
|
||||
meetingType: values.meetingType,
|
||||
meetingLink: values.meetingLink,
|
||||
meetingPassword: values.meetingPassword,
|
||||
interviewLocation: values.interviewLocation,
|
||||
@@ -89,6 +95,7 @@ const InterviewInvite: React.FC<InterviewInviteProps> = (props) => {
|
||||
setAlreadyInvited(true);
|
||||
interviewForm.resetFields();
|
||||
setInterviewMethod('online');
|
||||
setMeetingType('shz_video');
|
||||
} else {
|
||||
message.error(response.msg || '发送失败');
|
||||
}
|
||||
@@ -118,7 +125,7 @@ const InterviewInvite: React.FC<InterviewInviteProps> = (props) => {
|
||||
<Form
|
||||
form={interviewForm}
|
||||
layout="vertical"
|
||||
initialValues={{ interviewMethod: 'online', meetingLink: 'https://mc.repohub.cn/pro.html' }}
|
||||
initialValues={{ interviewMethod: 'online', meetingType: 'shz_video', meetingLink: 'https://mc.repohub.cn/pro.html' }}
|
||||
>
|
||||
<Form.Item
|
||||
name="interviewMethod"
|
||||
@@ -147,12 +154,53 @@ const InterviewInvite: React.FC<InterviewInviteProps> = (props) => {
|
||||
</Form.Item>
|
||||
|
||||
{interviewMethod === 'online' && (
|
||||
<Form.Item
|
||||
name="meetingLink"
|
||||
label="在线会议链接"
|
||||
>
|
||||
<Input placeholder="请输入在线会议链接" />
|
||||
</Form.Item>
|
||||
<>
|
||||
<Form.Item
|
||||
name="meetingType"
|
||||
label="在线会议类型"
|
||||
rules={[{ required: true, message: '请选择会议类型' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择会议类型"
|
||||
onChange={(value) => {
|
||||
setMeetingType(value);
|
||||
if (value === 'shz_video') {
|
||||
interviewForm.setFieldsValue({ meetingLink: 'https://mc.repohub.cn/pro.html', meetingPassword: '' });
|
||||
} else {
|
||||
interviewForm.setFieldsValue({ meetingLink: '', meetingPassword: '' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Option value="shz_video">石河子视频会议</Option>
|
||||
<Option value="tencent_meeting">腾讯会议</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{meetingType === 'shz_video' && (
|
||||
<Form.Item
|
||||
name="meetingLink"
|
||||
label="会议链接"
|
||||
>
|
||||
<Input disabled placeholder="https://mc.repohub.cn/pro.html" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{meetingType === 'tencent_meeting' && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="meetingLink"
|
||||
label="腾讯会议链接"
|
||||
rules={[{ required: true, message: '请输入腾讯会议链接' }]}
|
||||
>
|
||||
<Input placeholder="请输入腾讯会议链接" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="meetingPassword"
|
||||
label="会议密码"
|
||||
>
|
||||
<Input placeholder="请输入会议密码(选填)" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{interviewMethod === 'offline' && (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { FormattedMessage, useAccess } from '@umijs/max';
|
||||
import { FormattedMessage, useAccess, useNavigate } from '@umijs/max';
|
||||
import {Button, FormInstance, message, Switch} from 'antd';
|
||||
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { PlusOutlined, SendOutlined, StarOutlined, StarFilled } from '@ant-design/icons';
|
||||
import { PlusOutlined, SendOutlined, StarOutlined, StarFilled, FileTextOutlined } from '@ant-design/icons';
|
||||
import { getDictValueEnum } from '@/services/system/dict';
|
||||
import DictTag from '@/components/DictTag';
|
||||
import { applyAgree, delHrTalentCollect, exportCmsAppUserExport, getCmsAppUserList, getHrTalentCollectList } from '@/services/mobileusers/list';
|
||||
@@ -25,6 +25,7 @@ const handleExport = async (values: API.MobileUser.ListParams) => {
|
||||
|
||||
function ManagementList() {
|
||||
const access = useAccess();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const formTableRef = useRef<FormInstance>();
|
||||
const actionRef = useRef<ActionType>();
|
||||
@@ -85,10 +86,15 @@ function ManagementList() {
|
||||
}
|
||||
};
|
||||
|
||||
// 取消收藏(接口路径参数为userId)
|
||||
// 取消收藏(接口路径参数为收藏记录主键id)
|
||||
const handleCancelCollect = async (record: API.MobileUser.ListRow) => {
|
||||
const collectInfo = collectMap.get(record.userId);
|
||||
if (!collectInfo?.id) {
|
||||
message.error('收藏信息不存在');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await delHrTalentCollect(String(record.userId));
|
||||
const res = await delHrTalentCollect(String(collectInfo.id));
|
||||
if (res.code === 200) {
|
||||
message.success('已取消收藏');
|
||||
setCollectMap((prev) => {
|
||||
@@ -228,10 +234,19 @@ function ManagementList() {
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
dataIndex: 'jobId',
|
||||
width: 200,
|
||||
width: 280,
|
||||
render: (_, record) => {
|
||||
const isCollected = collectMap.has(record.userId);
|
||||
return [
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
key="resume"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => navigate(`/usermgmt/appuser/resume/${record.userId}?readOnly=1`)}
|
||||
>
|
||||
简历
|
||||
</Button>,
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
|
||||
86
src/pages/Resumeaccesslog/index.tsx
Normal file
86
src/pages/Resumeaccesslog/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { getResumeAccessLogList } from '@/services/cms/resumeAccessLog';
|
||||
|
||||
const ResumeAccessLogList: React.FC = () => {
|
||||
const actionRef = useRef<ActionType>();
|
||||
|
||||
const columns: ProColumns<Record<string, any>>[] = [
|
||||
{
|
||||
title: '求职者ID',
|
||||
dataIndex: 'userId',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'accessBy',
|
||||
align: 'center',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '企业ID',
|
||||
dataIndex: 'companyId',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '访问时间',
|
||||
dataIndex: 'accessTime',
|
||||
valueType: 'dateTime',
|
||||
align: 'center',
|
||||
width: 160,
|
||||
hideInSearch: true,
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '来源页面',
|
||||
dataIndex: 'sourcePage',
|
||||
align: 'center',
|
||||
ellipsis: true,
|
||||
width: 300,
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: 'IP地址',
|
||||
dataIndex: 'ipAddress',
|
||||
align: 'center',
|
||||
width: 140,
|
||||
hideInSearch: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<ProTable<Record<string, any>>
|
||||
actionRef={actionRef}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
search={{ labelWidth: 'auto' }}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, ...searchParams } = params;
|
||||
const filteredParams: Record<string, any> = {};
|
||||
Object.keys(searchParams).forEach((key) => {
|
||||
const val = (searchParams as any)[key];
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
filteredParams[key] = val;
|
||||
}
|
||||
});
|
||||
const res = await getResumeAccessLogList({
|
||||
pageNum: current,
|
||||
pageSize,
|
||||
...filteredParams,
|
||||
});
|
||||
return {
|
||||
data: res?.rows || [],
|
||||
total: res?.total || 0,
|
||||
success: true,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResumeAccessLogList;
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { useAccess } from '@umijs/max';
|
||||
import { useAccess, useNavigate } from '@umijs/max';
|
||||
import { Button, FormInstance, message, Tag } from 'antd';
|
||||
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { StarFilled } from '@ant-design/icons';
|
||||
import { StarFilled, FileTextOutlined, SendOutlined } from '@ant-design/icons';
|
||||
import { getDictValueEnum } from '@/services/system/dict';
|
||||
import DictTag from '@/components/DictTag';
|
||||
import { delHrTalentCollect, getHrTalentCollectList } from '@/services/mobileusers/list';
|
||||
import InterviewInvite from '@/pages/Management/List/ResumeRecommend/InterviewInvite';
|
||||
|
||||
function TalentCollectList() {
|
||||
const access = useAccess();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const formTableRef = useRef<FormInstance>();
|
||||
const actionRef = useRef<ActionType>();
|
||||
@@ -16,6 +18,8 @@ function TalentCollectList() {
|
||||
const [educationEnum, setEducationEnum] = useState<any>({});
|
||||
const [areaEnum, setAreaEnum] = useState<any>({});
|
||||
const [sexEnum, setSexEnum] = useState<any>({});
|
||||
const [inviteVisible, setInviteVisible] = useState<boolean>(false);
|
||||
const [currentRow, setCurrentRow] = useState<API.TalentCollect.ListRow>();
|
||||
|
||||
useEffect(() => {
|
||||
getDictValueEnum('education', false, true).then((data) => setEducationEnum(data));
|
||||
@@ -23,11 +27,11 @@ function TalentCollectList() {
|
||||
getDictValueEnum('sys_user_sex', false).then((data) => setSexEnum(data));
|
||||
}, []);
|
||||
|
||||
// 取消收藏
|
||||
// 取消收藏(接口路径参数为收藏记录主键id)
|
||||
const handleCancelCollect = async (record: API.TalentCollect.ListRow) => {
|
||||
if (!record.userId) return;
|
||||
if (!record.id) return;
|
||||
try {
|
||||
const res = await delHrTalentCollect(String(record.userId));
|
||||
const res = await delHrTalentCollect(String(record.id));
|
||||
if (res.code === 200) {
|
||||
message.success('已取消收藏');
|
||||
actionRef.current?.reload();
|
||||
@@ -155,8 +159,30 @@ function TalentCollectList() {
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
dataIndex: 'userId',
|
||||
width: 120,
|
||||
width: 280,
|
||||
render: (_, record) => [
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
key="resume"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => navigate(`/usermgmt/appuser/resume/${record.userId}?readOnly=1`)}
|
||||
>
|
||||
简历
|
||||
</Button>,
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
key="invite-interview"
|
||||
icon={<SendOutlined />}
|
||||
hidden={!access.hasPerms('cms:interview:add')}
|
||||
onClick={() => {
|
||||
setCurrentRow(record);
|
||||
setInviteVisible(true);
|
||||
}}
|
||||
>
|
||||
邀请面试
|
||||
</Button>,
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -204,6 +230,19 @@ function TalentCollectList() {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<InterviewInvite
|
||||
open={inviteVisible}
|
||||
userId={currentRow?.userId}
|
||||
userName={currentRow?.name}
|
||||
companyId={currentRow?.companyId}
|
||||
onClose={(needRefresh) => {
|
||||
setInviteVisible(false);
|
||||
setCurrentRow(undefined);
|
||||
if (needRefresh) {
|
||||
actionRef.current?.reload();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,45 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Tag } from 'antd';
|
||||
import { Tag, Button, Switch, Modal, Form, Input, message } from 'antd';
|
||||
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { getAppUserList } from '@/services/cms/appuser';
|
||||
import { useNavigate } from '@umijs/max';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
getAppUserList,
|
||||
updateIdCard,
|
||||
changeResumeStatus,
|
||||
resetPassword,
|
||||
} from '@/services/cms/appuser';
|
||||
import { getDictValueEnum } from '@/services/system/dict';
|
||||
import DictTag from '@/components/DictTag';
|
||||
|
||||
/** 身份证校验规则(18位) */
|
||||
const ID_CARD_REGEX = /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/;
|
||||
|
||||
function AppUserList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const navigate = useNavigate();
|
||||
const [educationEnum, setEducationEnum] = useState<Record<string, any>>({});
|
||||
const [sexEnum, setSexEnum] = useState<Record<string, any>>({});
|
||||
const [areaEnum, setAreaEnum] = useState<Record<string, any>>({});
|
||||
const [politicalEnum, setPoliticalEnum] = useState<Record<string, any>>({});
|
||||
const [nationEnum, setNationEnum] = useState<Record<string, any>>({});
|
||||
|
||||
/** 修改身份证 Modal 状态 */
|
||||
const [idCardModalOpen, setIdCardModalOpen] = useState(false);
|
||||
const [idCardRecord, setIdCardRecord] = useState<API.CmsAppUser.AppUserRow | null>(null);
|
||||
const [idCardForm] = Form.useForm();
|
||||
|
||||
/** 修改密码 Modal 状态 */
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||
const [passwordRecord, setPasswordRecord] = useState<API.CmsAppUser.AppUserRow | null>(null);
|
||||
const [passwordForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
getDictValueEnum('education', true, true).then(setEducationEnum);
|
||||
getDictValueEnum('sys_user_sex', true).then(setSexEnum);
|
||||
getDictValueEnum('area', true, true).then(setAreaEnum);
|
||||
getDictValueEnum('political_affiliation', true, true).then(setPoliticalEnum);
|
||||
getDictValueEnum('nation', false, true).then(setNationEnum);
|
||||
}, []);
|
||||
|
||||
const columns: ProColumns<API.CmsAppUser.AppUserRow>[] = [
|
||||
@@ -86,6 +109,15 @@ function AppUserList() {
|
||||
valueEnum: politicalEnum,
|
||||
render: (_, record) => <DictTag enums={politicalEnum} value={record.politicalAffiliation} />,
|
||||
},
|
||||
{
|
||||
title: '民族',
|
||||
dataIndex: 'nation',
|
||||
valueType: 'select',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
valueEnum: nationEnum,
|
||||
render: (_, record) => <DictTag enums={nationEnum} value={record.nation ?? undefined} />,
|
||||
},
|
||||
{
|
||||
title: '期望薪资',
|
||||
dataIndex: 'salaryRange',
|
||||
@@ -135,6 +167,77 @@ function AppUserList() {
|
||||
hideInSearch: true,
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '简历状态',
|
||||
dataIndex: 'resumeStatus',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
fixed: 'right',
|
||||
hideInSearch: true,
|
||||
render: (_, record) => (
|
||||
<Switch
|
||||
checked={record.resumeStatus === '1'}
|
||||
checkedChildren="公开"
|
||||
unCheckedChildren="保密"
|
||||
onChange={async (checked) => {
|
||||
try {
|
||||
const newStatus = checked ? '1' : '2';
|
||||
await changeResumeStatus({
|
||||
userId: record.userId,
|
||||
resumeStatus: newStatus,
|
||||
});
|
||||
message.success(checked ? '简历已设置成公开!' : '简历已设置成保密!');
|
||||
actionRef.current?.reload();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
valueType: 'option',
|
||||
align: 'center',
|
||||
width: 240,
|
||||
fixed: 'right',
|
||||
hideInSearch: true,
|
||||
render: (_, record) => [
|
||||
<Button
|
||||
key="resume"
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => navigate(`/usermgmt/appuser/resume/${record.userId}`)}
|
||||
>
|
||||
简历
|
||||
</Button>,
|
||||
<Button
|
||||
key="idCard"
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setIdCardRecord(record);
|
||||
idCardForm.resetFields();
|
||||
setIdCardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
修改身份证
|
||||
</Button>,
|
||||
<Button
|
||||
key="password"
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setPasswordRecord(record);
|
||||
passwordForm.resetFields();
|
||||
setPasswordModalOpen(true);
|
||||
}}
|
||||
>
|
||||
修改密码
|
||||
</Button>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -156,6 +259,8 @@ function AppUserList() {
|
||||
pageNum: current,
|
||||
pageSize,
|
||||
...filteredParams,
|
||||
orderByColumn: 'createTime',
|
||||
isAsc: 'desc',
|
||||
}).then((res) => ({
|
||||
data: res.rows || [],
|
||||
total: res.total,
|
||||
@@ -167,6 +272,122 @@ function AppUserList() {
|
||||
pagination={{ defaultPageSize: 10, showSizeChanger: true }}
|
||||
headerTitle="个人用户管理"
|
||||
/>
|
||||
|
||||
{/* 修改身份证 Modal */}
|
||||
<Modal
|
||||
title="修改身份证号"
|
||||
open={idCardModalOpen}
|
||||
onCancel={() => setIdCardModalOpen(false)}
|
||||
onOk={() => idCardForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={idCardForm}
|
||||
layout="vertical"
|
||||
onFinish={async (values) => {
|
||||
try {
|
||||
await updateIdCard({
|
||||
userId: idCardRecord!.userId,
|
||||
idCard: values.idCard,
|
||||
});
|
||||
setIdCardModalOpen(false);
|
||||
actionRef.current?.reload();
|
||||
} catch {
|
||||
message.error('修改失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item label="用户姓名" style={{ marginBottom: 8 }}>
|
||||
<span>{idCardRecord?.name || '-'}</span>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="新身份证号"
|
||||
name="idCard"
|
||||
rules={[
|
||||
{ required: true, message: '请输入身份证号' },
|
||||
{
|
||||
pattern: ID_CARD_REGEX,
|
||||
message: '身份证号格式不正确',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入18位身份证号" maxLength={18} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 修改密码 Modal */}
|
||||
<Modal
|
||||
title="修改密码"
|
||||
open={passwordModalOpen}
|
||||
onCancel={() => setPasswordModalOpen(false)}
|
||||
onOk={() => passwordForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={passwordForm}
|
||||
layout="vertical"
|
||||
onFinish={async (values) => {
|
||||
try {
|
||||
await resetPassword({
|
||||
userId: passwordRecord!.userId,
|
||||
password: values.password,
|
||||
});
|
||||
message.success('密码修改成功');
|
||||
setPasswordModalOpen(false);
|
||||
} catch {
|
||||
message.error('密码修改失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item label="用户姓名" style={{ marginBottom: 8 }}>
|
||||
<span>{passwordRecord?.name || '-'}</span>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="新密码"
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 8, max: 20, message: '密码长度在 8 到 20 个字符' },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve();
|
||||
const hasUpper = /[A-Z]/.test(value);
|
||||
const hasLower = /[a-z]/.test(value);
|
||||
const hasDigit = /\d/.test(value);
|
||||
const specialChars = value.replace(/[a-zA-Z0-9]/g, '');
|
||||
const hasTwoSpecial = specialChars.length >= 2;
|
||||
if (!hasUpper) return Promise.reject(new Error('密码需包含大写字母'));
|
||||
if (!hasLower) return Promise.reject(new Error('密码需包含小写字母'));
|
||||
if (!hasDigit) return Promise.reject(new Error('密码需包含数字'));
|
||||
if (!hasTwoSpecial) return Promise.reject(new Error('密码需包含至少2个特殊字符'));
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请输入新密码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="确认密码"
|
||||
name="confirmPassword"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请再次输入新密码" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
18
src/pages/Usermgmt/Appuser/Resume/index.less
Normal file
18
src/pages/Usermgmt/Appuser/Resume/index.less
Normal file
@@ -0,0 +1,18 @@
|
||||
.appuser-resume-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding: 8px;
|
||||
background: #f5f5f5;
|
||||
|
||||
.resume-iframe-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
526
src/pages/Usermgmt/Appuser/Resume/index.tsx
Normal file
526
src/pages/Usermgmt/Appuser/Resume/index.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from '@umijs/max';
|
||||
import { Button, Card, message, Space, Switch, Typography } from 'antd';
|
||||
import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import Iframe from 'iframe-js';
|
||||
import { getAppUserResume } from '@/services/cms/appuser';
|
||||
import { getDictValueEnum } from '@/services/system/dict';
|
||||
import { request } from '@umijs/max';
|
||||
import './index.less';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/** 字典 code → 汉字 的映射表 */
|
||||
type DictMap = Record<string, string>;
|
||||
|
||||
/**
|
||||
* 将后端简历数据转换为简历生成器需要的格式(匹配 EMBED_INTEGRATION.md 第6节 schema)
|
||||
*/
|
||||
function toGeneratorFormat(data: API.CmsAppUser.ResumeData, dicts: { sex: DictMap; education: DictMap; area: DictMap; political: DictMap; nation: DictMap }): Record<string, any> {
|
||||
const sections: any[] = [];
|
||||
|
||||
// 教育经历
|
||||
const education = (data.educationsList || []).map((item) => ({
|
||||
id: String(item.id),
|
||||
school: item.schoolName || '',
|
||||
degree: item.degree || '',
|
||||
major: item.major || '',
|
||||
startDate: item.startTime || '',
|
||||
endDate: item.endTime || '',
|
||||
description: item.content || '',
|
||||
}));
|
||||
|
||||
// 工作经历
|
||||
const experience = (data.experiencesList || []).map((item) => ({
|
||||
id: String(item.id),
|
||||
company: item.companyName || '',
|
||||
position: item.position || '',
|
||||
startDate: item.startDate || '',
|
||||
endDate: item.endDate || '',
|
||||
description: item.description || '',
|
||||
}));
|
||||
|
||||
// 技能
|
||||
const skills = (data.appSkillsList || []).map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.name || '',
|
||||
level: item.levels || '',
|
||||
}));
|
||||
|
||||
// 培训经历(生成器原生 training 字段)
|
||||
const training = (data.trainsList || []).map((t) => ({
|
||||
id: String(t.id),
|
||||
institution: t.trainOrg || '',
|
||||
course: t.trainCourse || '',
|
||||
startDate: t.startTime || '',
|
||||
endDate: t.endTime || '',
|
||||
description: t.trainContent || '',
|
||||
}));
|
||||
|
||||
// sections 决定区块显示顺序和显隐
|
||||
sections.push({ id: 'basics', type: 'basics', title: '基本信息', hidden: false });
|
||||
if (education.length > 0) sections.push({ id: 'education', type: 'education', title: '教育经历', hidden: false });
|
||||
if (experience.length > 0) sections.push({ id: 'experience', type: 'experience', title: '工作经历', hidden: false });
|
||||
if (training.length > 0) sections.push({ id: 'training', type: 'training', title: '培训经历', hidden: false });
|
||||
if (skills.length > 0) sections.push({ id: 'skills', type: 'skills', title: '技能特长', hidden: false });
|
||||
|
||||
// code → 汉字映射
|
||||
const sexLabel = dicts.sex[data.sex] || '';
|
||||
const educationLabel = dicts.education[data.education] || '';
|
||||
const politicalLabel = dicts.political[data.politicalAffiliation] || '';
|
||||
const nationLabel = dicts.nation[data.nation] || data.nation || '';
|
||||
|
||||
// summary:展示性别、学历、工作年限等补充信息(生成器 schema 仅 name/title/phone/email/location/summary 等标准字段,自定义字段会被 Zod 校验丢弃)
|
||||
const summaryParts: string[] = [];
|
||||
if (sexLabel) summaryParts.push(sexLabel);
|
||||
if (educationLabel) summaryParts.push(educationLabel);
|
||||
if (data.workExperience) summaryParts.push(`${data.workExperience}年工作经验`);
|
||||
|
||||
const salaryStr = data.salaryMin || data.salaryMax
|
||||
? `${data.salaryMin || ''}${data.salaryMax ? '-' + data.salaryMax : ''}`
|
||||
: '';
|
||||
|
||||
return {
|
||||
basics: {
|
||||
name: data.name || '',
|
||||
phone: data.phone || '',
|
||||
email: data.email || '',
|
||||
avatarUrl: data.avatar || '',
|
||||
title: data.jobTitle?.[0] || '',
|
||||
ethnicity: nationLabel,
|
||||
birthDate: data.birthDate || '',
|
||||
age: data.age || '',
|
||||
politicalStatus: politicalLabel,
|
||||
expectedPosition: data.jobTitle?.[0] || '',
|
||||
expectedSalary: salaryStr,
|
||||
summary: summaryParts.join(' | '),
|
||||
// 以下为自定义扩展字段,生成器 Zod schema 会丢弃,但 save 时从 summary 回读
|
||||
_gender: sexLabel,
|
||||
_educationLevel: educationLabel,
|
||||
_yearsOfExperience: data.workExperience || '',
|
||||
},
|
||||
education,
|
||||
experience,
|
||||
training,
|
||||
projects: [],
|
||||
skills,
|
||||
certificates: [],
|
||||
customSections: [],
|
||||
settings: {
|
||||
templateId: 'modern',
|
||||
primaryColor: '#0f766e',
|
||||
pageSize: 'A4',
|
||||
language: 'zh',
|
||||
fontFamily: '',
|
||||
sections,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将简历生成器返回的数据转为后端保存格式(汉字 → code 反向映射)
|
||||
*/
|
||||
function fromGeneratorFormat(resume: any, dicts: { sex: DictMap; education: DictMap; area: DictMap; political: DictMap; nation: DictMap }): {
|
||||
appUser: Record<string, any>;
|
||||
appUserEducations: any[];
|
||||
appUserTrains: any[];
|
||||
experiencesList: any[];
|
||||
appSkillsList: any[];
|
||||
} {
|
||||
const basics = resume?.basics || {};
|
||||
|
||||
// 反向 lookup:汉字 → code
|
||||
const reverseLookup = (map: DictMap, text: string): string => {
|
||||
if (!text) return '';
|
||||
for (const [code, label] of Object.entries(map)) {
|
||||
if (label === text) return code;
|
||||
}
|
||||
return text; // 找不到则原样返回
|
||||
};
|
||||
|
||||
// 性别:优先从 _gender 读取,生成器丢弃后从 summary 回退
|
||||
const sexText = basics._gender || '';
|
||||
const sexFromSummary = !sexText ? ((basics.summary || '').includes('男') ? '男' : (basics.summary || '').includes('女') ? '女' : '') : '';
|
||||
const sexFinal = sexText || sexFromSummary;
|
||||
|
||||
// 学历:优先从 _educationLevel,回退到 summary 中的学历标签
|
||||
const educationText = basics._educationLevel || '';
|
||||
const politicalText = basics.politicalStatus || '';
|
||||
const nationText = basics.ethnicity || '';
|
||||
|
||||
// 工作年限:从 _yearsOfExperience 或 summary 中匹配 "X年工作经验"
|
||||
const yearsFromField = basics._yearsOfExperience || '';
|
||||
const yearsFromSummary = !yearsFromField ? ((basics.summary || '').match(/(\d+)年工作经验/)?.[1] || '') : '';
|
||||
const workExperienceFinal = yearsFromField || yearsFromSummary;
|
||||
|
||||
return {
|
||||
appUser: {
|
||||
name: basics.name || undefined,
|
||||
phone: basics.phone || undefined,
|
||||
email: basics.email || undefined,
|
||||
avatar: basics.avatarUrl || undefined,
|
||||
sex: reverseLookup(dicts.sex, sexFinal) || (sexFinal === '男' ? '0' : sexFinal === '女' ? '1' : '') || undefined,
|
||||
age: basics.age || undefined,
|
||||
birthDate: basics.birthDate || undefined,
|
||||
education: reverseLookup(dicts.education, educationText) || educationText || undefined,
|
||||
workExperience: workExperienceFinal || undefined,
|
||||
politicalAffiliation: reverseLookup(dicts.political, politicalText) || politicalText || undefined,
|
||||
nation: reverseLookup(dicts.nation, nationText) || nationText || undefined,
|
||||
address: basics.location || undefined,
|
||||
},
|
||||
appUserEducations: (resume?.education || []).map((e: any) => ({
|
||||
id: e.id ? Number(e.id) : undefined,
|
||||
schoolName: e.school || undefined,
|
||||
educationLevel: undefined,
|
||||
major: e.major || undefined,
|
||||
degree: e.degree || undefined,
|
||||
studyType: undefined,
|
||||
startTime: e.startDate || undefined,
|
||||
endTime: e.endDate || undefined,
|
||||
content: e.description || undefined,
|
||||
sort: 0,
|
||||
})),
|
||||
appUserTrains: (resume?.training || []).map((t: any) => ({
|
||||
id: t.id ? Number(t.id) : undefined,
|
||||
trainOrg: t.institution || undefined,
|
||||
trainCourse: t.course || undefined,
|
||||
startTime: t.startDate || undefined,
|
||||
endTime: t.endDate || undefined,
|
||||
trainCert: undefined,
|
||||
trainContent: t.description || undefined,
|
||||
sort: 0,
|
||||
})),
|
||||
experiencesList: (resume?.experience || []).map((exp: any) => ({
|
||||
id: exp.id ? Number(exp.id) : undefined,
|
||||
companyName: exp.company || undefined,
|
||||
position: exp.position || undefined,
|
||||
startDate: exp.startDate || undefined,
|
||||
endDate: exp.endDate || undefined,
|
||||
description: exp.description || undefined,
|
||||
})),
|
||||
appSkillsList: (resume?.skills || []).map((s: any) => ({
|
||||
id: s.id ? Number(s.id) : undefined,
|
||||
name: s.name,
|
||||
levels: s.level,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function AppUserResume() {
|
||||
console.log('===== AppUserResume 组件渲染 =====');
|
||||
const params = useParams<{ userId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const forceReadOnly = searchParams.get('readOnly') === '1';
|
||||
const userId = Number(params.userId);
|
||||
console.log('userId from params:', userId);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [readOnly, setReadOnly] = useState(forceReadOnly);
|
||||
const [resumeData, setResumeData] = useState<API.CmsAppUser.ResumeData | null>(null);
|
||||
const [generatorReady, setGeneratorReady] = useState(false);
|
||||
const hostRef = useRef<Iframe | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const resumeDataRef = useRef<API.CmsAppUser.ResumeData | null>(null);
|
||||
|
||||
// 字典数据(code → 汉字)
|
||||
const [sexDict, setSexDict] = useState<DictMap>({});
|
||||
const [educationDict, setEducationDict] = useState<DictMap>({});
|
||||
const [areaDict, setAreaDict] = useState<DictMap>({});
|
||||
const [politicalDict, setPoliticalDict] = useState<DictMap>({});
|
||||
const [nationDict, setNationDict] = useState<DictMap>({});
|
||||
|
||||
// 加载字典
|
||||
useEffect(() => {
|
||||
getDictValueEnum('sys_user_sex', true).then((res: any) => {
|
||||
const map: DictMap = {};
|
||||
if (res) Object.keys(res).forEach(k => { map[k] = res[k]?.text || ''; });
|
||||
setSexDict(map);
|
||||
});
|
||||
getDictValueEnum('education', true, true).then((res: any) => {
|
||||
const map: DictMap = {};
|
||||
if (res) Object.keys(res).forEach(k => { map[k] = res[k]?.text || ''; });
|
||||
setEducationDict(map);
|
||||
});
|
||||
getDictValueEnum('area', true, true).then((res: any) => {
|
||||
const map: DictMap = {};
|
||||
if (res) Object.keys(res).forEach(k => { map[k] = res[k]?.text || ''; });
|
||||
setAreaDict(map);
|
||||
});
|
||||
getDictValueEnum('political_affiliation', true, true).then((res: any) => {
|
||||
const map: DictMap = {};
|
||||
if (res) Object.keys(res).forEach(k => { map[k] = res[k]?.text || ''; });
|
||||
setPoliticalDict(map);
|
||||
});
|
||||
getDictValueEnum('nation', false, true).then((res: any) => {
|
||||
const map: DictMap = {};
|
||||
if (res) Object.keys(res).forEach(k => { map[k] = res[k]?.text || ''; });
|
||||
setNationDict(map);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const dicts = { sex: sexDict, education: educationDict, area: areaDict, political: politicalDict, nation: nationDict };
|
||||
const dictsRef = useRef(dicts);
|
||||
dictsRef.current = dicts;
|
||||
|
||||
// 加载完整简历数据
|
||||
useEffect(() => {
|
||||
if (!userId || isNaN(userId)) return;
|
||||
setLoading(true);
|
||||
getAppUserResume(userId)
|
||||
.then((res) => {
|
||||
console.log('简历API返回:', res);
|
||||
if (res?.code === 200) {
|
||||
if (res?.data) {
|
||||
setResumeData(res.data);
|
||||
resumeDataRef.current = res.data;
|
||||
} else {
|
||||
console.warn('简历data为空,该用户可能没有简历信息');
|
||||
resumeDataRef.current = null;
|
||||
}
|
||||
} else {
|
||||
message.error('获取简历数据失败: ' + (res?.msg || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('获取简历数据异常:', err);
|
||||
message.error('获取简历数据异常');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [userId]);
|
||||
|
||||
// 初始化 iframe-js 通信(独立于数据加载)
|
||||
const initIframe = useCallback(() => {
|
||||
if (!iframeRef.current || hostRef.current) return;
|
||||
|
||||
const generatorUrl = window.location.origin + '/resumegenerator';
|
||||
console.log('[Resume] 开始创建iframe通信,URL:', generatorUrl);
|
||||
|
||||
let host;
|
||||
try {
|
||||
host = new Iframe({
|
||||
container: iframeRef.current,
|
||||
url: generatorUrl,
|
||||
whiteList: [location.origin],
|
||||
timeout: 15000,
|
||||
});
|
||||
console.log('[Resume] Iframe实例创建成功');
|
||||
} catch (err) {
|
||||
console.error('[Resume] Iframe创建失败:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
hostRef.current = host;
|
||||
|
||||
// 暴露保存方法给生成器
|
||||
host.expose('resume:save', async ({ resume }: { resume: any }) => {
|
||||
console.log('[Resume] 收到保存请求:', resume);
|
||||
setSaving(true);
|
||||
try {
|
||||
const mapped = fromGeneratorFormat(resume, dictsRef.current);
|
||||
// 注入 userId
|
||||
mapped.appUser.userId = userId;
|
||||
mapped.appUserEducations.forEach((e: any) => (e.userId = userId));
|
||||
mapped.appUserTrains.forEach((t: any) => (t.userId = userId));
|
||||
mapped.experiencesList.forEach((exp: any) => (exp.userId = userId));
|
||||
mapped.appSkillsList.forEach((s: any) => (s.userId = userId));
|
||||
|
||||
console.log('[Resume] 映射后的保存数据:', mapped);
|
||||
|
||||
const result = await request<API.Result>('/api/cms/appUser/saveResume', {
|
||||
method: 'POST',
|
||||
data: mapped,
|
||||
});
|
||||
if (result.code === 200) {
|
||||
message.success('简历保存成功');
|
||||
// 返回用户列表
|
||||
setTimeout(() => navigate('/usermgmt/appuser/index', { replace: true }), 800);
|
||||
return { ok: true };
|
||||
}
|
||||
message.error(result.msg || '保存失败');
|
||||
return { ok: false };
|
||||
} catch (err) {
|
||||
console.error('[Resume] 保存异常:', err);
|
||||
message.error('保存异常');
|
||||
return { ok: false };
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
});
|
||||
|
||||
// 暴露头像上传方法给生成器
|
||||
host.expose('avatar:upload', async ({ file }: { file: any }) => {
|
||||
// 文件上传:由生成器将文件传给父页面,父页面调用后端上传
|
||||
// 这里使用通用的文件上传接口
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await fetch('/api/common/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code === 200 && result.url) {
|
||||
return { url: result.url };
|
||||
}
|
||||
// 如果通用上传不可用,尝试以base64返回
|
||||
message.warning('头像上传接口不可用,将使用本地预览');
|
||||
return { url: '' };
|
||||
} catch {
|
||||
message.error('头像上传失败');
|
||||
return { url: '' };
|
||||
}
|
||||
});
|
||||
|
||||
// 生成器就绪
|
||||
host.action('resume:ready', () => {
|
||||
console.log('[Resume] 简历生成器已就绪');
|
||||
setGeneratorReady(true);
|
||||
// 有数据就加载
|
||||
const data = resumeDataRef.current;
|
||||
if (data) {
|
||||
const genData = toGeneratorFormat(data, dictsRef.current);
|
||||
console.log('加载简历数据到生成器:', genData);
|
||||
host.callRemote('resume:load', { resume: genData }, 8000).catch((err: any) => {
|
||||
console.error('[Resume] resume:load 失败:', err);
|
||||
});
|
||||
}
|
||||
if (readOnly) {
|
||||
host.callRemote('resume:setReadOnly', { readOnly: true }, 8000).catch((err: any) => {
|
||||
console.error('[Resume] resume:setReadOnly 失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// 监听 iframe 内的下载/导出按钮点击,记录下载日志
|
||||
try {
|
||||
const iframeDoc = iframeRef.current?.contentDocument;
|
||||
if (iframeDoc) {
|
||||
let lastLogTime = 0;
|
||||
iframeDoc.addEventListener('click', (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const text = (target.textContent || '').trim();
|
||||
const isDownloadBtn =
|
||||
/^(导出|下载|打印|Export|Download|Print|PDF|导出PDF|下载PDF)$/i.test(text) ||
|
||||
/export|download|pdf|print/i.test(target.className || '') ||
|
||||
target.getAttribute('aria-label')?.match(/export|download|pdf|print|导出|下载|打印/i) ||
|
||||
target.closest('[class*="export"],[class*="download"],[aria-label*="导出"],[aria-label*="下载"],[aria-label*="PDF"]');
|
||||
if (isDownloadBtn) {
|
||||
const now = Date.now();
|
||||
if (now - lastLogTime < 3000) return; // 3秒内防抖
|
||||
lastLogTime = now;
|
||||
console.log('[Resume] 检测到下载操作:', text);
|
||||
request('/api/cms/resumeAccessLog/download', {
|
||||
method: 'POST',
|
||||
data: { userId },
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Resume] 无法监听iframe下载事件:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听简历变化
|
||||
host.action('resume:change', (event: any) => {
|
||||
console.log('简历内容已更新', event?.data?.resume);
|
||||
});
|
||||
}, [readOnly, userId]);
|
||||
|
||||
// 当 resumeData 变化且生成器已就绪时,推送新数据到生成器
|
||||
useEffect(() => {
|
||||
if (hostRef.current && generatorReady && resumeData) {
|
||||
const genData = toGeneratorFormat(resumeData, dictsRef.current);
|
||||
console.log('[Resume] userId变化,推送新简历数据:', genData);
|
||||
hostRef.current.callRemote('resume:load', { resume: genData }, 8000).catch((err: any) => {
|
||||
console.error('[Resume] resume:load 失败:', err);
|
||||
});
|
||||
}
|
||||
}, [resumeData, generatorReady]);
|
||||
|
||||
// 页面加载时初始化 iframe
|
||||
useEffect(() => {
|
||||
// 先清理旧实例
|
||||
if (hostRef.current) {
|
||||
try { hostRef.current.destroy(); } catch (e) {}
|
||||
hostRef.current = null;
|
||||
}
|
||||
console.log('[Resume] useEffect触发 initIframe');
|
||||
initIframe();
|
||||
return () => {
|
||||
if (hostRef.current) {
|
||||
try { hostRef.current.destroy(); } catch (e) {}
|
||||
hostRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []); // 只在 mount 时执行一次
|
||||
|
||||
// 切换只读模式
|
||||
const toggleReadOnly = async (checked: boolean) => {
|
||||
setReadOnly(checked);
|
||||
if (hostRef.current && generatorReady) {
|
||||
try {
|
||||
await hostRef.current.callRemote('resume:setReadOnly', { readOnly: checked }, 8000);
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 重新加载最新数据到生成器
|
||||
const reloadResumeData = () => {
|
||||
if (hostRef.current && generatorReady && resumeData) {
|
||||
const genData = toGeneratorFormat(resumeData, dictsRef.current);
|
||||
hostRef.current.callRemote('resume:load', { resume: genData }, 8000);
|
||||
message.success('已重新加载简历');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="appuser-resume-page">
|
||||
{/* 顶部工具栏 */}
|
||||
<Card size="small" style={{ marginBottom: 8 }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
返回列表
|
||||
</Button>
|
||||
<Text strong>
|
||||
{resumeData?.name || '加载中...'} 的简历
|
||||
</Text>
|
||||
</Space>
|
||||
<Space>
|
||||
{!forceReadOnly && (
|
||||
<span>
|
||||
<EyeOutlined /> 只读模式{' '}
|
||||
<Switch checked={readOnly} onChange={toggleReadOnly} />
|
||||
</span>
|
||||
)}
|
||||
{generatorReady && (
|
||||
<Button size="small" onClick={reloadResumeData}>
|
||||
重新加载
|
||||
</Button>
|
||||
)}
|
||||
{saving && <Text type="secondary">正在保存...</Text>}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 简历生成器 iframe 区域 */}
|
||||
{loading && <div style={{ textAlign: 'center', padding: 20 }}>正在加载简历数据...</div>}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
id="resume-generator"
|
||||
title="简历生成器"
|
||||
src="/resumegenerator"
|
||||
style={{ width: '100%', height: 'calc(100vh - 110px)', border: '1px solid #f0f0f0', borderRadius: 4 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppUserResume;
|
||||
43
src/services/cms/appUserEducation.ts
Normal file
43
src/services/cms/appUserEducation.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
/**
|
||||
* 根据用户ID查询教育经历列表
|
||||
* GET /cms/appUserEducation/getByUserId/{userId}
|
||||
*/
|
||||
export async function getEducationByUserId(userId: number) {
|
||||
return request<API.CmsAppUser.EducationResult>('/api/cms/appUserEducation/getByUserId/' + userId, {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增教育经历
|
||||
* POST /cms/appUserEducation
|
||||
*/
|
||||
export async function addEducation(data: API.CmsAppUser.EducationParams) {
|
||||
return request<API.Result>('/api/cms/appUserEducation', {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改教育经历
|
||||
* PUT /cms/appUserEducation
|
||||
*/
|
||||
export async function updateEducation(data: API.CmsAppUser.EducationParams & { id: number }) {
|
||||
return request<API.Result>('/api/cms/appUserEducation', {
|
||||
method: 'PUT',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除教育经历
|
||||
* DELETE /cms/appUserEducation/{id}
|
||||
*/
|
||||
export async function deleteEducation(id: number) {
|
||||
return request<API.Result>('/api/cms/appUserEducation/' + id, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
43
src/services/cms/appUserTrain.ts
Normal file
43
src/services/cms/appUserTrain.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
/**
|
||||
* 根据用户ID查询培训经历列表
|
||||
* GET /cms/appUserTrain/getByUserId/{userId}
|
||||
*/
|
||||
export async function getTrainByUserId(userId: number) {
|
||||
return request<API.CmsAppUser.TrainResult>('/api/cms/appUserTrain/getByUserId/' + userId, {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增培训经历
|
||||
* POST /cms/appUserTrain
|
||||
*/
|
||||
export async function addTrain(data: API.CmsAppUser.TrainParams) {
|
||||
return request<API.Result>('/api/cms/appUserTrain', {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改培训经历
|
||||
* PUT /cms/appUserTrain
|
||||
*/
|
||||
export async function updateTrain(data: API.CmsAppUser.TrainParams & { id: number }) {
|
||||
return request<API.Result>('/api/cms/appUserTrain', {
|
||||
method: 'PUT',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除培训经历
|
||||
* DELETE /cms/appUserTrain/{id}
|
||||
*/
|
||||
export async function deleteTrain(id: number) {
|
||||
return request<API.Result>('/api/cms/appUserTrain/' + id, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -15,3 +15,49 @@ export async function getAppUserList(
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户完整简历信息(个人信息+教育+培训+工作经历+技能)
|
||||
* GET /cms/appUser/resume/{userId}
|
||||
*/
|
||||
export async function getAppUserResume(userId: number) {
|
||||
return request<API.CmsAppUser.ResumeResult>(`/api/cms/appUser/resume/${userId}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改用户身份证号
|
||||
* POST /cms/appUser/updateIdCard
|
||||
*/
|
||||
export async function updateIdCard(data: { userId: number; idCard: string }) {
|
||||
return request('/api/cms/appUser/updateIdCard', {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改简历公开/保密状态
|
||||
* POST /cms/appUser/changeResumeStatus
|
||||
*/
|
||||
export async function changeResumeStatus(data: {
|
||||
userId: number;
|
||||
resumeStatus: string;
|
||||
}) {
|
||||
return request('/api/cms/appUser/changeResumeStatus', {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置用户密码
|
||||
* POST /cms/appUser/resetPassword
|
||||
*/
|
||||
export async function resetPassword(data: { userId: number; password: string }) {
|
||||
return request('/api/cms/appUser/resetPassword', {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
12
src/services/cms/resumeAccessLog.ts
Normal file
12
src/services/cms/resumeAccessLog.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
/**
|
||||
* 简历访问记录列表
|
||||
* GET /cms/resumeAccessLog/list
|
||||
*/
|
||||
export async function getResumeAccessLogList(params?: Record<string, any>) {
|
||||
return request<API.Result>('/api/cms/resumeAccessLog/list', {
|
||||
method: 'GET',
|
||||
params,
|
||||
});
|
||||
}
|
||||
44
src/services/cms/userWorkExperiences.ts
Normal file
44
src/services/cms/userWorkExperiences.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
/**
|
||||
* 根据条件查询工作经历列表
|
||||
* GET /cms/userworkexperiences/list
|
||||
*/
|
||||
export async function getWorkExperiencesByUserId(userId: number) {
|
||||
return request<API.CmsAppUser.WorkExperienceResult>('/api/cms/userworkexperiences/list', {
|
||||
method: 'GET',
|
||||
params: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增工作经历
|
||||
* POST /cms/userworkexperiences
|
||||
*/
|
||||
export async function addWorkExperience(data: API.CmsAppUser.WorkExperienceParams) {
|
||||
return request<API.Result>('/api/cms/userworkexperiences', {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改工作经历
|
||||
* PUT /cms/userworkexperiences
|
||||
*/
|
||||
export async function updateWorkExperience(data: API.CmsAppUser.WorkExperienceParams & { id: number }) {
|
||||
return request<API.Result>('/api/cms/userworkexperiences', {
|
||||
method: 'PUT',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除工作经历
|
||||
* DELETE /cms/userworkexperiences/{id}
|
||||
*/
|
||||
export async function deleteWorkExperience(id: number) {
|
||||
return request<API.Result>('/api/cms/userworkexperiences/' + id, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
1
src/types/Management/interview.d.ts
vendored
1
src/types/Management/interview.d.ts
vendored
@@ -9,6 +9,7 @@ declare namespace API.Management {
|
||||
applyId?: number;
|
||||
interviewTime?: string;
|
||||
interviewMethod?: string;
|
||||
meetingType?: string;
|
||||
meetingLink?: string;
|
||||
meetingPassword?: string;
|
||||
interviewLocation?: string;
|
||||
|
||||
144
src/types/cms/appuser.d.ts
vendored
144
src/types/cms/appuser.d.ts
vendored
@@ -64,5 +64,149 @@ declare namespace API.CmsAppUser {
|
||||
status?: string;
|
||||
politicalAffiliation?: string;
|
||||
idCard?: string;
|
||||
orderByColumn?: string;
|
||||
isAsc?: string;
|
||||
}
|
||||
|
||||
/** 教育经历 */
|
||||
export interface EducationItem {
|
||||
id: number;
|
||||
userId: number;
|
||||
schoolName: string;
|
||||
educationLevel: number;
|
||||
major: string;
|
||||
degree: string;
|
||||
studyType: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
content: string;
|
||||
sort: number;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export interface EducationParams {
|
||||
userId: number;
|
||||
schoolName?: string;
|
||||
educationLevel?: number;
|
||||
major?: string;
|
||||
degree?: string;
|
||||
studyType?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
content?: string;
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
export interface EducationResult {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: EducationItem[];
|
||||
}
|
||||
|
||||
/** 培训经历 */
|
||||
export interface TrainItem {
|
||||
id: number;
|
||||
userId: number;
|
||||
trainOrg: string;
|
||||
trainCourse: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
trainCert: string;
|
||||
trainContent: string;
|
||||
sort: number;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export interface TrainParams {
|
||||
userId: number;
|
||||
trainOrg?: string;
|
||||
trainCourse?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
trainCert?: string;
|
||||
trainContent?: string;
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
export interface TrainResult {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: TrainItem[];
|
||||
}
|
||||
|
||||
/** 工作经历 */
|
||||
export interface WorkExperienceItem {
|
||||
id: number;
|
||||
userId: number;
|
||||
companyName: string;
|
||||
position: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
description: string;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export interface WorkExperienceParams {
|
||||
userId: number;
|
||||
companyName?: string;
|
||||
position?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface WorkExperienceResult {
|
||||
code: number;
|
||||
msg: string;
|
||||
rows: WorkExperienceItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 技能 */
|
||||
export interface SkillItem {
|
||||
id: number;
|
||||
userId: number;
|
||||
name: string;
|
||||
levels: string;
|
||||
}
|
||||
|
||||
/** 完整简历 */
|
||||
export interface ResumeData {
|
||||
userId: number;
|
||||
name: string;
|
||||
age: string;
|
||||
sex: string;
|
||||
birthDate: string;
|
||||
education: string;
|
||||
politicalAffiliation: string;
|
||||
phone: string;
|
||||
avatar: string;
|
||||
salaryMin: string;
|
||||
salaryMax: string;
|
||||
area: string;
|
||||
status: string;
|
||||
email: string;
|
||||
idCard: string;
|
||||
nation: string;
|
||||
workExperience: string;
|
||||
address: string;
|
||||
domicileAddress: string;
|
||||
userType: string;
|
||||
jobTitle: string[];
|
||||
experiencesList: WorkExperienceItem[];
|
||||
appSkillsList: SkillItem[];
|
||||
educationsList: EducationItem[];
|
||||
trainsList: TrainItem[];
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export interface ResumeResult {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: ResumeData;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user