first commit
This commit is contained in:
371
src/pages/Resume/Create/index.tsx
Normal file
371
src/pages/Resume/Create/index.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user