Files

1056 lines
43 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* =============================================================================
* Resume — 可编辑简历构建器
* Interactive Resume Builder
* =============================================================================
*
* 功能概要:
* 1. 浏览器端直接编辑 — 所有文字 contenteditable,所见即所得
* 2. 动态增删条目 — 工作经历、项目经历、技能标签、侧边栏自定义栏目
* 3. 24 套颜色主题 — 独立配色方案,◀ ▶ 左右切换
* 4. 12 套设计版式 — 布局、装饰、间距变化,◀ ▶ 左右切换
* 5. 打印导出 PDF — @media print 自动隐藏编辑 UI,多页自然分页
* 6. XML 导入导出 — 完整数据 + 主题 + 版式全部序列化
* 7. localStorage 自动保存 — 关闭浏览器不丢数据
*
* 架构:
* - 单例 IIFE 模块,挂载到 window.Resume
* - 数据模型:snapshot() 采集 → restore() 还原,闭环
* - 持久化:每次编辑 400ms debounce 写 localStorage
* - 主题系统:CSS 自定义属性 + body class 双通道
*
* 文件依赖:
* - index.html — DOM 结构(通过 onclick 绑定 Resume.xxx 方法)
* - style.css — 基础样式 + 12 套设计主题 + 打印样式
* - app.js — 本文件,全部交互逻辑
*
* 使用方式:浏览器直接打开 index.html 即可
* =============================================================================
*/
const Resume = (() => {
'use strict';
// ===========================================================================
// 常量定义
// ===========================================================================
/** localStorage 键名 */
const STORAGE_KEY = 'resume_data';
// ===========================================================================
// 颜色主题 — 24 套预设配色
//
// 每套主题定义一组 CSS 自定义属性(变量),通过 document.documentElement
// 上的 style.setProperty 动态注入。变量含义:
//
// --ink 主文字色
// --ink-muted 次要文字色(日期、副标题等)
// --accent 强调色(标题下划线、按钮、要点标记)
// --accent-light 强调色浅版(技能标签背景、hover 态)
// --border 边框色
// --bg 页面卡片背景
// --surface 次表面色(卡片背景、hover 态)
// --body-bg 屏幕端最外层背景
// --font-sans 无衬线字体栈
//
// 打印时不受颜色主题影响(@media print 独立样式)
// ===========================================================================
const COLOR_THEMES = [
{ name:'经典蓝', vars:{'--ink':'#1a1a1a','--ink-muted':'#555','--accent':'#1e5fa8','--accent-light':'#e8f0fa','--border':'#ddd','--bg':'#fff','--surface':'#f8f9fa','--body-bg':'#e8ecf1','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'暗夜黑', vars:{'--ink':'#e0e0e0','--ink-muted':'#999','--accent':'#5b9bd5','--accent-light':'#1e2a3a','--border':'#444','--bg':'#1e1e1e','--surface':'#2a2a2a','--body-bg':'#121212','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'墨绿', vars:{'--ink':'#1a2a1a','--ink-muted':'#5a7a5a','--accent':'#2d6a4f','--accent-light':'#d8f3dc','--border':'#b7c9b7','--bg':'#fafdfa','--surface':'#f0f7f0','--body-bg':'#dde8dd','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'酒红', vars:{'--ink':'#2a1a1a','--ink-muted':'#7a5555','--accent':'#8b1a2b','--accent-light':'#fce8ec','--border':'#d4b8b8','--bg':'#fefafa','--surface':'#faf0f0','--body-bg':'#ece0e0','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'石板灰', vars:{'--ink':'#1f1f1f','--ink-muted':'#666','--accent':'#4a4a4a','--accent-light':'#e8e8e8','--border':'#ccc','--bg':'#fafafa','--surface':'#f0f0f0','--body-bg':'#e0e0e0','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'琥珀暖', vars:{'--ink':'#2a2218','--ink-muted':'#7a6a50','--accent':'#b85c00','--accent-light':'#fff3e0','--border':'#d4c8b0','--bg':'#fffdf8','--surface':'#fff8ee','--body-bg':'#f0e8d8','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'青蓝', vars:{'--ink':'#1a2228','--ink-muted':'#5a7a88','--accent':'#0d7377','--accent-light':'#d4f4f5','--border':'#b8d4d6','--bg':'#f8fdfd','--surface':'#eef8f8','--body-bg':'#d8eaea','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'紫罗兰', vars:{'--ink':'#1f1a28','--ink-muted':'#6a5a88','--accent':'#6b3fa0','--accent-light':'#f0e8fc','--border':'#c8b8e0','--bg':'#fdfafe','--surface':'#f6eefc','--body-bg':'#e8daf0','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'极简黑白', vars:{'--ink':'#000','--ink-muted':'#555','--accent':'#222','--accent-light':'#eee','--border':'#ccc','--bg':'#fff','--surface':'#f8f8f8','--body-bg':'#f0f0f0','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'报纸风', vars:{'--ink':'#1a1a1a','--ink-muted':'#555','--accent':'#8b0000','--accent-light':'#fdf0f0','--border':'#ccc','--bg':'#fefef8','--surface':'#f8f8f0','--body-bg':'#e8e8d8','--font-sans':'"Noto Serif SC","Source Han Serif SC","SimSun",Georgia,serif'} },
{ name:'科技蓝', vars:{'--ink':'#e8f0ff','--ink-muted':'#8ab4f8','--accent':'#4da6ff','--accent-light':'#0d1b3e','--border':'#2a4480','--bg':'#0a1628','--surface':'#112240','--body-bg':'#060d1a','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'暖沙', vars:{'--ink':'#3a3028','--ink-muted':'#8a7a68','--accent':'#c07a40','--accent-light':'#fdf0e4','--border':'#d8c8b8','--bg':'#fefaf6','--surface':'#faf0e6','--body-bg':'#ede0d2','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'深海', vars:{'--ink':'#d0dce8','--ink-muted':'#7a96b0','--accent':'#5ec4d0','--accent-light':'#0f2838','--border':'#2a5068','--bg':'#0c1e2c','--surface':'#152a3c','--body-bg':'#06121c','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'日落橙', vars:{'--ink':'#2a2018','--ink-muted':'#8a6a48','--accent':'#e05530','--accent-light':'#fef0e8','--border':'#e0c8b0','--bg':'#fffaf6','--surface':'#fef4ea','--body-bg':'#f0e2d2','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'薄荷', vars:{'--ink':'#1a2822','--ink-muted':'#5a8a72','--accent':'#2eac7a','--accent-light':'#d8fcec','--border':'#b8d8c8','--bg':'#f8fefa','--surface':'#eefaf4','--body-bg':'#d8ece2','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'薰衣草', vars:{'--ink':'#2a2230','--ink-muted':'#8a7a98','--accent':'#8e6bb8','--accent-light':'#f4ecfc','--border':'#d8c8e8','--bg':'#fdf8ff','--surface':'#f8f0fc','--body-bg':'#ece0f8','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'木炭', vars:{'--ink':'#ddd','--ink-muted':'#999','--accent':'#a0a0a0','--accent-light':'#333','--border':'#555','--bg':'#2a2a2a','--surface':'#333','--body-bg':'#1a1a1a','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'枫叶', vars:{'--ink':'#2a1e18','--ink-muted':'#8a6048','--accent':'#c04020','--accent-light':'#fef0e8','--border':'#e0c0a8','--bg':'#fffaf6','--surface':'#fef4ea','--body-bg':'#f0e0d0','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'冰川', vars:{'--ink':'#1a2428','--ink-muted':'#5a7a88','--accent':'#4a9eb5','--accent-light':'#dff4f8','--border':'#b8d4dc','--bg':'#f8fcfd','--surface':'#eef8fa','--body-bg':'#d8eaf0','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'玫瑰金', vars:{'--ink':'#2a2022','--ink-muted':'#8a6870','--accent':'#c06078','--accent-light':'#fce8f0','--border':'#e8c0cc','--bg':'#fef8fa','--surface':'#fcf0f4','--body-bg':'#f0dce2','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'翡翠', vars:{'--ink':'#e0f0e8','--ink-muted':'#7ab898','--accent':'#4cdb9a','--accent-light':'#0f301e','--border':'#2a6840','--bg':'#0c1e12','--surface':'#152c1c','--body-bg':'#061208','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'午夜蓝', vars:{'--ink':'#c8d8f0','--ink-muted':'#6a8ab8','--accent':'#6b9ce0','--accent-light':'#102240','--border':'#304868','--bg':'#0e1628','--surface':'#182438','--body-bg':'#060c18','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'陶土', vars:{'--ink':'#2a201c','--ink-muted':'#8a6858','--accent':'#c06840','--accent-light':'#fef0e8','--border':'#e0c8b8','--bg':'#fefaf8','--surface':'#fcf2ec','--body-bg':'#f0e2d8','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
{ name:'铂金', vars:{'--ink':'#2a2a2a','--ink-muted':'#888','--accent':'#7a8a9a','--accent-light':'#e8ecf0','--border':'#d0d4d8','--bg':'#fcfcfc','--surface':'#f4f4f6','--body-bg':'#e4e6e8','--font-sans':'"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'} },
];
// ===========================================================================
// 设计主题 — 12 套版式布局
//
// 每套设计主题对应 style.css 中的 .page.theme-xxx 选择器规则。
// 切换时给 .page 元素替换对应 CSS class。
//
// 颜色主题和设计主题是正交的(24 × 12 = 288 种组合)。
// 打印时设计主题不生效,统一使用 @media print 的紧凑单栏布局。
// ===========================================================================
const DESIGN_THEMES = [
{ name:'经典双栏', cls:'theme-classic' }, // 默认,左 180px + 右主内容
{ name:'极简单栏', cls:'theme-minimal' }, // 全宽单栏,侧栏压缩到顶部横排
{ name:'右侧栏', cls:'theme-rightbar' }, // sidebar 在右侧
{ name:'顶栏式', cls:'theme-topbar' }, // 个人信息全宽顶部,下方单栏内容
{ name:'左色条', cls:'theme-accentbar' }, // 侧栏左侧 accent 色 4px 粗竖线
{ name:'宽侧栏', cls:'theme-wideside' }, // 侧栏加宽至 240px
{ name:'紧凑', cls:'theme-compact' }, // 缩小所有间距和字号,提升信息密度
{ name:'卡片式', cls:'theme-cards' }, // 主内容区 section 卡片包裹(圆角+边框+背景)
{ name:'时间轴', cls:'theme-timeline' }, // 经历/项目的日期提取到左侧圆点标记
{ name:'双色块', cls:'theme-duotone' }, // 侧栏带浅色背景色块
{ name:'线条风', cls:'theme-lines' }, // section 间 accent 色粗线分割
{ name:'无框', cls:'theme-frameless' }, // 去掉所有边框和背景装饰线
];
/** 当前颜色主题索引(0-23),持久化到 localStorage */
let colorIdx = 0;
/** 当前设计主题索引(0-11),持久化到 localStorage */
let designIdx = 0;
// ===========================================================================
// 颜色主题控制
// ===========================================================================
/**
* 应用颜色主题
* 将主题的 CSS 变量写入 :root,更新工具栏标签,触发自动保存。
* 支持负数索引(自动 wrap-around)。
*
* @param {number} idx - 主题索引
*/
function applyColor(idx) {
// 取模运算,确保 idx 在 [0, length) 区间
idx = ((idx % COLOR_THEMES.length) + COLOR_THEMES.length) % COLOR_THEMES.length;
colorIdx = idx;
const t = COLOR_THEMES[idx];
const root = document.documentElement;
// 将主题的 CSS 变量写入 :root,覆盖默认值
Object.entries(t.vars).forEach(([k, v]) => root.style.setProperty(k, v));
// 更新工具栏标签
const label = document.getElementById('themeLabel');
if (label) label.textContent = t.name;
save();
}
/** 切换到上一个颜色主题 */
function prevTheme() { applyColor(colorIdx - 1); }
/** 切换到下一个颜色主题 */
function nextTheme() { applyColor(colorIdx + 1); }
/**
* 按名称切换颜色主题(用于 restore / XML 导入)
* @param {string} name - 主题名称,如 '暗夜黑'
*/
function setColorByName(name) {
const idx = COLOR_THEMES.findIndex(t => t.name === name);
if (idx >= 0) applyColor(idx);
}
/** @returns {string} 当前颜色主题名称 */
function getColorName() { return COLOR_THEMES[colorIdx].name; }
// ===========================================================================
// 设计主题控制
// ===========================================================================
/**
* 应用设计主题
* 先移除 .page 上所有旧的设计主题 class,再添加新的。
*
* @param {number} idx - 主题索引
*/
function applyDesign(idx) {
idx = ((idx % DESIGN_THEMES.length) + DESIGN_THEMES.length) % DESIGN_THEMES.length;
designIdx = idx;
const page = document.querySelector('.page');
if (page) {
// 移除所有旧的设计主题 class
DESIGN_THEMES.forEach(d => page.classList.remove(d.cls));
// 添加新的
page.classList.add(DESIGN_THEMES[idx].cls);
}
const label = document.getElementById('designLabel');
if (label) label.textContent = DESIGN_THEMES[idx].name;
save();
}
/** 切换到上一个设计主题 */
function prevDesign() { applyDesign(designIdx - 1); }
/** 切换到下一个设计主题 */
function nextDesign() { applyDesign(designIdx + 1); }
/**
* 按名称切换设计主题(用于 restore / XML 导入)
* @param {string} name - 主题名称,如 '时间轴'
*/
function setDesignByName(name) {
const idx = DESIGN_THEMES.findIndex(d => d.name === name);
if (idx >= 0) applyDesign(idx);
}
/** @returns {string} 当前设计主题名称 */
function getDesignName() { return DESIGN_THEMES[designIdx].name; }
// ===========================================================================
// 数据模型 — DOM → 数据结构
//
// snapshot() 遍历 DOM 树,提取所有 contenteditable 元素的内容,
// 组装成纯数据对象。这是持久化、XML 导出的数据源。
//
// 字段命名约定:
// _theme / _design 元数据(主题选择),不显示在简历上
// 其余字段 对应 HTML 中各 section 的内容
// ===========================================================================
/**
* 拍摄当前简历的完整数据快照
* @returns {Object} 简历数据对象
*/
function snapshot() {
return {
_theme: getColorName(), // 颜色主题名称
_design: getDesignName(), // 设计主题名称
name: qs('h1 > span:first-child')?.innerHTML || '', // 姓名
title: qs('h1 > .sub')?.innerHTML || '', // 职位头衔
contact: collect('.info-item span:last-child'), // 联系方式 [邮箱,手机,城市,链接]
skills: collect('#skillTags .skill-tag', el => // 技能标签(去掉 × 符号)
el.textContent.replace('×','').trim()),
education: { // 教育背景
school: qs('.editable-block h3')?.innerHTML || '',
major: qs('.editable-block .section-sub')?.innerHTML || '',
date: qs('.editable-block .date')?.innerHTML || '',
},
sidebarCustom: collectSidebarCustom(), // 侧边栏自定义栏目
summary: qs('.summary-text')?.innerHTML || '', // 个人概述
experiences: collectCards('experienceList'), // 工作经历
projects: collectCards('projectList'), // 项目经历
extras: collectBullets('extraList'), // 其他条目
};
}
/**
* 通用内容采集器
* 接受 CSS 选择器字符串或已有的 NodeList,返回内容数组。
*
* @param {string|NodeList} sel - CSS 选择器 或 NodeList
* @param {Function} [fn] - 可选的映射函数,对每个元素做转换
* @returns {string[]} 内容数组
*/
function collect(sel, fn) {
const els = (typeof sel === 'string') ? document.querySelectorAll(sel) : sel;
return Array.from(els).map(el => fn ? fn(el) : (el.innerHTML || ''));
}
/**
* 采集经历/项目卡片列表
* 遍历指定容器下所有 .section-card,提取标题、日期、副标题、要点。
*
* @param {string} listId - 容器的 DOM id
* @returns {Array<{title,date,subtitle,bullets}>}
*/
function collectCards(listId) {
const cards = [];
const list = document.getElementById(listId);
if (!list) return cards;
list.querySelectorAll('.section-card').forEach(card => {
cards.push({
title: card.querySelector('h3')?.innerHTML || '',
date: card.querySelector('.date')?.innerHTML || '',
subtitle: card.querySelector('.section-sub')?.innerHTML || '',
// 注意:collect 传入 NodeListcollect 内部会检测类型
bullets: collect(card.querySelectorAll('ul.bullets li')),
});
});
return cards;
}
/**
* 采集纯要点列表(其他条目等无标题的列表)
* @param {string} listId - 容器 DOM id
* @returns {string[]}
*/
function collectBullets(listId) {
const list = document.getElementById(listId);
if (!list) return [];
return collect(list.querySelectorAll('ul.bullets li'));
}
/**
* 采集侧边栏自定义栏目
* @returns {Array<{title, content}>}
*/
function collectSidebarCustom() {
const c = document.getElementById('sidebarCustom');
if (!c) return [];
return Array.from(c.querySelectorAll('.sidebar-custom-section')).map(sec => ({
title: sec.querySelector('.sidebar-custom-title')?.innerHTML || '',
content: sec.querySelector('.sidebar-custom-content')?.innerHTML || '',
}));
}
// ===========================================================================
// 数据还原 — 数据结构 → DOM
//
// restore() 将数据对象写回 DOM,清空并重建所有动态内容。
// 这是 localStorage 加载和 XML 导入的入口。
// ===========================================================================
/**
* 将数据对象还原到 DOM
* @param {Object} data - snapshot() 格式的数据对象
*/
function restore(data) {
if (!data) return;
// 先恢复主题(会触发 save,但之后会继续填充数据)
if (data._theme) setColorByName(data._theme);
if (data._design) setDesignByName(data._design);
// 基础文本字段
setHTML('h1 > span:first-child', data.name);
setHTML('h1 > .sub', data.title);
// 联系方式(4 个固定位置)
const cs = document.querySelectorAll('.info-item span:last-child');
(data.contact || []).forEach((v, i) => { if (cs[i]) cs[i].innerHTML = v; });
// 教育背景
if (data.education) {
setHTML('.editable-block h3', data.education.school);
setHTML('.editable-block .section-sub', data.education.major);
setHTML('.editable-block .date', data.education.date);
}
setHTML('.summary-text', data.summary);
// 技能标签 — 清空后重建
const tc = document.getElementById('skillTags');
if (tc) { tc.innerHTML = ''; (data.skills || []).forEach(s => createSkillTag(tc, s)); }
// 动态列表 — 清空容器后用工厂函数重建
rebuildSidebarCustom(data.sidebarCustom || []);
rebuildCards('experienceList', data.experiences || [], createExperienceCard);
rebuildCards('projectList', data.projects || [], createProjectCard);
rebuildBullets('extraList', data.extras || []);
}
/**
* 安全地设置元素 innerHTML(元素不存在时静默跳过)
* @param {string} sel - CSS 选择器
* @param {string|undefined} html - HTML 内容
*/
function setHTML(sel, html) {
const el = qs(sel);
if (el && html !== undefined) el.innerHTML = html;
}
/** querySelector 快捷方式 */
function qs(sel) { return document.querySelector(sel); }
/**
* 重建卡片列表(工作经历/项目经历)
* @param {string} listId - 容器 DOM id
* @param {Array} items - 卡片数据数组
* @param {Function} fn - 工厂函数,签名 fn(list, item)
*/
function rebuildCards(listId, items, fn) {
const list = document.getElementById(listId);
if (!list) return;
list.innerHTML = '';
items.forEach(item => fn(list, item));
}
/**
* 重建纯要点列表
* @param {string} listId
* @param {string[]} items
*/
function rebuildBullets(listId, items) {
const list = document.getElementById(listId);
if (!list) return;
list.innerHTML = '';
const ul = mk('ul', { class:'bullets' });
items.forEach(t => {
const li = mk('li', { contenteditable:'true', 'data-placeholder':'描述内容…' });
li.innerHTML = t;
ul.appendChild(li);
});
list.appendChild(ul);
}
/**
* 重建侧边栏自定义栏目
* @param {Array<{title,content}>} items
*/
function rebuildSidebarCustom(items) {
const c = document.getElementById('sidebarCustom');
if (!c) return;
c.innerHTML = '';
items.forEach(item => createSidebarSection(c, item));
}
// ===========================================================================
// 持久化 — localStorage
//
// 每次编辑触发 400ms debounce 的自动保存。
// 关闭页面前也会强制保存(beforeunload)。
// JSON 序列化 snapshot() 完整数据。
// ===========================================================================
let saveTimer;
/** 防抖保存:400ms 内无新编辑才写入 localStorage */
function save() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot()));
}, 400);
}
/**
* 从 localStorage 加载数据并还原到 DOM
* @returns {boolean} 是否有数据被加载
*/
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
restore(JSON.parse(raw));
return true;
}
} catch (e) { console.warn('Failed to load resume data:', e); }
return false;
}
// ===========================================================================
// XML 导出
//
// 将 snapshot() 数据序列化为结构化 XML 文件,触发浏览器下载。
// XML 根元素携带 theme 和 design 属性,导入时可完整恢复主题状态。
//
// XML 结构(简化示意):
// <resume theme="经典蓝" design="经典双栏">
// <name>...</name>
// <contact><item label="邮箱">...</item>...</contact>
// <skills><skill>...</skill>...</skills>
// <education>...</education>
// <sidebarSections><section><title/><content/></section>...</sidebarSections>
// <summary>...</summary>
// <experiences><experience><title/><date/><subtitle/><bullets>...</bullets></experience>...</experiences>
// <projects>...</projects>
// <extras><extra>...</extra>...</extras>
// </resume>
// ===========================================================================
/** XML 特殊字符转义 */
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/**
* 生成 XML 标签
* @param {string} n - 标签名
* @param {string} c - 标签内容(空字符串 → 自闭合)
* @param {Object} [a] - 属性键值对
*/
function tag(n, c, a) {
const as = a ? ' ' + Object.entries(a).map(([k,v]) => `${k}="${esc(v)}"`).join(' ') : '';
return c === '' ? `<${n}${as}/>` : `<${n}${as}>${c}</${n}>`;
}
/**
* 将数据对象序列化为 XML 字符串
* @param {Object} data - 简历数据(来自 snapshot() 或解析后的 XML
* @returns {string} 完整的 XML 文档
*/
function toXML(data) {
const L = ['<?xml version="1.0" encoding="UTF-8"?>'];
// 根元素带主题属性(不用 tag() 避免自闭合)
const theme = esc(data._theme || getColorName());
const design = esc(data._design || getDesignName());
L.push(`<resume theme="${theme}" design="${design}">`);
L.push(tag('name', esc(data.name||'')));
L.push(tag('title', esc(data.title||'')));
// 联系方式
L.push('<contact>');
['邮箱','手机','城市','链接'].forEach((lb, i) =>
L.push(tag('item', esc((data.contact||[])[i]||''), { label: lb })));
L.push('</contact>');
// 技能
L.push('<skills>');
(data.skills||[]).forEach(s => { if (s) L.push(tag('skill', esc(s))); });
L.push('</skills>');
// 教育
const edu = data.education || {};
L.push('<education>');
L.push(tag('school', esc(edu.school||'')));
L.push(tag('major', esc(edu.major||'')));
L.push(tag('date', esc(edu.date||'')));
L.push('</education>');
// 侧边栏自定义
L.push('<sidebarSections>');
(data.sidebarCustom||[]).forEach(sec => {
L.push('<section>');
L.push(tag('title', esc(sec.title||'')));
L.push(tag('content', esc(sec.content||'')));
L.push('</section>');
});
L.push('</sidebarSections>');
L.push(tag('summary', esc(data.summary||'')));
// 工作经历
L.push('<experiences>');
(data.experiences||[]).forEach(exp => {
L.push('<experience>');
L.push(tag('title', esc(exp.title||'')));
L.push(tag('date', esc(exp.date||'')));
L.push(tag('subtitle', esc(exp.subtitle||'')));
L.push('<bullets>');
(exp.bullets||[]).forEach(b => { if (b) L.push(tag('bullet', esc(b))); });
L.push('</bullets></experience>');
});
L.push('</experiences>');
// 项目经历
L.push('<projects>');
(data.projects||[]).forEach(p => {
L.push('<project>');
L.push(tag('title', esc(p.title||'')));
L.push(tag('date', esc(p.date||'')));
L.push('<bullets>');
(p.bullets||[]).forEach(b => { if (b) L.push(tag('bullet', esc(b))); });
L.push('</bullets></project>');
});
L.push('</projects>');
// 其他
L.push('<extras>');
(data.extras||[]).forEach(e => { if (e) L.push(tag('extra', esc(e))); });
L.push('</extras>');
L.push('</resume>');
return L.join('\n');
}
/**
* 导出 XML 文件
* 采集当前数据 → 序列化为 XML → 创建 Blob → 触发浏览器下载。
* 成功/失败均有 toast/alert 反馈。
*/
function exportXML() {
try {
const data = snapshot();
const xml = toXML(data);
const blob = new Blob([xml], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'resume.xml';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 延迟释放 Blob URL,确保浏览器完成下载
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast('✅ XML 已导出');
} catch (err) {
alert('导出失败:' + err.message);
console.error(err);
}
}
// ===========================================================================
// XML 导入
//
// 用户选择 .xml 文件 → FileReader 读取 → DOMParser 解析 →
// parseXML() 提取数据 → restore() 写回 DOM → 持久化。
// ===========================================================================
/**
* 导入 XML 文件
* @param {HTMLInputElement} input - type=file 的 input 元素
*/
function importXML(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
try {
const doc = new DOMParser().parseFromString(e.target.result, 'application/xml');
// 检查 XML 语法错误
const errNode = doc.querySelector('parsererror');
if (errNode) {
alert('XML 解析失败:' + errNode.textContent);
return;
}
const data = parseXML(doc);
restore(data);
save();
// 同步写 localStorage,确保刷新后数据一致
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
toast('✅ XML 已导入');
} catch (err) {
alert('导入失败:' + err.message);
console.error(err);
}
};
reader.onerror = () => { alert('文件读取失败'); };
reader.readAsText(file);
// 清空 input,允许重复导入同一文件
input.value = '';
}
/**
* 解析 XML DOM 为数据对象
* @param {Document} doc - XML Document
* @returns {Object} 与 snapshot() 格式兼容的数据对象
*/
function parseXML(doc) {
// 辅助:取单个标签文本
const t = (el, tag) => { const n = el.querySelector(tag); return n ? n.textContent : ''; };
// 辅助:取多个同名标签文本数组
const ts = (el, tag) => Array.from(el.querySelectorAll(tag)).map(n => n.textContent || '');
const root = doc.documentElement;
return {
_theme: root.getAttribute('theme') || '',
_design: root.getAttribute('design') || '',
name: t(root, 'name'),
title: t(root, 'title'),
contact: Array.from(root.querySelectorAll('contact > item')).map(n => n.textContent || ''),
skills: ts(root, 'skills > skill'),
education: {
school: t(root, 'education > school'),
major: t(root, 'education > major'),
date: t(root, 'education > date'),
},
sidebarCustom: Array.from(root.querySelectorAll('sidebarSections > section')).map(sec => ({
title: t(sec, 'title'),
content: t(sec, 'content'),
})),
summary: t(root, 'summary'),
experiences: Array.from(root.querySelectorAll('experiences > experience')).map(exp => ({
title: t(exp, 'title'),
date: t(exp, 'date'),
subtitle: t(exp, 'subtitle'),
bullets: ts(exp, 'bullet'),
})),
projects: Array.from(root.querySelectorAll('projects > project')).map(p => ({
title: t(p, 'title'),
date: t(p, 'date'),
bullets: ts(p, 'bullet'),
})),
extras: ts(root, 'extras > extra'),
};
}
// ===========================================================================
// Toast 提示
//
// 页面底部居中浮出提示,1.5s 后淡出,2s 后移除 DOM。
// 用于导出/导入成功的非阻塞反馈。
// ===========================================================================
/**
* 显示临时 toast 提示
* @param {string} msg - 提示文本
*/
function toast(msg) {
const el = document.createElement('div');
el.textContent = msg;
el.style.cssText = [
'position:fixed', 'bottom:50px', 'left:50%', 'transform:translateX(-50%)',
'background:rgba(0,0,0,.8)', 'color:#fff', 'padding:8px 20px',
'border-radius:20px', 'font-size:13px', 'z-index:999',
'pointer-events:none', 'transition:opacity .3s',
'font-family:var(--font-sans)',
].join(';');
document.body.appendChild(el);
setTimeout(() => { el.style.opacity = '0'; }, 1500);
setTimeout(() => { el.remove(); }, 2000);
}
// ===========================================================================
// DOM 工具
// ===========================================================================
/**
* 创建 DOM 元素
* 智能处理 class、data-* 属性、contenteditable 属性。
*
* @param {string} tag - HTML 标签名
* @param {Object} [attrs] - 属性键值对
* @param {string} [html] - innerHTML 内容
* @returns {HTMLElement}
*/
function mk(tag, attrs, html) {
const el = document.createElement(tag);
if (attrs) {
Object.entries(attrs).forEach(([k, v]) => {
if (k === 'class') el.className = v;
else if (k.startsWith('data-')) el.setAttribute(k, v);
else if (k === 'contenteditable') el.setAttribute('contenteditable', v);
else el[k] = v;
});
}
// 注意:html 可能为空字符串 '',需要显式判断 undefined
if (html !== undefined) el.innerHTML = html;
return el;
}
// ===========================================================================
// 技能标签
//
// 侧边栏技术栈区域:彩色小标签,可编辑文字,悬停显示 × 删除。
// ===========================================================================
/**
* 创建一个技能标签元素
* @param {HTMLElement} container - 技能标签容器 #skillTags
* @param {string} text - 初始文本
* @returns {HTMLElement} 技能标签 span 元素
*/
function createSkillTag(container, text) {
const tag = mk('span', {
class: 'skill-tag',
contenteditable: 'true',
'data-placeholder': '技能',
});
tag.textContent = text || '';
// 删除按钮
const del = mk('button', { class: 'tag-delete no-print' }, '×');
del.addEventListener('click', e => {
e.stopPropagation();
e.preventDefault();
tag.remove();
save();
});
tag.appendChild(del);
container.appendChild(tag);
}
/**
* 添加一个空技能标签并聚焦
*/
function addSkill() {
const container = document.getElementById('skillTags');
if (!container) return;
createSkillTag(container, '');
const tags = container.querySelectorAll('.skill-tag');
const last = tags[tags.length - 1];
if (last) { last.focus(); last.textContent = ''; }
save();
}
// ===========================================================================
// 侧边栏自定义栏目
//
// 用户可以动态添加任意标题+内容的栏目(如"证书""开源贡献"等)。
// 每个栏目包含标题行(大写风格)和内容行。
// ===========================================================================
/**
* 创建一个侧边栏自定义栏目
* @param {HTMLElement} container - #sidebarCustom 容器
* @param {Object} [item] - { title, content }
* @returns {HTMLElement}
*/
function createSidebarSection(container, item) {
const sec = mk('div', { class: 'sidebar-custom-section' });
// 删除按钮
const del = mk('button', { class: 'card-delete no-print' }, '×');
del.addEventListener('click', () => { sec.remove(); save(); });
sec.appendChild(del);
// 标题
const ti = mk('div', {
class: 'sidebar-custom-title',
contenteditable: 'true',
'data-placeholder': '栏目名称',
});
ti.innerHTML = (item || {}).title || '';
sec.appendChild(ti);
// 内容
const co = mk('div', {
class: 'sidebar-custom-content',
contenteditable: 'true',
'data-placeholder': '内容…',
});
co.innerHTML = (item || {}).content || '';
sec.appendChild(co);
container.appendChild(sec);
return sec;
}
/**
* 添加一个空的自定义栏目并聚焦标题
*/
function addSidebarSection() {
const container = document.getElementById('sidebarCustom');
if (!container) return;
const sec = createSidebarSection(container, {});
const title = sec.querySelector('.sidebar-custom-title');
if (title) title.focus();
save();
}
// ===========================================================================
// 经历/项目卡片
//
// 工作经历和项目经历共用 createSectionCard 工厂函数。
// 区别:工作经历有副标题(职位/角色),项目经历没有。
//
// 卡片结构:
// .section-card
// button.card-delete (×)
// .section-header > h3 + .date
// (可选) .section-sub
// ul.bullets > li (×N)
// button.add-btn (+ 添加要点)
// ===========================================================================
/**
* 创建经历/项目卡片
* @param {HTMLElement} list - 卡片列表容器
* @param {Object} item - { title, date, subtitle?, bullets }
* @param {boolean} hasSub - 是否有副标题行(工作经历=true,项目=false
* @returns {HTMLElement}
*/
function createSectionCard(list, item, hasSub) {
const card = mk('div', { class: 'section-card' });
// 删除按钮(打印时隐藏)
const del = mk('button', { class: 'card-delete no-print' }, '×');
del.addEventListener('click', () => { card.remove(); save(); });
card.appendChild(del);
// 标题行:h3 标题 + 时间
const hdr = mk('div', { class: 'section-header' });
const h3 = mk('h3', {
contenteditable: 'true',
'data-placeholder': '公司 / 项目名称',
});
h3.innerHTML = item.title || '';
const dt = mk('span', {
class: 'date',
contenteditable: 'true',
'data-placeholder': '时间',
});
dt.innerHTML = item.date || '';
hdr.appendChild(h3);
hdr.appendChild(dt);
card.appendChild(hdr);
// 副标题(仅工作经历)
if (hasSub) {
const sub = mk('div', {
class: 'section-sub',
contenteditable: 'true',
'data-placeholder': '职位 / 角色',
});
sub.innerHTML = item.subtitle || '';
card.appendChild(sub);
}
// 要点列表
const ul = mk('ul', { class: 'bullets' });
(item.bullets || ['']).forEach(t => {
const li = mk('li', {
contenteditable: 'true',
'data-placeholder': '描述你做了什么…',
});
li.innerHTML = t;
ul.appendChild(li);
});
card.appendChild(ul);
// 添加要点按钮
const addB = mk('button', { class: 'add-btn no-print' }, '+ 添加要点');
addB.addEventListener('click', () => {
const li = mk('li', {
contenteditable: 'true',
'data-placeholder': '描述你做了什么…',
});
ul.appendChild(li);
li.focus();
save();
});
card.appendChild(addB);
list.appendChild(card);
return card;
}
/** 创建空的工作经历卡片(带副标题行) */
function createExperienceCard(list, item) {
return createSectionCard(list, item || {}, true);
}
/** 创建空的项目经历卡片(无副标题行) */
function createProjectCard(list, item) {
return createSectionCard(list, item || {}, false);
}
/** 添加一条空工作经历 */
function addExperience() {
const list = document.getElementById('experienceList');
if (!list) return;
createExperienceCard(list, { bullets: [''] });
save();
}
/** 添加一条空项目经历 */
function addProject() {
const list = document.getElementById('projectList');
if (!list) return;
createProjectCard(list, { bullets: [''] });
save();
}
// ===========================================================================
// 其他条目
//
// 简单的 ul.bullets 列表,用于"其他"部分的自由条目。
// ===========================================================================
/** 添加一条空的其他条目 */
function addExtra() {
const list = document.getElementById('extraList');
if (!list) return;
let ul = list.querySelector('ul.bullets');
if (!ul) {
ul = mk('ul', { class: 'bullets' });
list.appendChild(ul);
}
const li = mk('li', {
contenteditable: 'true',
'data-placeholder': '其他信息…',
});
ul.appendChild(li);
li.focus();
save();
}
// ===========================================================================
// 重置
// ===========================================================================
/** 清空所有数据(需确认),然后刷新页面 */
function reset() {
if (confirm('确定要清空所有内容吗?此操作不可恢复。')) {
localStorage.removeItem(STORAGE_KEY);
location.reload();
}
}
// ===========================================================================
// 自动保存事件绑定
//
// 监听三个事件确保编辑内容不被遗漏:
// - input 每次输入触发
// - focusout 焦点离开元素时触发
// - keyup 退格/删除键触发(input 在某些情况下不触发)
// - beforeunload 关闭页面前强制保存
// ===========================================================================
function bindAutoSave() {
document.addEventListener('input', e => {
if (e.target.isContentEditable) save();
});
document.addEventListener('focusout', e => {
if (e.target.isContentEditable) save();
});
document.addEventListener('keyup', e => {
if (e.target.isContentEditable &&
(e.key === 'Backspace' || e.key === 'Delete')) {
save();
}
});
// 关闭页面前强制保存,避免 debounce 未触发导致数据丢失
window.addEventListener('beforeunload', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot()));
});
}
// ===========================================================================
// 初始化
//
// 1. 绑定自动保存事件
// 2. 应用默认颜色主题和设计主题
// 3. 尝试从 localStorage 加载数据
// 4. 若无数据(首次访问),创建空模板卡片
// ===========================================================================
function init() {
bindAutoSave();
// 默认主题
applyColor(0);
applyDesign(0);
// 加载持久化数据
const hasData = load();
// 首次访问:创建空卡片作为视觉模板
if (!hasData) {
applyColor(0);
applyDesign(0);
if (!document.getElementById('experienceList').children.length) addExperience();
if (!document.getElementById('projectList').children.length) addProject();
if (!document.getElementById('skillTags').children.length) addSkill();
}
}
// ===========================================================================
// 公开 API
//
// 所有供 HTML onclick 调用的方法必须在此处暴露。
// IIFE 返回此对象,挂载到 window.Resume。
// ===========================================================================
return {
// 内容操作
addSkill,
addSidebarSection,
addExperience,
addProject,
addExtra,
reset,
// XML 导入导出
exportXML,
importXML,
// 颜色主题
prevTheme,
nextTheme,
setColorByName,
// 设计主题
prevDesign,
nextDesign,
setDesignByName,
// 生命周期
init,
};
})();
// DOM 加载完成后初始化
document.addEventListener('DOMContentLoaded', Resume.init);