feat(resume): 新增个人能力图/简历页单文件(resume.html)+域名server块
CI/CD Deploy (Web) / 构建前端镜像并部署 (push) Successful in 21s

This commit is contained in:
张家训
2026-10-23 16:42:30 +08:00
parent 8a45a5dfff
commit 8337e4223c
2 changed files with 605 additions and 0 deletions
+30
View File
@@ -76,6 +76,36 @@ server {
}
}
# -------------------------------------------------------------
# resume.jiaxun.site:个人能力图 / 简历页(纯静态单文件,无接口)
# 根路径直接出 resume.html;也可直接访问 /resume.html
# 前提:上游(VPS openresty)反代时需传 Host 头(proxy_set_header Host $host)
# -------------------------------------------------------------
server {
listen 80;
server_name resume.jiaxun.site;
root /usr/share/nginx/html;
index resume.html;
# 根路径与 /resume.html 都直出简历页
location = / {
try_files /resume.html =404;
}
location = /resume.html {
try_files /resume.html =404;
}
location / {
try_files $uri =404;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
# ---------------- 主站(IP:8880 及其他域名直达) ----------------
server {
listen 80 default_server;
+575
View File
@@ -0,0 +1,575 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#4f6ef7">
<title>张家训 · 个人能力图 / 简历</title>
<style>
/* ===== 主题变量(浅色靛蓝系,与 freedom 平台主色同源) ===== */
:root {
--indigo: #4f6ef7;
--indigo-2: #7c5cf0;
--ink: #161d33;
--text: #2b3448;
--muted: #6b7690;
--line: #e4e8f2;
--line-strong: #d3d9e8;
--surface: #ffffff;
--surface-2: #f6f8fc;
--surface-3: #eef2fa;
--accent-soft: rgba(79, 110, 247, .1);
--radius: 14px;
--shadow-sm: 0 1px 3px rgba(20, 27, 48, .06);
--shadow-md: 0 6px 20px rgba(20, 27, 48, .09);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--surface-2);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
font-size: 14px; line-height: 1.7;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { margin: 0; color: var(--ink); }
p { margin: 0; }
/* ===== 页面骨架 ===== */
.page { max-width: 940px; margin: 0 auto; padding: 28px 20px 56px; }
.card {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow-sm); padding: 22px 24px; margin-bottom: 18px;
}
.sec-title {
display: flex; align-items: center; gap: 9px;
font-size: 15.5px; font-weight: 700; letter-spacing: .3px; margin-bottom: 16px;
}
.sec-title::before {
content: ''; width: 4px; height: 16px; border-radius: 2px;
background: linear-gradient(180deg, var(--indigo), var(--indigo-2));
}
/* ===== 页头 ===== */
.hero { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; }
.avatar {
width: 78px; height: 78px; flex-shrink: 0; border-radius: 20px;
background: linear-gradient(135deg, var(--indigo), var(--indigo-2));
color: #fff; font-size: 30px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 6px 18px rgba(79, 110, 247, .32);
}
.hero-main { flex: 1; min-width: 240px; }
.hero-name { font-size: 26px; font-weight: 800; letter-spacing: .5px; }
.hero-role { font-size: 14.5px; color: var(--indigo); font-weight: 600; margin-top: 2px; }
.hero-meta { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 8px; color: var(--muted); font-size: 13px; }
.hero-actions { display: flex; gap: 10px; align-items: center; }
.btn {
border: 1px solid var(--line-strong); background: var(--surface); color: var(--text);
padding: 8px 16px; border-radius: 10px; font-size: 13.5px; cursor: pointer;
font-family: inherit; transition: all .18s; display: inline-flex; align-items: center; gap: 7px;
}
.btn:hover { border-color: var(--indigo); color: var(--indigo); }
.btn-primary {
background: linear-gradient(135deg, var(--indigo), var(--indigo-2));
border-color: transparent; color: #fff; font-weight: 600;
box-shadow: 0 4px 12px rgba(79, 110, 247, .28);
}
.btn-primary:hover { color: #fff; filter: brightness(1.06); }
/* ===== 能力雷达图 ===== */
.radar-wrap { display: flex; gap: 26px; align-items: center; flex-wrap: wrap; }
.radar-box { flex: 0 0 320px; max-width: 100%; }
.radar-note { flex: 1; min-width: 260px; }
.radar-note p { color: var(--text); margin-bottom: 10px; }
.radar-note .tags { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 12px; }
.tag {
background: var(--accent-soft); color: var(--indigo); border-radius: 20px;
padding: 3px 11px; font-size: 12.5px; font-weight: 600;
}
svg.radar { width: 100%; height: auto; display: block; }
.radar-grid { fill: none; stroke: var(--line-strong); stroke-width: 1; }
.radar-axis { stroke: var(--line); stroke-width: 1; }
.radar-area {
fill: rgba(79, 110, 247, .22); stroke: var(--indigo); stroke-width: 2; stroke-linejoin: round;
}
.radar-dot { fill: #fff; stroke: var(--indigo); stroke-width: 2; }
.radar-label { fill: var(--text); font-size: 12px; font-weight: 600; }
.radar-score { fill: var(--muted); font-size: 10.5px; }
/* ===== 技能栈 ===== */
.skill-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px 26px; }
.skill-cat h4 {
margin: 0 0 10px; font-size: 13.5px; font-weight: 700; color: var(--ink);
display: flex; align-items: center; gap: 7px;
}
.skill-cat h4 .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--indigo); }
.skill-row { margin-bottom: 9px; }
.skill-row .top { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 4px; }
.skill-row .name { color: var(--text); font-weight: 500; }
.skill-row .lv { color: var(--muted); font-size: 12px; }
.bar { height: 6px; background: var(--surface-3); border-radius: 4px; overflow: hidden; }
.bar > i {
display: block; height: 100%; border-radius: 4px; width: 0;
background: linear-gradient(90deg, var(--indigo), var(--indigo-2));
transition: width .9s cubic-bezier(.2, .8, .3, 1);
}
/* ===== 时间线 ===== */
.timeline { position: relative; padding-left: 22px; }
.timeline::before {
content: ''; position: absolute; left: 5px; top: 6px; bottom: 6px; width: 2px;
background: linear-gradient(180deg, var(--indigo), var(--line));
}
.tl-item { position: relative; padding-bottom: 18px; break-inside: avoid; }
.tl-item:last-child { padding-bottom: 0; }
.tl-item::before {
content: ''; position: absolute; left: -22px; top: 7px; width: 12px; height: 12px;
border-radius: 50%; background: #fff; border: 2px solid var(--indigo);
}
.tl-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.tl-title { font-size: 14.5px; font-weight: 700; color: var(--ink); }
.tl-role { font-size: 13px; color: var(--indigo); font-weight: 600; }
.tl-time { font-size: 12.5px; color: var(--muted); margin-left: auto; }
.tl-desc { margin-top: 6px; color: var(--text); }
.tl-points { margin: 8px 0 0; padding-left: 18px; }
.tl-points li { margin-bottom: 4px; }
.tl-stack { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
background: var(--surface-3); color: var(--muted); border-radius: 6px;
padding: 2px 8px; font-size: 12px; font-family: ui-monospace, Menlo, Consolas, monospace;
}
.todo-tip {
background: #fff8e6; border: 1px dashed #f0c674; color: #92620a;
border-radius: 10px; padding: 10px 14px; font-size: 13px; margin-bottom: 14px;
}
.foot { text-align: center; color: var(--muted); font-size: 12.5px; margin-top: 26px; }
/* ===== 提示条 ===== */
.toast {
position: fixed; left: 50%; bottom: 34px; transform: translate(-50%, 20px);
background: var(--ink); color: #fff; padding: 10px 20px; border-radius: 10px;
font-size: 13.5px; opacity: 0; pointer-events: none; transition: all .25s; z-index: 99;
}
.toast.on { opacity: 1; transform: translate(-50%, 0); }
/* ===== 响应式 ===== */
@media (max-width: 640px) {
.page { padding: 18px 14px 40px; }
.card { padding: 18px; }
.hero { gap: 16px; }
.avatar { width: 62px; height: 62px; font-size: 24px; border-radius: 16px; }
.hero-name { font-size: 22px; }
.radar-box { flex: 1 1 100%; }
.tl-time { margin-left: 0; }
}
/* ===== 打印(A4 投递) ===== */
@media print {
@page { size: A4; margin: 12mm; }
body { background: #fff; font-size: 12.5px; }
.page { max-width: 100%; padding: 0; }
.card { border-color: #dfe3ee; box-shadow: none; margin-bottom: 10px; padding: 14px 16px; }
.no-print, .toast { display: none !important; }
.tl-item, .skill-cat, .card { break-inside: avoid; }
.hero { gap: 14px; }
.bar > i { transition: none; }
a { color: inherit; text-decoration: none; }
}
</style>
</head>
<body>
<div class="page">
<!-- 页头 -->
<div class="card hero-card">
<div class="hero">
<div class="avatar" id="avatar">张</div>
<div class="hero-main">
<h1 class="hero-name" id="name">张家训</h1>
<div class="hero-role" id="role">Java 全栈开发工程师</div>
<div class="hero-meta" id="meta"></div>
</div>
<div class="hero-actions no-print">
<button class="btn btn-primary" id="exportBtn">导出 PDF</button>
</div>
</div>
</div>
<!-- 能力雷达图 -->
<div class="card">
<h2 class="sec-title">能力图</h2>
<div class="radar-wrap">
<div class="radar-box">
<svg class="radar" id="radar" viewBox="0 0 340 320"></svg>
</div>
<div class="radar-note" id="radarNote"></div>
</div>
</div>
<!-- 技能栈 -->
<div class="card">
<h2 class="sec-title">技能栈</h2>
<div class="skill-grid" id="skillGrid"></div>
</div>
<!-- 工作经历 -->
<div class="card">
<h2 class="sec-title">工作经历</h2>
<div id="workBox"></div>
</div>
<!-- 项目经历 -->
<div class="card">
<h2 class="sec-title">项目经历</h2>
<div class="timeline" id="projectList"></div>
</div>
<div class="foot" id="foot"></div>
</div>
<div class="toast" id="toast"></div>
<script>
/* ==================================================================
* 数据区:所有内容集中在这里,改这里即可,不用动下面的渲染代码
* 评分统一 0-100;时间用字符串,按你需要展示
* ================================================================== */
var DATA = {
profile: {
name: '张家训',
initial: '张', // 头像显示的字
role: 'Java 全栈开发工程师 · 工具与效率平台建设',
meta: [
'Gitea:gitea.jiaxun.site(yiciheng)',
'邮箱:待补充',
'电话:待补充',
'所在城市:待补充'
]
},
// 能力雷达:维度名 + 评分(0-100),建议 4-8 项
radar: {
dims: [
{ name: '后端开发', score: 92 },
{ name: '前端开发', score: 76 },
{ name: '数据库与建模', score: 82 },
{ name: '架构与工程化', score: 86 },
{ name: '运维与部署', score: 83 },
{ name: '工具与自动化', score: 90 }
],
summary: '以 Java 后端为主线,向上能独立交付前端页面,向下能自己搭 CI/CD 与服务器环境;' +
'擅长把重复劳动沉淀成工具(桌面工具平台、JAR 裁剪分析、本地语音转写工具),' +
'偏好多用工程化手段一次性解决问题。',
tags: ['Spring Boot', 'MyBatis-Plus', 'Vue2', 'Docker + CI/CD', 'JavaFX 桌面工具', 'Python 自动化']
},
// 技能栈:分类 + 每项熟练度(0-100)
skills: [
{
cat: '编程语言',
items: [
{ name: 'Java', lv: 95, note: '主力语言,熟悉 JDK 8 / 17 差异' },
{ name: 'Python', lv: 72, note: '自动化脚本与桌面小工具' },
{ name: 'JavaScript', lv: 74, note: '原生 JS + Vue2' },
{ name: 'SQL', lv: 80, note: '复杂查询、建模与迁移' }
]
},
{
cat: '后端框架',
items: [
{ name: 'Spring Boot', lv: 92, note: '2.7.x,含 Security + JWT 鉴权' },
{ name: 'MyBatis-Plus', lv: 88, note: '分页插件、自定义 SQL、代码生成' },
{ name: 'Dubbo', lv: 68, note: '接口文档梳理与调用链路分析' },
{ name: 'Maven / 多模块', lv: 80, note: '依赖治理与打包体积优化' }
]
},
{
cat: '前端与界面',
items: [
{ name: 'Vue2 + vue-cli', lv: 80, note: '路由守卫、动态菜单、组件封装' },
{ name: 'view-design', lv: 78, note: '表格/表单/弹窗,含样式与交互定制' },
{ name: '原生 HTML/CSS/JS', lv: 86, note: '零依赖单文件页面(看板、能力图)' },
{ name: 'JavaFX', lv: 82, note: '桌面工具界面与响应式布局' }
]
},
{
cat: '数据与文件',
items: [
{ name: 'MySQL 8', lv: 85, note: '索引、时区与字符集坑位排查' },
{ name: 'EasyExcel / POI', lv: 80, note: '大批量导出与模板填充' },
{ name: '数据建模', lv: 78, note: '权限模型、状态机表设计' }
]
},
{
cat: '工程化 · 运维',
items: [
{ name: 'Docker / Compose', lv: 85, note: '自建服务编排与容器排障' },
{ name: 'Nginx / OpenResty', lv: 78, note: '反向代理、多域名、gzip' },
{ name: 'Gitea + Actions CI/CD', lv: 84, note: 'push master 自动构建部署' },
{ name: 'frp 内网穿透', lv: 75, note: '公网入口与连接池调优' },
{ name: 'Linux(Ubuntu)', lv: 80, note: '1Panel 运维、日志与容器排障' }
]
},
{
cat: '自动化与工具',
items: [
{ name: 'launch4j / jlink', lv: 76, note: '桌面应用打包与体积裁剪' },
{ name: 'faster-whisper 本地 ASR', lv: 66, note: '语音转写 + 规则引擎增强' },
{ name: 'PyQt6 / sounddevice', lv: 68, note: '悬浮窗与音频采集' },
{ name: 'AI 辅助工程实践', lv: 85, note: '用大模型提效开发与排障' }
]
}
],
// 工作经历(任职信息待补充)
work: [
{
company: '(公司名待补充)',
role: 'Java 开发工程师',
time: '(起止时间待补充)',
desc: '负责业务系统开发与维护,参与需求拆解、编码实现与线上问题排查。',
points: [
'(请补充:核心职责与量化成果,例如承接哪个系统、日/月处理量级、性能提升幅度)',
'(请补充:团队角色,如带几个人、负责哪块模块)'
]
}
],
// 项目经历
projects: [
{
name: 'freedom 管理系统平台',
role: '全栈(独立开发)',
time: '2026',
desc: '自用的管理系统平台,前后端分离,覆盖系统管理与待办场景,部署在家里的服务器并经内网穿透对外提供服务。',
points: [
'后端 Spring Boot + MyBatis-Plus,实现用户/角色/权限/部门体系,权限统一由 JWT 解析,不依赖前端传参',
'前端 Vue2 + view-design,动态菜单、路由守卫、标签页导航',
'自建 Gitea + Actions 流水线,push master 自动构建镜像并部署,前后端各自独立发布',
'登录页首屏 JS 从 4.5MB 优化到 248KB(gzip + 清理无用依赖),公网加载约 2s',
'清理历史冗余模块(改枪码、kpi 等)与零使用依赖,保持代码库精简'
],
stack: ['Spring Boot', 'MyBatis-Plus', 'Vue2', 'view-design', 'Docker', 'Gitea Actions', 'Nginx']
},
{
name: '公开待办看板',
role: '全栈(独立开发)',
time: '2026',
desc: '免登录对外分享的待办看板,单文件零依赖页面,支持富文本备注与主题切换。',
points: [
'纯原生实现自绘下拉与日历组件,避开原生控件无法美化的问题',
'自研轻量富文本编辑器(contenteditable + execCommand),保存与渲染双次白名单净化防存储型 XSS',
'DATE 字段改用字符串映射,根治 JDBC 时区换算导致的日期差一天问题',
'全操作加 loading 与防重复提交,弱网下弱感知延迟'
],
stack: ['原生 JS', 'CSS 变量主题', 'Spring Boot', 'MySQL']
},
{
name: 'WorkUtils 桌面工具平台',
role: '全栈(独立开发)',
time: '2026',
desc: '基于 Spring Boot + JavaFX 的桌面辅助工具,插件化承载多个开发提效能力。',
points: [
'平台层与业务层分离,内置 ApiDoc、Scanner、MyBatis 导出、Diff、GitSync 等插件',
'解决 Windows .git 只读属性导致删除失败、readLine 阻塞 waitFor 等并发与文件系统问题',
'用 launch4j 打包为独立 EXE(内嵌 JRE),依赖裁剪后体积从 88.6MB 降到 61.5MB(-30.6%)',
'修复多项 UI 响应式问题(导航栏自适应、弹窗空白、字体跟随系统)'
],
stack: ['Java 17', 'JavaFX', 'Spring Boot', 'EasyExcel', 'launch4j', 'jlink']
},
{
name: '《三角洲行动》报点增强器',
role: '独立开发',
time: '2026',
desc: '游戏语音报点的实时增强工具:热键录音 → 本地语音识别 → 规则引擎增强 → 悬浮窗展示。',
points: [
'CapsLock 全局热键触发录音,faster-whisper 本地 ASR 转写,无需联网',
'规则引擎做术语与别名互认,内置地图知识库',
'PyQt6 无边框悬浮窗展示结果,低干扰'
],
stack: ['Python', 'faster-whisper', 'sounddevice', 'PyQt6']
},
{
name: 'ApiTrimmer / 拨款状态机重构',
role: '独立开发',
time: '2026',
desc: '面向既有代码的分析与重构类工作。',
points: [
'ApiTrimmer:分析 JAR 包依赖与裁剪空间的工具',
'拨款(disburse)状态机模块向决策表驱动架构转型,降低分支维护成本',
'输出 Dubbo 接口文档与代码调用链路,支撑存量系统梳理'
],
stack: ['Java', 'Dubbo', '状态机', '决策表']
}
],
foot: '本页为单文件静态页面,数据集中在 HTML 顶部的 DATA 区,修改后刷新即可生效'
};
/* ==================== 渲染逻辑(一般无需修改) ==================== */
var $ = function (id) { return document.getElementById(id) };
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
/* ---------- 页头 ---------- */
function renderProfile() {
var p = DATA.profile;
$('avatar').textContent = p.initial || (p.name || ' ').charAt(0);
$('name').textContent = p.name;
$('role').textContent = p.role;
$('meta').innerHTML = (p.meta || []).map(function (m) {
return '<span>' + esc(m) + '</span>';
}).join('');
}
/* ---------- 雷达图(纯 SVG 自绘,不引图表库) ---------- */
function renderRadar() {
var dims = DATA.radar.dims;
var cx = 170, cy = 150, r = 105, n = dims.length;
var svg = $('radar');
var parts = [];
// 背景网格:4 层同心多边形
for (var g = 4; g >= 1; g--) {
var pts = [];
for (var i = 0; i < n; i++) {
var a = -Math.PI / 2 + i * 2 * Math.PI / n;
var rr = r * g / 4;
pts.push((cx + rr * Math.cos(a)).toFixed(1) + ',' + (cy + rr * Math.sin(a)).toFixed(1));
}
parts.push('<polygon class="radar-grid" points="' + pts.join(' ') + '"/>');
}
// 轴线与标签
for (var j = 0; j < n; j++) {
var ang = -Math.PI / 2 + j * 2 * Math.PI / n;
var ex = cx + r * Math.cos(ang), ey = cy + r * Math.sin(ang);
parts.push('<line class="radar-axis" x1="' + cx + '" y1="' + cy + '" x2="' + ex.toFixed(1) + '" y2="' + ey.toFixed(1) + '"/>');
// 标签位置:按角度决定对齐方式,避免贴在边上
var lx = cx + (r + 26) * Math.cos(ang), ly = cy + (r + 26) * Math.sin(ang);
var anchor = Math.abs(Math.cos(ang)) < 0.2 ? 'middle' : (Math.cos(ang) > 0 ? 'start' : 'end');
parts.push('<text class="radar-label" x="' + lx.toFixed(1) + '" y="' + (ly + 4).toFixed(1) + '" text-anchor="' + anchor + '">' +
esc(dims[j].name) + '</text>');
parts.push('<text class="radar-score" x="' + lx.toFixed(1) + '" y="' + (ly + 18).toFixed(1) + '" text-anchor="' + anchor + '">' +
dims[j].score + '</text>');
}
// 数据区多边形 + 顶点
var vals = [], dots = [];
for (var k = 0; k < n; k++) {
var ak = -Math.PI / 2 + k * 2 * Math.PI / n;
var rk = r * (dims[k].score / 100);
var px = cx + rk * Math.cos(ak), py = cy + rk * Math.sin(ak);
vals.push(px.toFixed(1) + ',' + py.toFixed(1));
dots.push('<circle class="radar-dot" cx="' + px.toFixed(1) + '" cy="' + py.toFixed(1) + '" r="3.5"/>');
}
parts.push('<polygon class="radar-area" points="' + vals.join(' ') + '"/>');
parts.push(dots.join(''));
svg.innerHTML = parts.join('');
// 右侧概述
var note = DATA.radar;
$('radarNote').innerHTML =
'<p>' + esc(note.summary) + '</p>' +
'<div class="tags">' + (note.tags || []).map(function (t) {
return '<span class="tag">' + esc(t) + '</span>';
}).join('') + '</div>';
}
/* ---------- 技能栈 ---------- */
function renderSkills() {
$('skillGrid').innerHTML = DATA.skills.map(function (cat) {
var rows = cat.items.map(function (it) {
return '<div class="skill-row">'
+ '<div class="top"><span class="name">' + esc(it.name) + '</span>'
+ '<span class="lv">' + (it.note ? esc(it.note) + ' · ' : '') + it.lv + '</span></div>'
+ '<div class="bar"><i data-w="' + it.lv + '"></i></div>'
+ '</div>';
}).join('');
return '<div class="skill-cat"><h4><span class="dot"></span>' + esc(cat.cat) + '</h4>' + rows + '</div>';
}).join('');
// 进度条动画:下一帧再赋宽度才有过渡效果
requestAnimationFrame(function () {
Array.prototype.forEach.call(document.querySelectorAll('.bar > i'), function (el) {
el.style.width = el.dataset.w + '%';
});
});
}
/* ---------- 工作经历 ---------- */
function renderWork() {
var pending = DATA.work.some(function (w) { return /待补充/.test(w.company + w.time) });
var tip = pending
? '<div class="todo-tip no-print">任职信息(公司、起止时间、量化成果)待补充,改 DATA.work 即可;打印时本提示不会输出。</div>'
: '';
$('workBox').innerHTML = tip + '<div class="timeline">' + DATA.work.map(function (w) {
return '<div class="tl-item">'
+ '<div class="tl-head"><span class="tl-title">' + esc(w.company) + '</span>'
+ '<span class="tl-role">' + esc(w.role) + '</span>'
+ '<span class="tl-time">' + esc(w.time) + '</span></div>'
+ '<div class="tl-desc">' + esc(w.desc) + '</div>'
+ '<ul class="tl-points">' + (w.points || []).map(function (p) {
return '<li>' + esc(p) + '</li>';
}).join('') + '</ul>'
+ (w.stack ? '<div class="tl-stack">' + w.stack.map(function (s) {
return '<span class="chip">' + esc(s) + '</span>';
}).join('') + '</div>' : '')
+ '</div>';
}).join('') + '</div>';
}
/* ---------- 项目经历 ---------- */
function renderProjects() {
$('projectList').innerHTML = DATA.projects.map(function (p) {
return '<div class="tl-item">'
+ '<div class="tl-head"><span class="tl-title">' + esc(p.name) + '</span>'
+ '<span class="tl-role">' + esc(p.role) + '</span>'
+ '<span class="tl-time">' + esc(p.time) + '</span></div>'
+ '<div class="tl-desc">' + esc(p.desc) + '</div>'
+ '<ul class="tl-points">' + (p.points || []).map(function (x) {
return '<li>' + esc(x) + '</li>';
}).join('') + '</ul>'
+ '<div class="tl-stack">' + p.stack.map(function (s) {
return '<span class="chip">' + esc(s) + '</span>';
}).join('') + '</div>'
+ '</div>';
}).join('');
}
/* ---------- 提示与导出 ---------- */
var toastTimer = null;
function toast(msg) {
var el = $('toast');
el.textContent = msg;
el.classList.add('on');
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { el.classList.remove('on') }, 2200);
}
$('exportBtn').addEventListener('click', function () {
toast('已唤起打印,目标选择「另存为 PDF」即可');
setTimeout(function () { window.print() }, 320);
});
/* ---------- 启动 ---------- */
renderProfile();
renderRadar();
renderSkills();
renderWork();
renderProjects();
$('foot').textContent = DATA.foot;
document.title = DATA.profile.name + ' · 个人能力图 / 简历';
</script>
</body>
</html>