feat: profile page + resume builder (section reorder/enable, word/pdf export)
This commit is contained in:
@@ -10,6 +10,8 @@ const GradesPage = lazy(() => import('@/pages/grades/GradesPage').then(m => ({ d
|
||||
const TypesPage = lazy(() => import('@/pages/types/TypesPage').then(m => ({ default: m.TypesPage })));
|
||||
const TaxonomyPage = lazy(() => import('@/pages/taxonomy/TaxonomyPage').then(m => ({ default: m.TaxonomyPage })));
|
||||
const AdminPage = lazy(() => import('@/pages/admin/AdminPage').then(m => ({ default: m.AdminPage })));
|
||||
const ProfilePage = lazy(() => import('@/pages/profile/ProfilePage').then(m => ({ default: m.ProfilePage })));
|
||||
const ResumePage = lazy(() => import('@/pages/resume/ResumePage').then(m => ({ default: m.ResumePage })));
|
||||
|
||||
function PageLoading() {
|
||||
return <div className="py-16 text-center text-[13px] text-ink-3">加载中…</div>;
|
||||
@@ -27,6 +29,8 @@ export default function App() {
|
||||
<Route path="/types" element={<Suspense fallback={<PageLoading />}><TypesPage /></Suspense>} />
|
||||
<Route path="/taxonomy" element={<Suspense fallback={<PageLoading />}><TaxonomyPage /></Suspense>} />
|
||||
<Route path="/admin" element={<Suspense fallback={<PageLoading />}><AdminPage /></Suspense>} />
|
||||
<Route path="/profile" element={<Suspense fallback={<PageLoading />}><ProfilePage /></Suspense>} />
|
||||
<Route path="/resume" element={<Suspense fallback={<PageLoading />}><ResumePage /></Suspense>} />
|
||||
<Route path="*" element={<Navigate to="/timeline" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Header({ onMenu }: { onMenu?: () => void }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-line bg-surface px-3 sm:gap-4 sm:px-5">
|
||||
<header className="no-print flex h-14 shrink-0 items-center gap-2 border-b border-line bg-surface px-3 sm:gap-4 sm:px-5">
|
||||
<Button variant="ghost" size="icon" className="lg:hidden" onClick={onMenu} aria-label="打开导航">
|
||||
<Menu size={17} />
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, LogIn, LogOut, ShieldCheck, Cloud } from 'lucide-react';
|
||||
import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, LogIn, LogOut, ShieldCheck, Cloud, User, ScrollText } from 'lucide-react';
|
||||
import { serverMe, clearToken, getToken } from '@/lib/api';
|
||||
import { LoginDialog } from '@/components/auth/LoginDialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -16,6 +16,13 @@ const NAV = [
|
||||
{ to: '/dashboard', label: '统计', icon: ChartColumn },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '个人',
|
||||
items: [
|
||||
{ to: '/profile', label: '个人信息', icon: User },
|
||||
{ to: '/resume', label: '我的简历', icon: ScrollText },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '配置',
|
||||
items: [
|
||||
@@ -52,6 +59,7 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'no-print',
|
||||
'flex w-[236px] shrink-0 flex-col border-r border-line bg-surface',
|
||||
'max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:z-40 max-lg:transition-transform',
|
||||
open ? 'max-lg:translate-x-0' : 'max-lg:-translate-x-full',
|
||||
|
||||
+23
-1
@@ -1,7 +1,8 @@
|
||||
import type {
|
||||
AppInfo, Attachment, Dimension, FootprintDetail, FootprintInput, FootprintQuery,
|
||||
FootprintRow, FootprintType, FormField, GradeBand, GradesOverview, MetricValue,
|
||||
StatsOverview, SyncStatus, TaxonomyItem, TimelineGroup
|
||||
StatsOverview, SyncStatus, TaxonomyItem, TimelineGroup,
|
||||
ProfileInfo, ResumeConfig,
|
||||
} from './types';
|
||||
|
||||
|
||||
@@ -212,3 +213,24 @@ export const adminUpdateInvite = (id: number, patch: { remark?: string; valid?:
|
||||
adminCall<{ id: number }>('/admin/invites/update', { id, ...patch }, baseUrl);
|
||||
export const adminDeleteInvite = (id: number, baseUrl?: string) =>
|
||||
adminCall<{ deleted: number }>('/admin/invites/delete', { id }, baseUrl);
|
||||
|
||||
// ---- 个人信息 / 简历 ----
|
||||
export const profileGet = () => call<{ content: { info?: Partial<ProfileInfo>; resume?: ResumeConfig } }>('profile_get', {});
|
||||
export const profileUpsert = (content: { info?: Partial<ProfileInfo>; resume?: ResumeConfig }) =>
|
||||
call<{ ok: true }>('profile_upsert', { content });
|
||||
export async function profilePhotoUpload(file: File): Promise<string> {
|
||||
const dataB64 = await new Promise<string>((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result || '').split(',')[1] || '');
|
||||
fr.onerror = () => reject(new Error('读取文件失败'));
|
||||
fr.readAsDataURL(file);
|
||||
});
|
||||
const r = await fetch(getServerUrl() + '/api/profile_photo', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + getToken() },
|
||||
body: JSON.stringify({ data_b64: dataB64, file_name: file.name, mime: file.type || 'image/png' }),
|
||||
});
|
||||
const j = await r.json().catch(() => null);
|
||||
if (!j?.ok) throw new Error(j?.error || '证件照上传失败');
|
||||
return j.data.url as string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { FootprintRow, ProfileInfo, ResumeConfig } from './types';
|
||||
|
||||
export interface ResumeSectionDef {
|
||||
key: string; defaultTitle: string; types?: string[]; fixed?: boolean;
|
||||
}
|
||||
|
||||
// 简历板块定义(顺序 = 默认顺序)
|
||||
export const RESUME_SECTION_DEFS: ResumeSectionDef[] = [
|
||||
{ key: 'info', defaultTitle: '基本信息', fixed: true },
|
||||
{ key: 'education', defaultTitle: '教育背景', types: ['学业'] },
|
||||
{ key: 'skills', defaultTitle: '技能证书', types: ['证书技能'] },
|
||||
{ key: 'awards', defaultTitle: '荣誉奖项', types: ['荣誉惩处'] },
|
||||
{ key: 'competition', defaultTitle: '竞赛经历', types: ['竞赛'] },
|
||||
{ key: 'research', defaultTitle: '科研经历', types: ['科研'] },
|
||||
{ key: 'practice', defaultTitle: '实践经历', types: ['志愿公益', '实习实践', '学生工作'] },
|
||||
{ key: 'activity', defaultTitle: '文体活动', types: ['文体活动'] },
|
||||
{ key: 'other', defaultTitle: '其他经历' },
|
||||
{ key: 'self', defaultTitle: '自我评价', fixed: true },
|
||||
];
|
||||
|
||||
export function defaultResumeConfig(): ResumeConfig {
|
||||
return { sections: RESUME_SECTION_DEFS.map(s => ({ key: s.key, enabled: true, title: s.defaultTitle })) };
|
||||
}
|
||||
|
||||
export interface ResumeItem {
|
||||
row: FootprintRow;
|
||||
}
|
||||
|
||||
export interface AssembledSection {
|
||||
key: string; title: string;
|
||||
items?: ResumeItem[];
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export function assembleSections(
|
||||
records: FootprintRow[],
|
||||
profile: Partial<ProfileInfo> | undefined,
|
||||
config: ResumeConfig | undefined
|
||||
): AssembledSection[] {
|
||||
const cfg = config?.sections?.length ? config.sections : defaultResumeConfig().sections;
|
||||
const byKey = new Map(RESUME_SECTION_DEFS.map(s => [s.key, s]));
|
||||
|
||||
const out: AssembledSection[] = [];
|
||||
for (const s of cfg) {
|
||||
if (!s.enabled) continue;
|
||||
const def = byKey.get(s.key);
|
||||
if (s.key === 'info') { out.push({ key: s.key, title: s.title, text: 'info' }); continue; }
|
||||
if (s.key === 'self') { out.push({ key: s.key, title: s.title, text: profile?.selfIntro || '' }); continue; }
|
||||
const types = def?.types;
|
||||
if (!types) continue;
|
||||
const items = records.filter(r => types.includes(r.type_name) && r.status !== 'draft');
|
||||
if (!items.length) continue;
|
||||
items.sort((a, b) => (b.start_date ?? b.created_at).localeCompare(a.start_date ?? a.created_at));
|
||||
out.push({ key: s.key, title: s.title, items: items.map(row => ({ row })) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Word 导出:简历 HTML(内联样式,Word/WPS 可直接打开)
|
||||
export function resumeToWordHtml(
|
||||
sections: AssembledSection[],
|
||||
profile: Partial<ProfileInfo> | undefined,
|
||||
photoUrl?: string
|
||||
): string {
|
||||
const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const P = profile ?? {};
|
||||
const secHtml = sections.map(sec => {
|
||||
let inner = '';
|
||||
if (sec.key === 'info') {
|
||||
const rows: [string, string][] = ([
|
||||
['姓名', P.name ?? ''], ['性别', P.gender ?? ''], ['出生年月', P.birth ?? ''],
|
||||
['民族', P.nation ?? ''], ['籍贯', P.native ?? ''], ['政治面貌', P.political ?? ''],
|
||||
['学校', P.school ?? ''], ['学院', P.college ?? ''], ['专业', P.major ?? ''],
|
||||
['班级', P.className ?? ''], ['学号', P.studentId ?? ''], ['学历', P.degree ?? ''],
|
||||
['手机', P.phone ?? ''], ['邮箱', P.email ?? ''],
|
||||
] as [string, string][]).filter(r => r[1] !== '');
|
||||
inner = '<table style="width:100%;border-collapse:collapse;font-size:12pt">' +
|
||||
rows.map(([k, v], i) =>
|
||||
(i % 2 === 0 ? '<tr>' : '') +
|
||||
'<td style="width:80px;padding:3px 6px;color:#555">' + esc(String(k)) + '</td>' +
|
||||
'<td style="padding:3px 6px">' + esc(String(v)) + '</td>' +
|
||||
(i % 2 === 1 ? '</tr>' : '')
|
||||
).join('') + '</table>';
|
||||
} else if (sec.items) {
|
||||
inner = '<div style="margin-bottom:6pt">' + sec.items.map(it => {
|
||||
const r = it.row;
|
||||
const date = (r.start_date ?? r.created_at).slice(0, 10);
|
||||
const sub = [r.organizer, r.level, r.award_grade].filter(v => v != null && v !== '').map(v => esc(String(v))).join(' · ');
|
||||
return '<div style="margin-bottom:7pt">' +
|
||||
'<div style="display:flex;justify-content:space-between"><b style="font-size:12pt">' + esc(r.title) + '</b>' +
|
||||
'<span style="color:#666;font-size:10.5pt">' + esc(date) + '</span></div>' +
|
||||
(sub ? '<div style="color:#666;font-size:10.5pt">' + sub + '</div>' : '') +
|
||||
(r.result ? '<div style="color:#177245;font-size:11pt">' + esc(r.result) + '</div>' : '') +
|
||||
'</div>';
|
||||
}).join('') + '</div>';
|
||||
} else if (sec.text) {
|
||||
inner = '<p style="font-size:12pt;line-height:1.7;margin:0">' + esc(sec.text) + '</p>';
|
||||
}
|
||||
return '<h2 style="font-size:14pt;border-bottom:1.5pt solid #333;padding-bottom:3pt;margin:14pt 0 7pt">' + esc(sec.title) + '</h2>' + inner;
|
||||
}).join('');
|
||||
|
||||
const photo = photoUrl && P.photoUrl
|
||||
? '<div style="text-align:right"><img src="' + esc(P.photoUrl) + '" alt="证件照" style="width:90pt;height:120pt;object-fit:cover" /></div>'
|
||||
: '';
|
||||
|
||||
return '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">' +
|
||||
'<head><meta charset="utf-8"><title>个人简历</title></head>' +
|
||||
'<body style="font-family:SimSun,Calibri,sans-serif;margin:36pt">' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:flex-start">' +
|
||||
'<h1 style="font-size:20pt;margin:0 0 4pt">' + esc(P.name || '个人简历') + '</h1>' + photo + '</div>' +
|
||||
(P.major || P.school ? '<div style="color:#555;font-size:11pt;margin-bottom:8pt">' +
|
||||
[P.school, P.college, P.major, P.degree].filter(v => !!v).map(v => esc(String(v))).join(' · ') + '</div>' : '') +
|
||||
secHtml +
|
||||
'</body></html>';
|
||||
}
|
||||
@@ -230,3 +230,14 @@ export interface GradesOverview {
|
||||
tier_dist: { label: string; color: string; count: number }[];
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 个人信息 ----------
|
||||
export interface ProfileInfo {
|
||||
name: string; gender: string; birth: string; nation: string; native: string;
|
||||
political: string; school: string; college: string; major: string;
|
||||
className: string; studentId: string; degree: string;
|
||||
phone: string; email: string; homepage: string; photoUrl: string;
|
||||
selfIntro: string;
|
||||
}
|
||||
export interface ResumeSectionCfg { key: string; enabled: boolean; title: string; }
|
||||
export interface ResumeConfig { sections: ResumeSectionCfg[]; }
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Pencil, Upload, User as UserIcon } from 'lucide-react';
|
||||
import { profileGet, profilePhotoUpload, profileUpsert } from '@/lib/api';
|
||||
import type { ProfileInfo } from '@/lib/types';
|
||||
import { Button, Dialog, Field, Input, Select, Textarea, useToast } from '@/components/ui';
|
||||
|
||||
const EMPTY: ProfileInfo = {
|
||||
name: '', gender: '', birth: '', nation: '', native: '', political: '',
|
||||
school: '', college: '', major: '', className: '', studentId: '', degree: '',
|
||||
phone: '', email: '', homepage: '', photoUrl: '', selfIntro: ''
|
||||
};
|
||||
|
||||
const FIELD_GROUPS: { title: string; fields: { key: keyof ProfileInfo; label: string; type?: string; options?: string[] }[] }[] = [
|
||||
{ title: '基本信息', fields: [
|
||||
{ key: 'name', label: '姓名' },
|
||||
{ key: 'gender', label: '性别', options: ['', '男', '女'] },
|
||||
{ key: 'birth', label: '出生日期', type: 'date' },
|
||||
{ key: 'nation', label: '民族' },
|
||||
{ key: 'native', label: '籍贯' },
|
||||
{ key: 'political', label: '政治面貌' },
|
||||
]},
|
||||
{ title: '学校信息', fields: [
|
||||
{ key: 'school', label: '学校' },
|
||||
{ key: 'college', label: '学院' },
|
||||
{ key: 'major', label: '专业' },
|
||||
{ key: 'className', label: '班级' },
|
||||
{ key: 'studentId', label: '学号' },
|
||||
{ key: 'degree', label: '学历层次', options: ['', '专科', '本科', '硕士研究生', '博士研究生'] },
|
||||
]},
|
||||
{ title: '联系方式', fields: [
|
||||
{ key: 'phone', label: '手机' },
|
||||
{ key: 'email', label: '邮箱' },
|
||||
{ key: 'homepage', label: '个人主页 / 博客' },
|
||||
]},
|
||||
];
|
||||
|
||||
export function ProfilePage() {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState<ProfileInfo>(EMPTY);
|
||||
const [photoBusy, setPhotoBusy] = useState(false);
|
||||
|
||||
const q = useQuery({ queryKey: ['profile'], queryFn: profileGet });
|
||||
const info: Partial<ProfileInfo> = q.data?.content?.info ?? {};
|
||||
const set = (patch: Partial<ProfileInfo>) => setForm(f => ({ ...EMPTY, ...f, ...patch }));
|
||||
|
||||
const openEdit = () => { setForm({ ...EMPTY, ...info }); setEditing(true); };
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () => profileUpsert({ info: form }),
|
||||
onSuccess: () => { toast('success', '个人信息已保存'); setEditing(false); void qc.invalidateQueries({ queryKey: ['profile'] }); },
|
||||
onError: (e: Error) => toast('error', e.message)
|
||||
});
|
||||
|
||||
const onPhoto = async (f: File | undefined) => {
|
||||
if (!f) return;
|
||||
setPhotoBusy(true);
|
||||
try {
|
||||
const url = await profilePhotoUpload(f);
|
||||
const next = { ...EMPTY, ...form, photoUrl: url };
|
||||
setForm(next);
|
||||
await profileUpsert({ info: next });
|
||||
void qc.invalidateQueries({ queryKey: ['profile'] });
|
||||
toast('success', '证件照已上传');
|
||||
} catch (e) { toast('error', (e as Error).message); }
|
||||
setPhotoBusy(false);
|
||||
};
|
||||
|
||||
const photo = info.photoUrl;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[880px] px-3 py-4 sm:px-6 sm:py-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-[15px] font-semibold text-ink-1">个人信息</h2>
|
||||
<Button variant="primary" onClick={openEdit}><Pencil size={13} /> 编辑</Button>
|
||||
</div>
|
||||
|
||||
{q.isLoading ? (
|
||||
<div className="py-16 text-center text-[13px] text-ink-3">加载中…</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-line bg-surface shadow-card">
|
||||
<div className="flex flex-col gap-4 border-b border-line p-4 sm:flex-row sm:items-center">
|
||||
<div className="flex size-20 shrink-0 items-center justify-center overflow-hidden rounded-full border border-line bg-surface-2">
|
||||
{photo ? <img src={photo} alt="证件照" className="h-full w-full object-cover" />
|
||||
: <UserIcon size={32} className="text-ink-3" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[17px] font-semibold text-ink-1">{info.name || '未填写姓名'}</div>
|
||||
<div className="mt-0.5 text-[12px] text-ink-3">
|
||||
{[info.school, info.college, info.major].filter(Boolean).join(' · ') || '完善学校信息后,简历将自动填充'}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-line px-2.5 py-1.5 text-[12px] text-ink-2 transition-colors hover:border-line-strong">
|
||||
<Upload size={13} />
|
||||
{photoBusy ? '上传中…' : (photo ? '更换证件照' : '上传证件照')}
|
||||
<input type="file" accept="image/*" className="hidden" disabled={photoBusy}
|
||||
onChange={e => { void onPhoto(e.target.files?.[0]); e.target.value = ''; }} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{FIELD_GROUPS.map(g => (
|
||||
<div key={g.title} className="border-b border-line px-4 py-3 last:border-b-0">
|
||||
<div className="mb-2 text-[12px] font-semibold text-ink-2">{g.title}</div>
|
||||
<dl className="grid grid-cols-1 gap-x-6 gap-y-1.5 sm:grid-cols-2">
|
||||
{g.fields.map(f => {
|
||||
const v = (info[f.key] as string) || '';
|
||||
return (
|
||||
<div key={f.key} className="grid min-w-0 grid-cols-[84px_1fr] gap-x-2.5 py-0.5">
|
||||
<dt className="shrink-0 text-right text-[12px] text-ink-3">{f.label}</dt>
|
||||
<dd className={'min-w-0 truncate text-[12.5px] ' + (v ? 'text-ink-1' : 'text-ink-3/60')} title={v}>{v || '未填写'}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!info.selfIntro && (
|
||||
<div className="mt-3 rounded-md border border-dashed border-line px-4 py-2.5 text-[12px] text-ink-3">
|
||||
提示:编辑里填写「自我评价」后,可一键用于简历生成。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={editing} onClose={() => setEditing(false)} width={720} title="编辑个人信息"
|
||||
footer={<>
|
||||
<span className="mr-auto text-[12px] text-ink-3">填写一次,填表与简历自动复用</span>
|
||||
<Button variant="primary" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>保存</Button>
|
||||
</>}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{FIELD_GROUPS.map(g => (
|
||||
<div key={g.title}>
|
||||
<div className="mb-2 text-[12px] font-semibold text-ink-2">{g.title}</div>
|
||||
<div className="grid grid-cols-1 gap-x-3 gap-y-3 sm:grid-cols-2">
|
||||
{g.fields.map(f => (
|
||||
<Field key={f.key} label={f.label}>
|
||||
{f.options ? (
|
||||
<Select value={form[f.key] as string || ''} onChange={e => set({ [f.key]: e.target.value } as Partial<ProfileInfo>)}
|
||||
options={f.options.map(o => ({ value: o, label: o || '未选择' }))} />
|
||||
) : (
|
||||
<Input type={f.type || 'text'} value={form[f.key] as string || ''} onChange={e => set({ [f.key]: e.target.value } as Partial<ProfileInfo>)} />
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-t border-line pt-3">
|
||||
<Field label="自我评价" hint="简历「自我评价」板块使用">
|
||||
<Textarea rows={4} value={form.selfIntro} onChange={e => set({ selfIntro: e.target.value })} placeholder="简述性格、能力、方向…" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowDown, ArrowUp, Download, Printer } from 'lucide-react';
|
||||
import { footprintList, profileGet } from '@/lib/api';
|
||||
import type { ProfileInfo, ResumeConfig } from '@/lib/types';
|
||||
import { assembleSections, defaultResumeConfig, RESUME_SECTION_DEFS } from '@/lib/resume';
|
||||
import { profileUpsert } from '@/lib/api';
|
||||
import { Button, Card, CardBody, CardHeader, Field, Textarea, useToast } from '@/components/ui';
|
||||
|
||||
function selfIntroDraftFix(v: string) { return v; }
|
||||
|
||||
export function ResumePage() {
|
||||
const toast = useToast();
|
||||
const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet });
|
||||
const listQ = useQuery({ queryKey: ['resume-records'], queryFn: () => footprintList({ page: 1, page_size: 500 }) });
|
||||
|
||||
const stored = profileQ.data?.content;
|
||||
const profile = { ...(stored?.info ?? {}) } as Partial<ProfileInfo>;
|
||||
const [config, setConfig] = useState<ResumeConfig>(() => stored?.resume ?? defaultResumeConfig());
|
||||
const [selfIntro, setSelfIntro] = useState(profile.selfIntro ?? '');
|
||||
const introDirty = selfIntro !== (profile.selfIntro ?? '');
|
||||
const previewProfile: Partial<ProfileInfo> = { ...profile, selfIntro: selfIntroDraftFix(selfIntro) };
|
||||
|
||||
const records = listQ.data?.items ?? [];
|
||||
const sections = useMemo(
|
||||
() => assembleSections(records, previewProfile, config),
|
||||
[records, previewProfile, config]
|
||||
);
|
||||
|
||||
const move = (i: number, dir: -1 | 1) => setConfig(c => {
|
||||
const arr = [...c.sections];
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= arr.length) return c;
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
return { ...c, sections: arr };
|
||||
});
|
||||
const toggle = (i: number) => setConfig(c => {
|
||||
const arr = [...c.sections];
|
||||
arr[i] = { ...arr[i], enabled: !arr[i].enabled };
|
||||
return { ...c, sections: arr };
|
||||
});
|
||||
const retitle = (i: number, title: string) => setConfig(c => {
|
||||
const arr = [...c.sections];
|
||||
arr[i] = { ...arr[i], title };
|
||||
return { ...c, sections: arr };
|
||||
});
|
||||
|
||||
const exportWord = () => {
|
||||
void import('@/lib/resume').then(({ resumeToWordHtml }) => {
|
||||
const html = resumeToWordHtml(sections, previewProfile, previewProfile.photoUrl);
|
||||
const blob = new Blob(['\ufeff' + html], { type: 'application/msword' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = (profile.name || '个人简历') + '.doc';
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
toast('success', 'Word 文档已导出');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5">
|
||||
<div className="no-print mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-[15px] font-semibold text-ink-1">我的简历</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={exportWord}><Download size={13} /> 导出 Word</Button>
|
||||
<Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印 / 导出 PDF</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[300px_1fr]">
|
||||
{/* 布局面板 */}
|
||||
<Card className="no-print">
|
||||
<CardHeader title="板块布局" />
|
||||
<CardBody className="p-0">
|
||||
<div className="flex flex-col divide-y divide-line">
|
||||
{config.sections.map((s, i) => {
|
||||
const hasItems = sections.find(x => x.key === s.key);
|
||||
return (
|
||||
<div key={s.key} className="p-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input type="checkbox" checked={s.enabled} onChange={() => toggle(i)} aria-label="启用板块" className="size-3.5" />
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-sm border border-transparent bg-transparent px-1 py-0.5 text-[12.5px] font-medium text-ink-1 transition-colors hover:border-line focus:border-brand focus:outline-none"
|
||||
value={s.title}
|
||||
onChange={e => retitle(i, e.target.value)}
|
||||
/>
|
||||
<button className="rounded p-1 text-ink-3 transition-colors hover:bg-surface-3 hover:text-ink-1 disabled:opacity-30"
|
||||
aria-label="上移" disabled={i === 0} onClick={() => move(i, -1)}><ArrowUp size={13} /></button>
|
||||
<button className="rounded p-1 text-ink-3 transition-colors hover:bg-surface-3 hover:text-ink-1 disabled:opacity-30"
|
||||
aria-label="下移" disabled={i === config.sections.length - 1} onClick={() => move(i, 1)}><ArrowDown size={13} /></button>
|
||||
</div>
|
||||
<div className="mt-0.5 pl-5 text-[11px] text-ink-3">
|
||||
{RESUME_SECTION_DEFS.find(d => d.key === s.key)?.types?.join(' / ') ?? '固定板块'}
|
||||
{s.enabled && !hasItems && <span className="text-warning"> · 暂无内容</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardBody>
|
||||
<div className="border-t border-line p-3">
|
||||
<Field label="自我评价">
|
||||
<Textarea rows={4} value={selfIntro} onChange={e => setSelfIntro(e.target.value)} placeholder="简述性格、能力、研究方向…" />
|
||||
</Field>
|
||||
<Button size="sm" variant="primary" className="mt-2 w-full"
|
||||
disabled={!introDirty} onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await profileUpsert({ info: { ...profile, selfIntro }, resume: config });
|
||||
await profileQ.refetch();
|
||||
toast('success', '自我评价已保存');
|
||||
} catch (e) { toast('error', (e as Error).message); }
|
||||
})();
|
||||
}}>保存自我评价</Button>
|
||||
<p className="mt-1 text-[11px] text-ink-3">板块顺序与标题自动保存 · 内容来自「个人信息」与各类型记录</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 简历预览(A4 比例白卡) */}
|
||||
<div>
|
||||
<div className="print-sheet mx-auto w-full max-w-[794px] rounded-lg border border-line bg-white p-8 text-[13px] leading-relaxed text-neutral-900 shadow-card sm:p-10">
|
||||
<ResumePreviewBody sections={sections} profile={previewProfile} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumePreviewBody({ sections, profile: P }: {
|
||||
sections: ReturnType<typeof assembleSections>;
|
||||
profile: Partial<ProfileInfo>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-[26px] font-bold leading-tight tracking-tight text-neutral-900">{P.name || '姓名'}</h1>
|
||||
<div className="mt-1.5 text-[12.5px] text-neutral-500">
|
||||
{[P.school, P.college, P.major, P.degree].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-neutral-500">
|
||||
{[P.phone, P.email, P.homepage].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
{P.photoUrl && <img src={P.photoUrl} alt="证件照" className="h-[120px] w-[90px] rounded border border-neutral-200 object-cover" />}
|
||||
</div>
|
||||
|
||||
{sections.map(sec => (
|
||||
<div key={sec.key} className="mb-5">
|
||||
<div className="mb-2.5 border-b-2 border-neutral-800 pb-1 text-[14px] font-bold tracking-wide text-neutral-900">{sec.title}</div>
|
||||
{sec.key === 'info' ? (
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-[12.5px]">
|
||||
{([
|
||||
['姓名', P.name], ['性别', P.gender], ['出生年月', P.birth], ['民族', P.nation],
|
||||
['籍贯', P.native], ['政治面貌', P.political], ['班级', P.className], ['学号', P.studentId],
|
||||
['手机', P.phone], ['邮箱', P.email],
|
||||
] as [string, string][]).filter(r => r[1]).map(([k, v]) => (
|
||||
<div key={k} className="flex gap-2"><span className="w-16 shrink-0 text-neutral-500">{k}</span><span>{v}</span></div>
|
||||
))}
|
||||
</div>
|
||||
) : sec.text !== undefined ? (
|
||||
<p className="whitespace-pre-wrap text-[12.5px] leading-relaxed">{sec.text || '(在「个人信息」中填写自我评价)'}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{sec.items?.map(it => {
|
||||
const r = it.row;
|
||||
const date = (r.start_date ?? r.created_at).slice(0, 10);
|
||||
return (
|
||||
<div key={r.id}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-[13px] font-semibold text-neutral-900">{r.title}</span>
|
||||
<span className="num shrink-0 text-[11.5px] text-neutral-500">{date}{r.end_date ? ' ~ ' + r.end_date.slice(5, 10) : ''}</span>
|
||||
</div>
|
||||
<div className="text-[12px] text-neutral-600">
|
||||
{[r.organizer, r.level, r.award_grade].filter(Boolean).join(' · ')}
|
||||
{r.result && <span className="text-emerald-700"> — {r.result}</span>}
|
||||
</div>
|
||||
{r.description && <p className="mt-0.5 line-clamp-2 text-[12px] text-neutral-500">{r.description}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,3 +155,12 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
|
||||
position: absolute; left: 0; top: 6px; bottom: 6px; width: 3px;
|
||||
border-radius: 0 3px 3px 0; background: var(--brand);
|
||||
}
|
||||
|
||||
/* ---------- 简历打印 / 导出 ---------- */
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
html, body, #root { height: auto !important; background: #fff !important; }
|
||||
body { padding: 0 !important; }
|
||||
.print-scroll { overflow: visible !important; height: auto !important; }
|
||||
.print-sheet { border: none !important; box-shadow: none !important; margin: 0 !important; max-width: none !important; width: 100% !important; padding: 0 !important; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user