Files
shz-admin/src/pages/Usermgmt/Appuser/Resume/index.tsx

642 lines
24 KiB
TypeScript
Raw Normal View History

import React, { useCallback, useEffect, useRef, useState } from 'react';
2026-07-24 19:28:10 +08:00
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 : ''}`
: '';
2026-07-23 12:30:28 +08:00
// 将生成器不支持的标准字段放入 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] || '',
2026-07-23 12:30:28 +08:00
location: data.area ? (dicts.area[data.area] || data.area) : '',
summary: summaryParts.join(' | '),
// 以下为自定义扩展字段,生成器 Zod schema 会丢弃,但 save 时从 summary 回读
_gender: sexLabel,
_educationLevel: educationLabel,
_yearsOfExperience: data.workExperience || '',
2026-07-23 12:30:28 +08:00
_politicalStatus: politicalLabel,
_ethnicity: nationLabel,
_age: data.age || '',
_birthDate: data.birthDate || '',
_expectedSalary: salaryStr,
},
education,
experience,
training,
projects: [],
skills,
certificates: [],
2026-07-23 12:30:28 +08:00
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 || {};
2026-07-23 12:30:28 +08:00
// 从 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 || '';
2026-07-23 12:30:28 +08:00
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,
2026-07-23 12:30:28 +08:00
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,
2026-07-23 12:30:28 +08:00
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);
2026-07-24 19:28:10 +08:00
// 行为上报:记录浏览时长和浏览次数
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;
2026-07-23 12:30:28 +08:00
setResumeData(null);
message.info('该用户暂无简历信息,生成器将显示空白模板');
}
} else {
message.error('获取简历数据失败: ' + (res?.msg || '未知错误'));
}
})
.catch((err) => {
console.error('获取简历数据异常:', err);
message.error('获取简历数据异常');
})
.finally(() => setLoading(false));
}, [userId]);
2026-07-23 12:30:28 +08:00
// 初始化 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({
2026-07-23 12:30:28 +08:00
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: '' };
}
});
2026-07-23 12:30:28 +08:00
// 超时保护15秒内未收到 ready 则报错
const readyTimeout = setTimeout(() => {
if (!hostRef.current) return;
console.error('[Resume] 简历生成器加载超时15秒');
message.error('简历生成器加载失败,请检查网络连接后刷新页面重试');
setGeneratorReady(false);
}, 15000);
// 生成器就绪
2026-07-23 12:30:28 +08:00
let readyFired = false;
host.action('resume:ready', () => {
2026-07-23 12:30:28 +08:00
if (readyFired) return; // 防止重复触发
readyFired = true;
clearTimeout(readyTimeout);
console.log('[Resume] 简历生成器已就绪');
setGeneratorReady(true);
2026-07-23 12:30:28 +08:00
// 推送数据到生成器(如果数据已加载)
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);
});
2026-07-23 12:30:28 +08:00
}, []); // 仅在首次挂载时初始化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]);
2026-07-23 12:30:28 +08:00
// 页面卸载时清理 iframe-js 实例
useEffect(() => {
return () => {
if (hostRef.current) {
try { hostRef.current.destroy(); } catch (e) {}
hostRef.current = null;
}
};
2026-07-23 12:30:28 +08:00
}, []);
// 切换只读模式
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 区域 */}
2026-07-23 12:30:28 +08:00
<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;