feat: fictional demo persona (profile seed), word export mirrors preview layout, print scrollbar fix

This commit is contained in:
root
2026-10-17 13:23:36 +08:00
parent 603e39d2bd
commit 68043d309e
19 changed files with 142 additions and 122 deletions
+17 -2
View File
@@ -370,9 +370,9 @@ export const DEMO_ROWS = [
['学生工作', '院学生会宣传部副部长', '主导', '院级', null, '院学生会', '2025-09-01', null, 'ongoing',
'统筹部门工作,管理 8 名干事,主导院级活动宣传方案', '', ['管理'], ['综合素养', '职业发展'],
{ duties: '部门统筹、活动宣传方案设计', term: '2025.09 - 至今' }, {}],
['荣誉惩处', '校级二等奖学金', '参加', '校级', null, '重庆城市科技学院', '2025-10-15', '2025-10-15', 'finished',
['荣誉惩处', '校级二等奖学金', '参加', '校级', null, '云川理工学院', '2025-10-15', '2025-10-15', 'finished',
'2024-2025 学年综合测评前列', '二等奖学金', ['奖学金'], ['学业深造'],
{ issued_by: '重庆城市科技学院' }, {}],
{ issued_by: '云川理工学院' }, {}],
['荣誉惩处', '优秀共青团员', '参加', '校级', null, '校团委', '2025-05-04', '2025-05-04', 'finished',
'五四表彰', '优秀共青团员', ['表彰'], ['综合素养'],
{ issued_by: '共青团重庆城市科技学院委员会' }, {}],
@@ -398,6 +398,19 @@ export const DEMO_ROWS = [
'参与企业授权渗透测试项目 3 个,编写测试报告', '获实习优秀评价', ['实习', '安全'], ['职业发展'],
{ position: '渗透测试实习生', mentor: '陈工' }, {}]
];
// 演示个人档案(虚构人设:云川理工学院 · 信息安全 · 林晓阳)
export const DEMO_PROFILE = {
info: {
name: '林晓阳', gender: '男', birth: '2004-06', nation: '汉族', native: '四川成都',
political: '共青团员', school: '云川理工学院', college: '信息工程学院', major: '信息安全',
className: '信安2201', studentId: '2023110245', degree: '本科', eduLength: '四年',
eduStart: '2023-09', eduEnd: '2027-06',
majorCourses: '计算机网络、数据库技术、操作系统、信息安全导论、密码学基础、网络安全、云计算与虚拟化技术',
phone: '138****8000', email: 'linxy.demo@example.com', homepage: 'https://blog.example.com/linxy',
photoUrl: '', selfIntro: '性格沉稳踏实,做事有计划性,习惯提前预判风险并准备预案;对网络安全方向有持续热情,动手能力强,能独立完成从方案设计到落地验证的完整流程;责任心强,注重细节与条理,是可靠的技术执行者。'
}
};
// 学期: { term, gpa, rank, total, courses: [课程名, 学分, 分数, 绩点][] }
export const DEMO_SEMESTERS = [
{ term: '2023-2024-1', gpa: 3.42, rank: 38, total: 156, courses: [
@@ -1173,6 +1186,8 @@ function listFootprints(d, query) {
const fpCnt = d.prepare('SELECT COUNT(*) AS c FROM footprint WHERE deleted_at IS NULL').get().c;
const semCnt = d.prepare('SELECT COUNT(*) AS c FROM semester WHERE deleted_at IS NULL').get().c;
if (fpCnt > 0 || semCnt > 0) return { seeded: false, footprints: fpCnt, semesters: semCnt };
// 个人档案(简历与个人信息页展示用)
handleCommand('profile_upsert', { content: DEMO_PROFILE });
const typeIds = Object.fromEntries(d.prepare('SELECT id, name FROM footprint_type').all().map((r) => [r.name, r.id]));
const dimIds = Object.fromEntries(d.prepare('SELECT id, name FROM dimension').all().map((r) => [r.name, r.id]));
let n = 0;
+1 -1
View File
@@ -29,7 +29,7 @@ export function AppShell() {
<Sidebar open={navOpen} onClose={() => setNavOpen(false)} />
<div className="flex min-w-0 flex-1 flex-col">
<Header onMenu={() => setNavOpen(true)} />
<main className="min-h-0 flex-1 overflow-y-auto">
<main className="app-scroll min-h-0 flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
+62 -60
View File
@@ -23,14 +23,14 @@ export const RESUME_SECTION_DEFS: ResumeSectionDef[] = [
{ key: 'self', defaultTitle: '自我评价', kind: 'text', fixed: true },
];
// 首次使用种子内容(IT/网安方向示例,可自由修改)
// 首次使用种子内容(虚构示例,可自由修改)
const SEED: Record<string, { text?: string; items?: { title: string; subtitle?: string; date?: string; desc?: string }[] }> = {
skills: { text: '路由交换:熟悉TCP/IP协议栈与基础路由交换,掌握VLAN、链路聚合、MSTP、VRRP/VRRP6等园区网冗余技术,熟悉OSPF、RIP、BGP等动态路由协议,具备IPv4/IPv6双栈网络的规划与部署能力。\n网络安全:熟悉防火墙安全区域、ACL访问控制与安全策略配置,掌握NAPT、NAT Server、NAT64地址转换,了解GRE over IPsec隧道与防火墙双机热备。\n网络设备:熟悉华为交换机、路由器、防火墙CLI操作与配置,掌握GVRP、MSTP、VRRP、链路聚合及BFD联动快速收敛,熟悉Wireshark抓包分析。\n运维基础:熟悉RedHat、Ubuntu等Linux系统部署与日常维护,了解Docker、K8s、Prometheus等云原生运维工具。' },
skills: { text: '程序设计:熟悉 Python 与 C/C++,掌握常用数据结构与算法,能独立完成中小型工具与脚本开发。\n网络安全:熟悉常见 Web 漏洞原理(SQL 注入、XSS、CSRF 等)与防护方案,掌握 Burp Suite、Nmap、Wireshark 等常用工具。\n网络基础:熟悉 TCP/IP 协议栈与交换路由基础配置,理解 VLAN、NAT、ACL 等核心技术。\n运维基础:熟悉 Linux 常用操作与 Shell 脚本,了解 Docker 容器化部署与 Nginx 服务配置。' },
campus: { items: [
{ title: '学校图书馆 勤工俭学固定岗', subtitle: '图书管理员', date: '2024.12 - 2026.7', desc: '负责图书上架、整理和维护,定时进行馆内安全巡检,协助读者检索和借阅服务,累计服务读者超2000人次,零服务投诉。这段经历培养了高度的责任意识与服务意识,锻炼了高效执行和多任务时间管理能力。' },
{ title: '学院团总支学生会宣传部 干事', subtitle: '视觉设计', date: '2023.9 - 2024.9', desc: '独立完成学院活动海报、邀请函、KT板、易拉宝等40余份宣传物料设计,参与换届大会、迎新、毕业季等重大活动视觉设计与宣传品制作。负责报告厅后台多媒体设备控制,保障12场大型活动视觉呈现零失误,锻炼了应急处置能力与谨慎细致的工作态度。' },
{ title: '校图书馆学生助理', subtitle: '读者服务岗', date: '2024.9 - 2025.6', desc: '负责书库整理与借阅服务,协助开展入馆培训与阅读推广活动,累计服务读者千余人次。培养了责任意识与服务意识,锻炼了多任务时间管理能力。' },
{ title: '院学生会宣传部干事', subtitle: '平面设计与文案', date: '2023.9 - 2024.6', desc: '参与学院活动的海报设计与推文撰写,负责活动现场的后勤与设备保障。锻炼了审美表达、协作沟通与临场应变能力。' },
]},
self: { text: '心态沉稳、抗压能力较好,遇到突发状况能冷静应对、稳住局面。做事有计划性,习惯提前预判风险并做好预案,追求稳妥与确定性,执行质量有保障,能在压力下保持稳定输出。责任心强,凡事有始有终,注重细节与条理,能在复杂环境中稳步推进工作,是一名可靠、值得信赖的执行者。' },
self: { text: '性格沉稳踏实,做事有计划性,习惯提前预判风险并准备预案;学习能力强,能快速上手新技术与工具;责任心强,注重细节与条理,凡事有始有终,能独立推进任务并在压力下保持稳定输出。' },
};
@@ -158,7 +158,7 @@ export const TEMPLATES: { key: ResumeTemplate; name: string; desc: string }[] =
export const ACCENT_CHOICES = ['#3056d3', '#0f766e', '#7c3aed', '#c2410c', '#0e7490', '#1f2937'];
// ---------- Word 导出(内联样式,按模板着色) ----------
// ---------- Word 导出(按预览布局生成,Word/WPS 直接打开) ----------
const esc = (t: unknown) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -171,73 +171,75 @@ export function resumeToWordHtml(
const scale = cfg.fontScale;
const pt = (base: number) => (base * scale).toFixed(1) + 'pt';
const accent = cfg.accent;
const titleStyle: Record<ResumeTemplate, string> = {
classic: 'font-size:' + pt(14) + ';border-bottom:1.5pt solid #333;padding-bottom:3pt;margin:12pt 0 6pt;color:#111',
tech: 'font-size:' + pt(14) + ';color:' + accent + ';border-left:3pt solid ' + accent + ';padding-left:6pt;margin:12pt 0 6pt',
minimal: 'font-size:' + pt(11.5) + ';color:#555;letter-spacing:1.5pt;text-transform:uppercase;margin:14pt 0 6pt',
const P = profile;
// 模板标题样式
const titleCss: Record<ResumeTemplate, string> = {
classic: 'font-size:' + pt(14) + ';color:#111;border-bottom:1.5pt solid #333;padding-bottom:3pt;margin:14pt 0 7pt',
tech: 'font-size:' + pt(14) + ';color:' + accent + ';border-bottom:1pt solid ' + accent + ';padding-bottom:3pt;margin:14pt 0 7pt',
minimal: 'font-size:' + pt(11.5) + ';color:#555;letter-spacing:1.5pt;text-transform:uppercase;border-bottom:0.75pt solid #e0e0e0;padding-bottom:3pt;margin:14pt 0 7pt',
};
// 头部:姓名 + 校园信息 + 联系方式(左) + 证件照(右)
const nameColor = cfg.template === 'tech' ? accent : '#111';
const schoolLine = [P.school, P.college, P.major, P.degree].filter(v => v !== '').map(v => esc(v)).join(' · ');
const contactLine = [P.phone, P.email, P.homepage].filter(v => v !== '').map(v => esc(v)).join(' · ');
const photo = photoUrl && P.photoUrl
? '<td style="width:' + (90 * scale).toFixed(0) + 'pt;text-align:right;vertical-align:top">' +
'<img src="' + esc(P.photoUrl) + '" alt="证件照" style="width:' + (90 * scale).toFixed(0) + 'pt;height:' + (120 * scale).toFixed(0) + 'pt;object-fit:cover" /></td>'
: '';
const head = '<table style="width:100%;border-collapse:collapse"><tr>' +
'<td style="vertical-align:top">' +
'<div style="font-size:' + pt(20) + ';font-weight:bold;color:' + nameColor + '">' + esc(P.name || '个人简历') + '</div>' +
(schoolLine ? '<div style="color:#555;font-size:' + pt(11) + ';margin-top:3pt">' + schoolLine + '</div>' : '') +
(contactLine ? '<div style="color:#555;font-size:' + pt(11) + ';margin-top:2pt">' + contactLine + '</div>' : '') +
'</td>' + photo + '</tr></table>';
// 条目:标题(左) + 日期(右) 表格行 + 副标题 + 描述
const itemHtml = (it: { title: string; subtitle?: string; date?: string; desc?: string }) =>
'<table style="width:100%;border-collapse:collapse;margin-top:' + (cfg.density === 'compact' ? 4 : 7) + 'pt"><tr>' +
'<td style="font-weight:bold;font-size:' + pt(12) + '">' + esc(it.title) + '</td>' +
'<td style="text-align:right;color:#666;font-size:' + pt(10.5) + ';white-space:nowrap">' + esc(it.date ?? '') + '</td></tr></table>' +
(it.subtitle ? '<div style="color:#666;font-size:' + pt(10.5) + '">' + esc(it.subtitle) + '</div>' : '') +
(it.desc ? '<div style="color:#444;font-size:' + pt(11) + ';line-height:1.55;margin-top:2pt">' + esc(it.desc) + '</div>' : '');
const secHtml = sections.map(sec => {
let inner = '';
if (sec.kind === 'info') {
if (sec.kind === 'edu') {
const rows: [string, string][] = ([
['姓名', P0(profile).name], ['性别', P0(profile).gender], ['出生年月', P0(profile).birth],
['民族', P0(profile).nation], ['籍贯', P0(profile).native], ['政治面貌', P0(profile).political],
['学校', P0(profile).school], ['学院', P0(profile).college], ['专业', P0(profile).major],
['班级', P0(profile).className], ['学号', P0(profile).studentId], ['学历', P0(profile).degree],
['手机', P0(profile).phone], ['邮箱', P0(profile).email],
['学校', P.school ?? ''], ['专业', P.major ?? ''], ['学制', P.eduLength ?? ''],
['学历', P.degree ?? ''], ['入学', P.eduStart ?? ''], ['毕业', P.eduEnd ?? ''],
] as [string, string][]).filter(r => r[1] !== '');
inner = '<table style="width:100%;border-collapse:collapse;font-size:' + pt(12) + '">' +
rows.map(([k, v], i) =>
rows.map(kv =>
'<tr><td style="width:80px;padding:3px 6px;color:#555">' + esc(kv[0]) + '</td>' +
'<td style="padding:3px 6px">' + esc(kv[1]) + '</td></tr>').join('') + '</table>' +
(P.majorCourses ? '<div style="margin-top:6pt;font-size:' + pt(11.5) + ';line-height:1.6"><b>主修课程:</b>' + esc(P.majorCourses) + '</div>' : '');
} else if (sec.kind === 'info') {
const rows: [string, string][] = ([
['姓名', P.name ?? ''], ['性别', P.gender ?? ''], ['出生年月', P.birth ?? ''],
['民族', P.nation ?? ''], ['籍贯', P.native ?? ''], ['政治面貌', P.political ?? ''],
['班级', 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:' + pt(12) + '">' +
rows.map((kv, i) =>
(i % 2 === 0 ? '<tr>' : '') +
'<td style="width:80px;padding:3px 6px;color:#555">' + esc(k) + '</td>' +
'<td style="padding:3px 6px">' + esc(v) + '</td>' +
(i % 2 === 1 ? '</tr>' : '')
).join('') + '</table>';
} else if (sec.kind === 'edu') {
const rows: [string, string][] = ([
['学校', profile.school ?? ''], ['专业', profile.major ?? ''], ['学制', profile.eduLength ?? ''],
['学历', profile.degree ?? ''], ['入学', profile.eduStart ?? ''], ['毕业', profile.eduEnd ?? ''],
] as [string, string][]).filter(r => r[1] !== '');
inner = '<table style="width:100%;border-collapse:collapse;font-size:' + pt(12) + '">' +
rows.map(function (kv) {
return '<tr><td style="width:80px;padding:3px 6px;color:#555">' + esc(kv[0]) + '</td>' +
'<td style="padding:3px 6px">' + esc(kv[1]) + '</td></tr>';
}).join('') + '</table>' +
(profile.majorCourses ? '<div style="margin-top:6pt"><b style="font-size:' + pt(11) + '">主修课程:</b>' +
'<span style="font-size:' + pt(11.5) + '">' + esc(profile.majorCourses) + '</span></div>' : '');
'<td style="width:80px;padding:3px 6px;color:#555">' + esc(kv[0]) + '</td>' +
'<td style="padding:3px 6px">' + esc(kv[1]) + '</td>' +
(i % 2 === 1 ? '</tr>' : '')).join('') + '</table>';
} else if (sec.kind === 'list') {
inner = '<div style="margin-bottom:6pt">' + (sec.items ?? []).map(it => {
const sub = it.subtitle ? '<div style="color:#666;font-size:' + pt(10.5) + '">' + esc(it.subtitle) + '</div>' : '';
const desc = it.desc ? '<div style="color:#444;font-size:' + pt(11) + ';line-height:1.5">' + esc(it.desc) + '</div>' : '';
return '<div style="margin-bottom:7pt">' +
'<div style="display:flex;justify-content:space-between"><b style="font-size:' + pt(12) + '">' + esc(it.title) + '</b>' +
'<span style="color:#666;font-size:' + pt(10.5) + '">' + esc(it.date ?? '') + '</span></div>' +
sub + desc + '</div>';
}).join('') + '</div>';
inner = '<div>' + (sec.items ?? []).map(itemHtml).join('') + '</div>';
} else {
inner = '<p style="font-size:' + pt(12) + ';line-height:1.7;margin:0">' + esc(sec.text ?? '') + '</p>';
inner = '<p style="font-size:' + pt(12) + ';line-height:1.7;margin:0">' + esc(sec.text ?? '').replace(/\n/g, '<br/>') + '</p>';
}
return '<h2 style="' + titleStyle[cfg.template] + '">' + esc(sec.title) + '</h2>' + inner;
return '<div style="' + titleCss[cfg.template] + '">' + esc(sec.title) + '</div>' + inner;
}).join('');
const photo = photoUrl && profile.photoUrl
? '<div style="text-align:right"><img src="' + esc(profile.photoUrl) + '" alt="证件照" style="width:' + (90 * scale).toFixed(0) + 'pt;height:' + (120 * scale).toFixed(0) + 'pt;object-fit:cover" /></div>'
: '';
const headSub = [P0(profile).school, P0(profile).college, P0(profile).major, P0(profile).degree].filter(v => v !== '').map(v => esc(v)).join(' · ');
return '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">' +
'<head><meta charset="utf-8"><title>' + esc(P0(profile).name || '个人简历') + '</title></head>' +
'<body style="font-family:SimSun,Calibri,sans-serif;margin:36pt;color:#222">' +
'<div style="display:flex;justify-content:space-between;align-items:flex-start">' +
'<h1 style="font-size:' + pt(20) + ';margin:0 0 4pt;color:' + (cfg.template === 'tech' ? accent : '#111') + '">' + esc(P0(profile).name || '个人简历') + '</h1>' + photo + '</div>' +
(headSub ? '<div style="color:#555;font-size:' + pt(11) + ';margin-bottom:8pt">' + headSub + '</div>' : '') +
secHtml +
'</body></html>';
'<head><meta charset="utf-8"><title>' + esc(P.name || '个人简历') + '</title>' +
'<!--[if gte mso 9]><xml><w:WordDocument><w:View>Print</w:View></w:WordDocument></xml><![endif]--></head>' +
'<body style="font-family:Calibri,SimSun,sans-serif;margin:36pt;color:#222">' + head + secHtml + '</body></html>';
}
// 避免循环引用的小工具
function P0(p: Partial<ProfileInfo> | undefined): Partial<ProfileInfo> {
return p ?? {};
}
+5 -2
View File
@@ -157,12 +157,15 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
}
/* ---------- 简历打印 / 导出 ---------- */
@page { size: A4; margin: 10mm; }
@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; }
.app-scroll, .print-scroll { overflow: visible !important; height: auto !important; max-height: none !important; }
.print-sheet { border: none !important; box-shadow: none !important; margin: 0 !important; max-width: none !important; width: 100% !important; padding: 0 !important; border-radius: 0 !important; }
* { scrollbar-width: none !important; }
*::-webkit-scrollbar { width: 0 !important; height: 0 !important; display: none !important; }
}
/* ---------- 简历模板 ---------- */