Files
shz-admin/src/pages/Usermgmt/Appuser/Resume/index.tsx
francis-fh 279011e652
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
客户端集成简历生成器
2026-07-24 19:28:10 +08:00

642 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useModel, 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 : ''}`
: '';
// 将生成器不支持的标准字段放入 customSections确保用户可见
const supplementaryItems: any[] = [];
let itemIndex = 0;
if (nationLabel) supplementaryItems.push({ id: `sup_${itemIndex++}`, label: '民族', value: nationLabel });
if (data.birthDate) supplementaryItems.push({ id: `sup_${itemIndex++}`, label: '出生日期', value: data.birthDate });
if (data.age) supplementaryItems.push({ id: `sup_${itemIndex++}`, label: '年龄', value: data.age });
if (politicalLabel) supplementaryItems.push({ id: `sup_${itemIndex++}`, label: '政治面貌', value: politicalLabel });
if (salaryStr) supplementaryItems.push({ id: `sup_${itemIndex++}`, label: '期望薪资', value: salaryStr });
if (data.area) supplementaryItems.push({ id: `sup_${itemIndex++}`, label: '所在区域', value: dicts.area[data.area] || data.area });
const customSections: any[] = [];
if (supplementaryItems.length > 0) {
customSections.push({ id: 'supplementary', title: '补充信息', items: supplementaryItems });
sections.push({ id: 'supplementary', type: 'customSection', title: '补充信息', hidden: false });
}
return {
basics: {
name: data.name || '',
phone: data.phone || '',
email: data.email || '',
avatarUrl: data.avatar || '',
title: data.jobTitle?.[0] || '',
location: data.area ? (dicts.area[data.area] || data.area) : '',
summary: summaryParts.join(' | '),
// 以下为自定义扩展字段,生成器 Zod schema 会丢弃,但 save 时从 summary 回读
_gender: sexLabel,
_educationLevel: educationLabel,
_yearsOfExperience: data.workExperience || '',
_politicalStatus: politicalLabel,
_ethnicity: nationLabel,
_age: data.age || '',
_birthDate: data.birthDate || '',
_expectedSalary: salaryStr,
},
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 || {};
// 从 customSections 中按 label 查找值(作为 Zod 丢弃字段后的回退方案)
const getCustomSectionValue = (label: string): string => {
const supSection = (resume?.customSections || []).find((s: any) => s.id === 'supplementary');
if (!supSection?.items) return '';
const item = supSection.items.find((i: any) => i.label === label);
return item?.value || '';
};
// 反向 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 || getCustomSectionValue('政治面貌') || '';
const nationText = basics._ethnicity || getCustomSectionValue('民族') || '';
const ageText = basics._age || getCustomSectionValue('年龄') || '';
const birthDateText = basics._birthDate || getCustomSectionValue('出生日期') || '';
const salaryText = basics._expectedSalary || getCustomSectionValue('期望薪资') || '';
// 工作年限:从 _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: ageText || undefined,
birthDate: birthDateText || 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,
salaryMin: salaryText ? salaryText.split('-')[0] : undefined,
salaryMax: salaryText ? salaryText.split('-')[1] : 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);
// 行为上报:记录浏览时长和浏览次数
const { initialState } = useModel('@@initialState');
const enterTimeRef = useRef<number>(0);
const reportedRef = useRef<boolean>(false);
const actorIdRef = useRef<string | undefined>('');
const targetIdRef = useRef<number>(0);
actorIdRef.current = initialState?.currentUser?.userId;
targetIdRef.current = userId;
// 页面进入/离开时上报浏览行为
useEffect(() => {
enterTimeRef.current = Date.now();
reportedRef.current = false;
const doReport = (durationSeconds: number) => {
if (reportedRef.current) return;
reportedRef.current = true;
const actorId = actorIdRef.current;
const targetId = targetIdRef.current;
if (!actorId || !targetId || durationSeconds <= 0) return;
const payload = {
actorType: '2',
actorId: String(actorId),
targetType: '1',
targetId: String(targetId),
viewDuration: String(durationSeconds),
};
fetch('/api/cms/behavior/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
}).catch(() => {});
};
const calcDuration = () =>
Math.max(1, Math.floor((Date.now() - enterTimeRef.current) / 1000));
const handleBeforeUnload = () => {
doReport(calcDuration());
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
doReport(calcDuration());
};
}, []);
// 字典数据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;
setResumeData(null);
message.info('该用户暂无简历信息,生成器将显示空白模板');
}
} else {
message.error('获取简历数据失败: ' + (res?.msg || '未知错误'));
}
})
.catch((err) => {
console.error('获取简历数据异常:', err);
message.error('获取简历数据异常');
})
.finally(() => setLoading(false));
}, [userId]);
// 初始化 iframe-js 通信(通过 ref callback 在 DOM 插入时同步执行,确保监听器在 iframe 加载前就位)
const initHost = useCallback((el: HTMLIFrameElement) => {
if (!el || hostRef.current) return;
iframeRef.current = el;
const generatorUrl = window.location.origin + '/resumegenerator';
console.log('[Resume] 开始创建iframe通信URL:', generatorUrl);
let host;
try {
host = new Iframe({
container: el,
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: '' };
}
});
// 超时保护15秒内未收到 ready 则报错
const readyTimeout = setTimeout(() => {
if (!hostRef.current) return;
console.error('[Resume] 简历生成器加载超时15秒');
message.error('简历生成器加载失败,请检查网络连接后刷新页面重试');
setGeneratorReady(false);
}, 15000);
// 生成器就绪
let readyFired = false;
host.action('resume:ready', () => {
if (readyFired) return; // 防止重复触发
readyFired = true;
clearTimeout(readyTimeout);
console.log('[Resume] 简历生成器已就绪');
setGeneratorReady(true);
// 推送数据到生成器(如果数据已加载)
const tryLoadData = (attempts: number = 0) => {
const data = resumeDataRef.current;
if (data) {
const genData = toGeneratorFormat(data, dictsRef.current);
console.log('[Resume] 加载简历数据到生成器:', genData);
host.callRemote('resume:load', { resume: genData }, 8000).catch((err: any) => {
console.error('[Resume] resume:load 失败:', err);
message.warning('简历数据加载失败,请点击"重新加载"按钮重试');
});
} else if (attempts < 20) {
// 数据还没加载完,轮询等待(最多 10 秒,每 500ms 一次)
setTimeout(() => tryLoadData(attempts + 1), 500);
} else {
console.warn('[Resume] 等待简历数据超时10秒生成器将显示空模板');
}
};
tryLoadData();
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 通过 ref 获取最新值
// 当 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-js 实例
useEffect(() => {
return () => {
if (hostRef.current) {
try { hostRef.current.destroy(); } catch (e) {}
hostRef.current = null;
}
};
}, []);
// 切换只读模式
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 区域 */}
<div style={{ position: 'relative', width: '100%', height: 'calc(100vh - 110px)' }}>
{loading && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff', zIndex: 1 }}>
...
</div>
)}
{!generatorReady && !loading && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff', zIndex: 1, color: '#999' }}>
...
</div>
)}
<iframe
ref={initHost}
id="resume-generator"
title="简历生成器"
src="/resumegenerator"
style={{ width: '100%', height: '100%', border: '1px solid #f0f0f0', borderRadius: 4 }}
/>
</div>
</div>
);
}
export default AppUserResume;