feat: profile page + resume builder (section reorder/enable, word/pdf export)

This commit is contained in:
root
2026-10-17 12:14:01 +08:00
parent 500d59ba58
commit 013c9fb929
25 changed files with 891 additions and 339 deletions
+4
View File
@@ -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>
+1 -1
View File
@@ -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>
+9 -1
View File
@@ -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
View File
@@ -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;
}
+115
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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>';
}
+11
View File
@@ -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[]; }
+159
View File
@@ -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>
);
}
+192
View File
@@ -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>
))}
</>
);
}
+9
View File
@@ -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; }
}
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r as g,f as v,j as t,S as j,t as S}from"./index-C89WOjDi.js";import{C as c,a as d,b as x}from"./card-Cg1yGhg4.js";import{u as N,i as u,a as m}from"./useChart-CDXdKvrQ.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.count>0).slice().reverse();return{grid:{left:64,right:24,top:8,bottom:22},tooltip:{trigger:"axis"},xAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"lg:col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage};
import{r as g,f as v,j as t,S as j,t as S}from"./index-DkrEeFZh.js";import{C as c,a as d,b as x}from"./card-DdglV9lU.js";import{u as N,i as u,a as m}from"./useChart-DNtmZa2Q.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.count>0).slice().reverse();return{grid:{left:64,right:24,top:8,bottom:22},tooltip:{trigger:"axis"},xAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"lg:col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage};
@@ -1,4 +1,4 @@
import{c as R,f as I,j as e,E as O,w as $,u as N,d as f,r as u,g as v,x as H,y as W,B as b,P as q,C as L,z as J,A as T,D as U,m as k,l as F,T as z,I as g,F as Y,H as S,J as E,K as V}from"./index-C89WOjDi.js";import{C,a as M,b as _}from"./card-Cg1yGhg4.js";import{u as X,i as Q,a as K}from"./useChart-CDXdKvrQ.js";import{u as Z,i as ee,b as se,a as te,c as ae,d as re,e as ne,f as le}from"./echarts-BIWefrIc.js";/**
import{c as R,f as I,j as e,E as O,w as $,u as N,d as f,r as u,g as v,x as H,y as W,B as b,P as q,C as L,z as J,A as T,D as U,m as k,l as F,T as z,I as g,F as Y,H as S,J as E,K as V}from"./index-DkrEeFZh.js";import{C,a as M,b as _}from"./card-DdglV9lU.js";import{u as X,i as Q,a as K}from"./useChart-DNtmZa2Q.js";import{u as Z,i as ee,b as se,a as te,c as ae,d as re,e as ne,f as le}from"./echarts-BIWefrIc.js";/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as fe,j as e,a as N,r as s,b as he,u as ve,d as ge,e as je,f as L,g as ye,I as be,S as k,B as w,P as Q,h as te,L as se,G as ae,R as Ne,i as ne,k as ke,l as we,T as Ce,E as le,m as Ee,n as Se,o as _e,C as ze,p as qe,q as Le,s as Re,t as De,v as Ie}from"./index-C89WOjDi.js";/**
import{c as fe,j as e,a as N,r as s,b as he,u as ve,d as ge,e as je,f as L,g as ye,I as be,S as k,B as w,P as Q,h as te,L as se,G as ae,R as Ne,i as ne,k as ke,l as we,T as Ce,E as le,m as Ee,n as Se,o as _e,C as ze,p as qe,q as Le,s as Re,t as De,v as Ie}from"./index-DkrEeFZh.js";/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as D,u as K,d as F,f as L,r,g as I,j as e,B as p,P as q,M as O,m as _,l as H,T as P,C as R,N as G,F as U,H as i,I as j,O as W,S as z,Q as J,U as V,q as X}from"./index-C89WOjDi.js";import{C as Y}from"./card-Cg1yGhg4.js";/**
import{c as D,u as K,d as F,f as L,r,g as I,j as e,B as p,P as q,M as O,m as _,l as H,T as P,C as R,N as G,F as U,H as i,I as j,O as W,S as z,Q as J,U as V,q as X}from"./index-DkrEeFZh.js";import{C as Y}from"./card-DdglV9lU.js";/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
@@ -1 +1 @@
import{j as s,a}from"./index-C89WOjDi.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b};
import{j as s,a}from"./index-DkrEeFZh.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r}from"./index-C89WOjDi.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u};
import{r}from"./index-DkrEeFZh.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u};
+2 -2
View File
@@ -9,8 +9,8 @@
<link rel="icon" href="/icons/pwa-192.png" />
<link rel="apple-touch-icon" href="/icons/pwa-192.png" />
<title>足迹 Footprints</title>
<script type="module" crossorigin src="/assets/index-C89WOjDi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DbEUVhiT.css">
<script type="module" crossorigin src="/assets/index-DkrEeFZh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BheMcaGY.css">
</head>
<body>
<div id="root"></div>