372 lines
17 KiB
TypeScript
372 lines
17 KiB
TypeScript
|
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|||
|
|
import { Input, Button, Select, message, ConfigProvider } from 'antd';
|
|||
|
|
import zhCN from 'antd/locale/zh_CN';
|
|||
|
|
import {
|
|||
|
|
PlusOutlined,
|
|||
|
|
DeleteOutlined,
|
|||
|
|
UserOutlined,
|
|||
|
|
BankOutlined,
|
|||
|
|
ReadOutlined,
|
|||
|
|
FileSearchOutlined,
|
|||
|
|
ArrowLeftOutlined,
|
|||
|
|
LoadingOutlined,
|
|||
|
|
CheckCircleFilled,
|
|||
|
|
ClockCircleOutlined,
|
|||
|
|
RocketOutlined,
|
|||
|
|
} from '@ant-design/icons';
|
|||
|
|
import { useNavigate, useSearchParams, request } from '@umijs/max';
|
|||
|
|
import Navbar from '../../Home/components/Navbar';
|
|||
|
|
import Footer from '../../Home/components/Footer';
|
|||
|
|
import styles from './index.less';
|
|||
|
|
|
|||
|
|
const { TextArea } = Input;
|
|||
|
|
|
|||
|
|
interface WorkExp {
|
|||
|
|
company: string;
|
|||
|
|
position: string;
|
|||
|
|
startDate: string;
|
|||
|
|
endDate: string;
|
|||
|
|
description: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Education {
|
|||
|
|
school: string;
|
|||
|
|
major: string;
|
|||
|
|
degree: string;
|
|||
|
|
startDate: string;
|
|||
|
|
endDate: string;
|
|||
|
|
description: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const PROGRESS_STEPS = [
|
|||
|
|
{ label: '提交表单数据', duration: 2000 },
|
|||
|
|
{ label: 'AI 正在分析岗位要求', duration: 6000 },
|
|||
|
|
{ label: 'AI 正在生成简历内容', duration: 12000 },
|
|||
|
|
{ label: '正在填充 Word 模板', duration: 8000 },
|
|||
|
|
{ label: '正在上传文件', duration: 3000 },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const degreeOptions = [
|
|||
|
|
{ value: '博士', label: '博士' },
|
|||
|
|
{ value: '硕士', label: '硕士' },
|
|||
|
|
{ value: '本科', label: '本科' },
|
|||
|
|
{ value: '大专', label: '大专' },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const emptyWork = (): WorkExp => ({ company: '', position: '', startDate: '', endDate: '', description: '' });
|
|||
|
|
const emptyEdu = (): Education => ({ school: '', major: '', degree: '', startDate: '', endDate: '', description: '' });
|
|||
|
|
|
|||
|
|
const ResumeCreate: React.FC = () => {
|
|||
|
|
const navigate = useNavigate();
|
|||
|
|
const [searchParams] = useSearchParams();
|
|||
|
|
const templateId = searchParams.get('templateId') || '1';
|
|||
|
|
const templateTitle = searchParams.get('templateTitle') || '';
|
|||
|
|
|
|||
|
|
// Form fields
|
|||
|
|
const [resumeName, setResumeName] = useState('');
|
|||
|
|
const [jobIntention, setJobIntention] = useState('');
|
|||
|
|
const [expectedSalary, setExpectedSalary] = useState('');
|
|||
|
|
const [preferredCity, setPreferredCity] = useState('');
|
|||
|
|
const [language, setLanguage] = useState('中文');
|
|||
|
|
const [jobDescription, setJobDescription] = useState('');
|
|||
|
|
const [workList, setWorkList] = useState<WorkExp[]>([emptyWork()]);
|
|||
|
|
const [eduList, setEduList] = useState<Education[]>([emptyEdu()]);
|
|||
|
|
|
|||
|
|
// Generation state
|
|||
|
|
const [generating, setGenerating] = useState(false);
|
|||
|
|
const [progressStep, setProgressStep] = useState(0);
|
|||
|
|
const timerRef = useRef<any>(null);
|
|||
|
|
|
|||
|
|
// Cleanup timers on unmount
|
|||
|
|
useEffect(() => {
|
|||
|
|
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
// Work experience helpers
|
|||
|
|
const addWork = () => setWorkList([...workList, emptyWork()]);
|
|||
|
|
const removeWork = (i: number) => setWorkList(workList.filter((_, idx) => idx !== i));
|
|||
|
|
const updateWork = (i: number, field: keyof WorkExp, val: string) => {
|
|||
|
|
const list = [...workList];
|
|||
|
|
list[i] = { ...list[i], [field]: val };
|
|||
|
|
setWorkList(list);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Education helpers
|
|||
|
|
const addEdu = () => setEduList([...eduList, emptyEdu()]);
|
|||
|
|
const removeEdu = (i: number) => setEduList(eduList.filter((_, idx) => idx !== i));
|
|||
|
|
const updateEdu = (i: number, field: keyof Education, val: string) => {
|
|||
|
|
const list = [...eduList];
|
|||
|
|
list[i] = { ...list[i], [field]: val };
|
|||
|
|
setEduList(list);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Animate progress steps
|
|||
|
|
const animateProgress = useCallback(() => {
|
|||
|
|
let step = 0;
|
|||
|
|
setProgressStep(0);
|
|||
|
|
const next = () => {
|
|||
|
|
if (step < PROGRESS_STEPS.length - 1) {
|
|||
|
|
timerRef.current = setTimeout(() => {
|
|||
|
|
step++;
|
|||
|
|
setProgressStep(step);
|
|||
|
|
next();
|
|||
|
|
}, PROGRESS_STEPS[step].duration);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
next();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
// Generate resume
|
|||
|
|
const handleGenerate = async () => {
|
|||
|
|
if (!resumeName.trim()) { message.warning('请输入简历名称'); return; }
|
|||
|
|
if (!jobIntention.trim()) { message.warning('请输入求职意向'); return; }
|
|||
|
|
if (!jobDescription.trim()) { message.warning('请粘贴岗位描述/JD'); return; }
|
|||
|
|
|
|||
|
|
setGenerating(true);
|
|||
|
|
animateProgress();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const payload: any = {
|
|||
|
|
resumeName: resumeName.trim(),
|
|||
|
|
jobIntention: jobIntention.trim(),
|
|||
|
|
jobDescription: jobDescription.trim(),
|
|||
|
|
templateId,
|
|||
|
|
};
|
|||
|
|
if (expectedSalary.trim()) payload.expectedSalary = expectedSalary.trim();
|
|||
|
|
if (preferredCity.trim()) payload.preferredCity = preferredCity.trim();
|
|||
|
|
if (language) payload.language = language;
|
|||
|
|
|
|||
|
|
const validWork = workList.filter(w => w.company.trim() || w.position.trim());
|
|||
|
|
if (validWork.length) payload.workExperience = validWork;
|
|||
|
|
|
|||
|
|
const validEdu = eduList.filter(e => e.school.trim() || e.major.trim());
|
|||
|
|
if (validEdu.length) payload.education = validEdu;
|
|||
|
|
|
|||
|
|
const res = await request('/api/resume/student/ai/generate', {
|
|||
|
|
method: 'POST',
|
|||
|
|
data: payload,
|
|||
|
|
timeout: 120000,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (res.code === 0 && res.data?.url) {
|
|||
|
|
setProgressStep(PROGRESS_STEPS.length - 1);
|
|||
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|||
|
|
message.success('简历生成成功');
|
|||
|
|
// 跳转到预览页
|
|||
|
|
navigate(`/resume/preview?url=${encodeURIComponent(res.data.url)}&name=${encodeURIComponent(resumeName.trim())}`);
|
|||
|
|
} else {
|
|||
|
|
message.error(res.message || '生成失败,请重试');
|
|||
|
|
}
|
|||
|
|
} catch (err: any) {
|
|||
|
|
message.error(err?.message || '请求超时或网络异常,请重试');
|
|||
|
|
} finally {
|
|||
|
|
setGenerating(false);
|
|||
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleCancel = () => navigate(-1);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<ConfigProvider locale={zhCN}>
|
|||
|
|
<div className={styles.createPage}>
|
|||
|
|
<div className={styles.navbarWrap}><Navbar solidBg /></div>
|
|||
|
|
|
|||
|
|
<div className={styles.contentArea}>
|
|||
|
|
{/* Top bar */}
|
|||
|
|
<div className={styles.topBar}>
|
|||
|
|
<span className={styles.backLink} onClick={handleCancel}>
|
|||
|
|
<ArrowLeftOutlined /> 返回模板列表
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Header */}
|
|||
|
|
<div className={styles.headerCard}>
|
|||
|
|
<div className={styles.headerCardInner}>
|
|||
|
|
<h1 className={styles.pageTitle}>AI 智能生成简历</h1>
|
|||
|
|
{templateTitle && <span className={styles.templateBadge}>模板:{decodeURIComponent(templateTitle)}</span>}
|
|||
|
|
</div>
|
|||
|
|
<p className={styles.headerDesc}>填写以下信息,AI 将根据岗位要求为你量身定制简历</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Loading overlay */}
|
|||
|
|
{generating && (
|
|||
|
|
<div className={styles.loadingOverlay}>
|
|||
|
|
<div className={styles.loadingCard}>
|
|||
|
|
<div className={styles.loadingSpinner}><LoadingOutlined /></div>
|
|||
|
|
<h2 className={styles.loadingTitle}>AI 正在为你生成简历</h2>
|
|||
|
|
<p className={styles.loadingHint}>预计需要 10~30 秒,请耐心等待</p>
|
|||
|
|
<div className={styles.progressSteps}>
|
|||
|
|
{PROGRESS_STEPS.map((s, i) => (
|
|||
|
|
<div key={i} className={`${styles.stepItem} ${i < progressStep ? styles.stepDone : ''} ${i === progressStep ? styles.stepActive : ''}`}>
|
|||
|
|
<span className={styles.stepDot}>
|
|||
|
|
{i < progressStep ? <CheckCircleFilled /> : i === progressStep ? <LoadingOutlined /> : <ClockCircleOutlined />}
|
|||
|
|
</span>
|
|||
|
|
<span className={styles.stepLabel}>{s.label}</span>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Section 1: Basic Info */}
|
|||
|
|
<div className={styles.section}>
|
|||
|
|
<div className={styles.sectionHeader}>
|
|||
|
|
<div className={styles.sectionIcon}><UserOutlined /></div>
|
|||
|
|
<span className={styles.sectionTitle}>基本信息</span>
|
|||
|
|
<span className={styles.sectionHint}>带 * 为必填项</span>
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formGrid4}>
|
|||
|
|
<div className={styles.formItem}>
|
|||
|
|
<label className={styles.label}><span className={styles.required}>*</span> 简历名称</label>
|
|||
|
|
<Input className={styles.input} placeholder="如:Java开发工程师简历" value={resumeName} onChange={e => setResumeName(e.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formItem}>
|
|||
|
|
<label className={styles.label}><span className={styles.required}>*</span> 求职意向</label>
|
|||
|
|
<Input className={styles.input} placeholder="如:Java开发工程师" value={jobIntention} onChange={e => setJobIntention(e.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formItem}>
|
|||
|
|
<label className={styles.label}>期望薪资</label>
|
|||
|
|
<Input className={styles.input} placeholder="如:15-20K" value={expectedSalary} onChange={e => setExpectedSalary(e.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formItem}>
|
|||
|
|
<label className={styles.label}>意向城市</label>
|
|||
|
|
<Input className={styles.input} placeholder="如:上海" value={preferredCity} onChange={e => setPreferredCity(e.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formRowSingle}>
|
|||
|
|
<div className={styles.formItem}>
|
|||
|
|
<label className={styles.label}>语种</label>
|
|||
|
|
<Select className={styles.select} value={language} onChange={setLanguage}
|
|||
|
|
popupClassName={styles.selectDropdown}
|
|||
|
|
options={[{ value: '中文', label: '中文' }, { value: '英文', label: '英文' }]} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Section 2: Work Experience */}
|
|||
|
|
<div className={styles.section}>
|
|||
|
|
<div className={styles.sectionHeader}>
|
|||
|
|
<div className={styles.sectionIcon}><BankOutlined /></div>
|
|||
|
|
<span className={styles.sectionTitle}>工作/实习经历</span>
|
|||
|
|
<span className={styles.sectionHint}>选填,可添加多段经历</span>
|
|||
|
|
</div>
|
|||
|
|
{workList.map((w, i) => (
|
|||
|
|
<div key={i} className={styles.expCard}>
|
|||
|
|
<div className={styles.expCardHeader}>
|
|||
|
|
<span className={styles.expBadge}>经历 {i + 1}</span>
|
|||
|
|
{workList.length > 1 && (
|
|||
|
|
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeWork(i)}>删除</Button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formGrid4}>
|
|||
|
|
<div className={styles.formItem}>
|
|||
|
|
<label className={styles.label}>公司名称</label>
|
|||
|
|
<Input className={styles.input} placeholder="公司名称" value={w.company} onChange={e => updateWork(i, 'company', e.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formItem}>
|
|||
|
|
<label className={styles.label}>职位</label>
|
|||
|
|
<Input className={styles.input} placeholder="职位名称" value={w.position} onChange={e => updateWork(i, 'position', e.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formItem}>
|
|||
|
|
<label className={styles.label}>开始时间</label>
|
|||
|
|
<Input className={styles.input} placeholder="如:2022.07" value={w.startDate} onChange={e => updateWork(i, 'startDate', e.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formItem}>
|
|||
|
|
<label className={styles.label}>结束时间</label>
|
|||
|
|
<Input className={styles.input} placeholder="如:至今" value={w.endDate} onChange={e => updateWork(i, 'endDate', e.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formItemFull}>
|
|||
|
|
<label className={styles.label}>工作描述</label>
|
|||
|
|
<TextArea className={styles.textarea} rows={3} placeholder="简要描述工作内容" value={w.description} onChange={e => updateWork(i, 'description', e.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
<div className={styles.addBtnWrap}>
|
|||
|
|
<Button className={styles.addBtn} icon={<PlusOutlined />} onClick={addWork}>添加经历</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Section 3: Education */}
|
|||
|
|
<div className={styles.section}>
|
|||
|
|
<div className={styles.sectionHeader}>
|
|||
|
|
<div className={styles.sectionIcon}><ReadOutlined /></div>
|
|||
|
|
<span className={styles.sectionTitle}>教育背景</span>
|
|||
|
|
<span className={styles.sectionHint}>选填,可添加多段教育经历</span>
|
|||
|
|
</div>
|
|||
|
|
{eduList.map((e, i) => (
|
|||
|
|
<div key={i} className={styles.expCard}>
|
|||
|
|
<div className={styles.expCardHeader}>
|
|||
|
|
<span className={styles.expBadge}>教育 {i + 1}</span>
|
|||
|
|
{eduList.length > 1 && (
|
|||
|
|
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeEdu(i)}>删除</Button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formGrid4}>
|
|||
|
|
<div className={styles.formItem}>
|
|||
|
|
<label className={styles.label}>学校名称</label>
|
|||
|
|
<Input className={styles.input} placeholder="学校名称" value={e.school} onChange={ev => updateEdu(i, 'school', ev.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formItem}>
|
|||
|
|
<label className={styles.label}>专业</label>
|
|||
|
|
<Input className={styles.input} placeholder="专业名称" value={e.major} onChange={ev => updateEdu(i, 'major', ev.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formItem}>
|
|||
|
|
<label className={styles.label}>学历</label>
|
|||
|
|
<Select className={styles.selectFull} value={e.degree || undefined} onChange={v => updateEdu(i, 'degree', v)}
|
|||
|
|
popupClassName={styles.selectDropdown} placeholder="选择学历" options={degreeOptions} />
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formItem}>
|
|||
|
|
<label className={styles.label}>开始时间</label>
|
|||
|
|
<Input className={styles.input} placeholder="如:2018.09" value={e.startDate} onChange={ev => updateEdu(i, 'startDate', ev.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formItem}>
|
|||
|
|
<label className={styles.label}>结束时间</label>
|
|||
|
|
<Input className={styles.input} placeholder="如:2022.06" value={e.endDate} onChange={ev => updateEdu(i, 'endDate', ev.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formItemFull}>
|
|||
|
|
<label className={styles.label}>主修课程/描述</label>
|
|||
|
|
<TextArea className={styles.textarea} rows={3} placeholder="主修课程或其他描述" value={e.description} onChange={ev => updateEdu(i, 'description', ev.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
<div className={styles.addBtnWrap}>
|
|||
|
|
<Button className={styles.addBtn} icon={<PlusOutlined />} onClick={addEdu}>添加教育经历</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Section 4: Job Description */}
|
|||
|
|
<div className={styles.section}>
|
|||
|
|
<div className={styles.sectionHeader}>
|
|||
|
|
<div className={styles.sectionIcon}><FileSearchOutlined /></div>
|
|||
|
|
<span className={styles.sectionTitle}>岗位描述 / JD</span>
|
|||
|
|
<span className={styles.sectionHint}>必填,直接粘贴招聘要求即可</span>
|
|||
|
|
</div>
|
|||
|
|
<div className={styles.formItemFull}>
|
|||
|
|
<label className={styles.label}><span className={styles.required}>*</span> 应聘岗位要求</label>
|
|||
|
|
<TextArea className={styles.textarea} rows={6}
|
|||
|
|
placeholder="请粘贴目标岗位的招聘要求,AI 将根据 JD 为你量身定制简历内容..."
|
|||
|
|
value={jobDescription} onChange={e => setJobDescription(e.target.value)} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Footer actions */}
|
|||
|
|
<div className={styles.footerActions}>
|
|||
|
|
<Button className={styles.cancelBtn} onClick={handleCancel}>取消</Button>
|
|||
|
|
<Button type="primary" className={styles.generateBtn} loading={generating} onClick={handleGenerate}>
|
|||
|
|
{generating ? 'AI 生成中...' : <><RocketOutlined /> AI 生成简历</>}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<Footer />
|
|||
|
|
</div>
|
|||
|
|
</ConfigProvider>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default ResumeCreate;
|