From e816f5b7742062bbfe3793a227d3a7b4ebc83c15 Mon Sep 17 00:00:00 2001 From: iorebuild Date: Sun, 7 Jun 2026 17:51:13 +0800 Subject: [PATCH] Add full Chinese comments, README docs, fix bugs (applyDesign state, mk empty string, beforeunload save) --- resume/README.md | 192 +++++++++++ resume/app.js | 848 ++++++++++++++++++++++++++++++++++++---------- resume/index.html | 15 +- resume/style.css | 27 ++ 4 files changed, 902 insertions(+), 180 deletions(-) create mode 100644 resume/README.md diff --git a/resume/README.md b/resume/README.md new file mode 100644 index 0000000..e73e98c --- /dev/null +++ b/resume/README.md @@ -0,0 +1,192 @@ +# Resume — 可编辑简历构建器 + +纯静态 Web 页面,浏览器直接打开即可使用。所有内容可在页面上直接编辑,打印即出 PDF。 + +## 快速开始 + +```bash +# 浏览器打开 +open index.html + +# 或直接双击 index.html +``` + +无需任何构建工具、包管理器或服务器。 + +## 功能 + +| 功能 | 说明 | +|---|---| +| ✏️ **所见即所得编辑** | 所有文字 contenteditable,点击直接修改 | +| ➕ **动态增删条目** | 工作经历、项目经历、技能标签、侧边栏栏目均可自由增删 | +| 🎨 **24 套颜色主题** | 经典蓝、暗夜黑、墨绿、酒红… ◀ ▶ 左右切换 | +| 📐 **12 套设计版式** | 经典双栏、时间轴、卡片式、极简单栏… | +| 🖨️ **打印导出 PDF** | Ctrl+P → 另存为 PDF,自动隐藏编辑 UI,多页自然分页 | +| 📤 **XML 导入导出** | 完整数据 + 主题 + 版式全部序列化,可备份/恢复/分享 | +| 💾 **自动保存** | localStorage 自动持久化,关闭浏览器不丢数据 | + +## 文件结构 + +``` +resume/ +├── index.html ← 页面结构(纯 HTML,所有区段带 contenteditable) +├── style.css ← 样式表(基础样式 + 12 套设计主题 + 打印样式) +├── app.js ← 全部交互逻辑(IIFE 模块,挂载到 window.Resume) +└── README.md ← 本文件 +``` + +### 各文件职责 + +| 文件 | 行数 | 职责 | +|---|---|---| +| `index.html` | ~150 | DOM 结构,通过 `onclick="Resume.xxx()"` 绑定事件 | +| `style.css` | ~690 | CSS 变量默认值、屏幕/打印双布局、12 套 `.page.theme-xxx` | +| `app.js` | ~560 | 数据采集/还原、主题切换、XML 序列化、自动保存、DOM 操作 | + +## 架构说明 + +### 数据流 + +``` +用户编辑 DOM + │ + ▼ +input/focusout/keyup 事件 + │ + ▼ +save() ── 400ms debounce ──► snapshot() 采集 DOM + │ │ + │ ▼ + │ JSON.stringify + │ │ + │ ▼ + │ localStorage + │ + └── beforeunload ──► 强制保存 +``` + +### snapshot / restore 闭环 + +``` + snapshot() + DOM ──────────────► 数据对象 + ▲ │ + │ ▼ + │ JSON / XML + │ │ + │ ▼ + └──── restore() ◄── 存储 +``` + +- **snapshot()** — 遍历 DOM 树,提取所有 contenteditable 元素内容,组装纯数据对象 +- **restore()** — 清空动态容器,用工厂函数重建 DOM,填入数据 +- 两者格式完全对称,确保闭环无丢失 + +### 主题系统 + +颜色主题和设计主题是**正交的**(24 × 12 = 288 种组合): + +``` +颜色主题(24 种) 设计主题(12 种) +┌─────────────────┐ ┌─────────────────┐ +│ CSS 变量注入 │ │ CSS class 切换 │ +│ :root 上设置 │ │ .page 上加 class │ +│ --ink, --accent │ │ .theme-timeline │ +│ --border, --bg │ │ .theme-cards │ +│ … │ │ … │ +└─────────────────┘ └─────────────────┘ + │ │ + └──────────┬───────────────────┘ + ▼ + 最终视觉效果 +``` + +### XML 格式 + +导出/导入使用统一 XML 结构,根元素携带主题属性: + +```xml + + + 张三 + 嵌入式软件工程师 + + zhangsan@example.com + 13800000000 + 广州 + github.com/zhangsan + + + C/C++ + STM32 + + + 某某大学 + 电子信息工程 · 本科 + 2016 – 2020 + + +
+ 证书 + 嵌入式系统设计师 +
+
+ 概述文本… + + + 公司名称 + 2022 – 至今 + 职位 + + 工作内容要点 1 + 工作内容要点 2 + + + + + +
+``` + +## 设计主题一览 + +| # | 名称 | CSS class | 效果 | +|---|---|---|---| +| 1 | 经典双栏 | `theme-classic` | 左 180px + 右主内容(默认) | +| 2 | 极简单栏 | `theme-minimal` | 全宽单栏,侧栏压缩到顶部横排 | +| 3 | 右侧栏 | `theme-rightbar` | 侧栏在右,主内容在左 | +| 4 | 顶栏式 | `theme-topbar` | 个人信息横排顶部,下方全宽 | +| 5 | 左色条 | `theme-accentbar` | 侧栏左侧 accent 色 4px 粗竖线 | +| 6 | 宽侧栏 | `theme-wideside` | 侧栏加宽至 240px | +| 7 | 紧凑 | `theme-compact` | 缩小间距字号,高信息密度 | +| 8 | 卡片式 | `theme-cards` | 主内容区 section 用卡片包裹 | +| 9 | 时间轴 | `theme-timeline` | 日期提取到左侧圆点标记 | +| 10 | 双色块 | `theme-duotone` | 侧栏带浅色背景色块 | +| 11 | 线条风 | `theme-lines` | section 间 accent 色粗线分割 | +| 12 | 无框 | `theme-frameless` | 去掉所有边框和背景装饰 | + +## 打印说明 + +- **快捷键**:`Ctrl+P`(Windows/Linux)或 `Cmd+P`(Mac) +- **导出 PDF**:打印对话框中选择"另存为 PDF" +- 打印时自动隐藏工具栏、添加按钮、删除按钮 +- 第二页起 sidebar 不重复出现,主内容全宽 +- 设计主题在打印时不生效(统一紧凑单栏布局) +- 颜色主题的 accent 色在打印中保留 + +## 浏览器兼容 + +- Chrome / Edge 90+ +- Firefox 90+ +- Safari 15+ +- 任何支持 CSS Grid + CSS 自定义属性的现代浏览器 + +## 技术栈 + +- 纯 HTML5 + CSS3 + Vanilla JavaScript (ES6+) +- CSS Grid 双栏布局 +- CSS 自定义属性(变量)主题系统 +- DOMParser XML 序列化/反序列化 +- localStorage 持久化 +- Blob + URL.createObjectURL 文件下载 +- FileReader 文件读取 diff --git a/resume/app.js b/resume/app.js index cf039cf..57f5136 100644 --- a/resume/app.js +++ b/resume/app.js @@ -1,16 +1,62 @@ /** - * Resume — interactive resume builder - * 24 colour themes + 12 design/layout themes. All config in one XML. + * ============================================================================= + * 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'; - // ============================================================ - // COLOUR THEMES — 24 presets - // ============================================================ + // =========================================================================== + // 颜色主题 — 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'} }, @@ -38,97 +84,177 @@ const Resume = (() => { { 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'} }, ]; - // ============================================================ - // DESIGN THEMES — 12 layout presets - // ============================================================ + // =========================================================================== + // 设计主题 — 12 套版式布局 + // + // 每套设计主题对应 style.css 中的 .page.theme-xxx 选择器规则。 + // 切换时给 .page 元素替换对应 CSS class。 + // + // 颜色主题和设计主题是正交的(24 × 12 = 288 种组合)。 + // 打印时设计主题不生效,统一使用 @media print 的紧凑单栏布局。 + // =========================================================================== + const DESIGN_THEMES = [ - { name:'经典双栏', cls:'theme-classic' }, - { name:'极简单栏', cls:'theme-minimal' }, - { name:'右侧栏', cls:'theme-rightbar' }, - { name:'顶栏式', cls:'theme-topbar' }, - { name:'左色条', cls:'theme-accentbar' }, - { name:'宽侧栏', cls:'theme-wideside' }, - { name:'紧凑', cls:'theme-compact' }, - { name:'卡片式', cls:'theme-cards' }, - { name:'时间轴', cls:'theme-timeline' }, - { name:'双色块', cls:'theme-duotone' }, - { name:'线条风', cls:'theme-lines' }, - { name:'无框', cls:'theme-frameless' }, + { 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; - // ---- Colour theme helpers ---- + // =========================================================================== + // 颜色主题控制 + // =========================================================================== + + /** + * 应用颜色主题 + * 将主题的 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; } - // ---- Design theme helpers ---- + // =========================================================================== + // 设计主题控制 + // =========================================================================== + + /** + * 应用设计主题 + * 先移除 .page 上所有旧的设计主题 class,再添加新的。 + * + * @param {number} idx - 主题索引 + */ function applyDesign(idx) { idx = ((idx % DESIGN_THEMES.length) + DESIGN_THEMES.length) % DESIGN_THEMES.length; - // Remove old design class + designIdx = idx; const page = document.querySelector('.page'); if (page) { + // 移除所有旧的设计主题 class DESIGN_THEMES.forEach(d => page.classList.remove(d.cls)); - designIdx = idx; + // 添加新的 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; } - // ============================================================ - // DATA MODEL - // ============================================================ + // =========================================================================== + // 数据模型 — 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: { + _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'), + 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); @@ -138,18 +264,28 @@ const Resume = (() => { title: card.querySelector('h3')?.innerHTML || '', date: card.querySelector('.date')?.innerHTML || '', subtitle: card.querySelector('.section-sub')?.innerHTML || '', + // 注意:collect 传入 NodeList,collect 内部会检测类型 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 []; @@ -159,21 +295,33 @@ const Resume = (() => { })); } - // ============================================================ - // RESTORE - // ============================================================ + // =========================================================================== + // 数据还原 — 数据结构 → 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); @@ -181,27 +329,48 @@ const Resume = (() => { } 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; @@ -209,10 +378,16 @@ const Resume = (() => { const ul = mk('ul', { class:'bullets' }); items.forEach(t => { const li = mk('li', { contenteditable:'true', 'data-placeholder':'描述内容…' }); - li.innerHTML = t; ul.appendChild(li); + 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; @@ -220,10 +395,17 @@ const Resume = (() => { items.forEach(item => createSidebarSection(c, item)); } - // ============================================================ - // PERSISTENCE - // ============================================================ + // =========================================================================== + // 持久化 — localStorage + // + // 每次编辑触发 400ms debounce 的自动保存。 + // 关闭页面前也会强制保存(beforeunload)。 + // JSON 序列化 snapshot() 完整数据。 + // =========================================================================== + let saveTimer; + + /** 防抖保存:400ms 内无新编辑才写入 localStorage */ function save() { clearTimeout(saveTimer); saveTimer = setTimeout(() => { @@ -231,6 +413,10 @@ const Resume = (() => { }, 400); } + /** + * 从 localStorage 加载数据并还原到 DOM + * @returns {boolean} 是否有数据被加载 + */ function load() { try { const raw = localStorage.getItem(STORAGE_KEY); @@ -238,43 +424,81 @@ const Resume = (() => { restore(JSON.parse(raw)); return true; } - } catch (e) { console.warn('load failed', e); } + } catch (e) { console.warn('Failed to load resume data:', e); } return false; } - // ============================================================ - // XML EXPORT - // ============================================================ - function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + // =========================================================================== + // XML 导出 + // + // 将 snapshot() 数据序列化为结构化 XML 文件,触发浏览器下载。 + // XML 根元素携带 theme 和 design 属性,导入时可完整恢复主题状态。 + // + // XML 结构(简化示意): + // + // ... + // ...... + // ...... + // ... + //
<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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); + } + + /** + * 生成 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(' ') : ''; + 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('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}))); + ['邮箱','手机','城市','链接'].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))); }); + (data.skills||[]).forEach(s => { if (s) L.push(tag('skill', esc(s))); }); L.push('</skills>'); - const edu = data.education||{}; + // 教育 + 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>'); @@ -286,85 +510,118 @@ const Resume = (() => { 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('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))); }); + (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(tag('date', esc(p.date||''))); L.push('<bullets>'); - (p.bullets||[]).forEach(b => { if(b) L.push(tag('bullet', esc(b))); }); + (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))); }); + (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(); + 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) { + } catch (err) { alert('导出失败:' + err.message); console.error(err); } } - // ============================================================ - // XML IMPORT - // ============================================================ + // =========================================================================== + // 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 r = new FileReader(); - r.onload = e => { + + 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; } + 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) { + } catch (err) { alert('导入失败:' + err.message); console.error(err); } }; - r.onerror = () => { alert('文件读取失败'); }; - r.readAsText(file); + 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 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 { @@ -372,150 +629,328 @@ const Resume = (() => { _design: root.getAttribute('design') || '', name: t(root, 'name'), title: t(root, 'title'), - contact: Array.from(root.querySelectorAll('contact > item')).map(n => n.textContent||''), + 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') }, + 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'), + title: t(sec, 'title'), + content: t(sec, 'content'), })), - summary: t(root, 'summary'), + 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'), + 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'), + title: t(p, 'title'), + date: t(p, 'date'), + bullets: ts(p, 'bullet'), })), extras: ts(root, 'extras > extra'), }; } - // ============================================================ - // TOAST - // ============================================================ + // =========================================================================== + // 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)'; + 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 HELPERS - // ============================================================ - function mk(tag, a, h) { + // =========================================================================== + // 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); - Object.entries(a||{}).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; - }); - if (h) el.innerHTML=h; + 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; } - // ============================================================ - // SKILL TAGS - // ============================================================ - function createSkillTag(c, 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); c.appendChild(tag); + // =========================================================================== + // 技能标签 + // + // 侧边栏技术栈区域:彩色小标签,可编辑文字,悬停显示 × 删除。 + // =========================================================================== + + /** + * 创建一个技能标签元素 + * @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 c = document.getElementById('skillTags'); - createSkillTag(c, ''); - const tags = c.querySelectorAll('.skill-tag'); - const last = tags[tags.length-1]; - if (last) { last.focus(); last.textContent=''; } + 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(); } - // ============================================================ - // SIDEBAR CUSTOM SECTIONS - // ============================================================ - function createSidebarSection(c, item) { - const sec = mk('div', {class:'sidebar-custom-section'}); - const del = mk('button', {class:'card-delete no-print'}, '×'); + // =========================================================================== + // 侧边栏自定义栏目 + // + // 用户可以动态添加任意标题+内容的栏目(如"证书""开源贡献"等)。 + // 每个栏目包含标题行(大写风格)和内容行。 + // =========================================================================== + + /** + * 创建一个侧边栏自定义栏目 + * @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); - c.appendChild(sec); + + // 标题 + 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 c = document.getElementById('sidebarCustom'); - const sec = createSidebarSection(c, {}); - sec.querySelector('.sidebar-custom-title').focus(); + const container = document.getElementById('sidebarCustom'); + if (!container) return; + const sec = createSidebarSection(container, {}); + const title = sec.querySelector('.sidebar-custom-title'); + if (title) title.focus(); save(); } - // ============================================================ - // SECTION CARDS (Experience / Project) - // ============================================================ + // =========================================================================== + // 经历/项目卡片 + // + // 工作经历和项目经历共用 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'}, '×'); + 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); - 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); + // 标题行: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||''; + 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); + // 要点列表 + 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'}, '+ 添加要点'); + // 添加要点按钮 + 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(); + 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 l=document.getElementById('experienceList'); createExperienceCard(l,{bullets:['']}); save(); } - function addProject() { const l=document.getElementById('projectList'); createProjectCard(l,{bullets:['']}); save(); } - - // ============================================================ - // EXTRA BULLETS - // ============================================================ - function addExtra() { - const list = document.getElementById('extraList'); - 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 createExperienceCard(list, item) { + return createSectionCard(list, item || {}, true); } - // ============================================================ - // RESET - // ============================================================ + /** 创建空的项目经历卡片(无副标题行) */ + 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); @@ -523,27 +958,55 @@ const Resume = (() => { } } - // ============================================================ - // AUTO-SAVE - // ============================================================ + // =========================================================================== + // 自动保存事件绑定 + // + // 监听三个事件确保编辑内容不被遗漏: + // - 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('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(); + if (e.target.isContentEditable && + (e.key === 'Backspace' || e.key === 'Delete')) { + save(); + } + }); + // 关闭页面前强制保存,避免 debounce 未触发导致数据丢失 + window.addEventListener('beforeunload', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot())); }); } - // ============================================================ - // INIT - // ============================================================ + // =========================================================================== + // 初始化 + // + // 1. 绑定自动保存事件 + // 2. 应用默认颜色主题和设计主题 + // 3. 尝试从 localStorage 加载数据 + // 4. 若无数据(首次访问),创建空模板卡片 + // =========================================================================== + function init() { bindAutoSave(); + + // 默认主题 applyColor(0); applyDesign(0); + // 加载持久化数据 const hasData = load(); + // 首次访问:创建空卡片作为视觉模板 if (!hasData) { applyColor(0); applyDesign(0); @@ -553,13 +1016,40 @@ const Resume = (() => { } } + // =========================================================================== + // 公开 API + // + // 所有供 HTML onclick 调用的方法必须在此处暴露。 + // IIFE 返回此对象,挂载到 window.Resume。 + // =========================================================================== + return { - addSkill, addSidebarSection, addExperience, addProject, addExtra, - reset, exportXML, importXML, - prevTheme, nextTheme, setColorByName, - prevDesign, nextDesign, setDesignByName, + // 内容操作 + addSkill, + addSidebarSection, + addExperience, + addProject, + addExtra, + reset, + + // XML 导入导出 + exportXML, + importXML, + + // 颜色主题 + prevTheme, + nextTheme, + setColorByName, + + // 设计主题 + prevDesign, + nextDesign, + setDesignByName, + + // 生命周期 init, }; })(); +// DOM 加载完成后初始化 document.addEventListener('DOMContentLoaded', Resume.init); diff --git a/resume/index.html b/resume/index.html index 1d29bbf..a9cdbde 100644 --- a/resume/index.html +++ b/resume/index.html @@ -1,4 +1,14 @@ <!DOCTYPE html> +<!-- + ============================================================================= + Resume — 可编辑简历构建器 + ============================================================================= + 纯静态页面,浏览器直接打开即可使用。 + 依赖:style.css(样式) + app.js(逻辑) + 所有文字 contenteditable,所见即所得。 + 工具栏:打印PDF | 导出XML | 导入XML | 配色◀▶ | 版式◀▶ | 重置 + ============================================================================= +--> <html lang="zh-CN"> <head> <meta charset="UTF-8"> @@ -33,7 +43,10 @@ <div class="resume-grid"> - <!-- ====== Sidebar ====== --> + <!-- ============================================================ + Sidebar — 左侧个人信息栏 + 内容:姓名/头衔、联系方式、技能标签、教育背景、自定义栏目 + ============================================================ --> <aside class="sidebar"> <!-- Name --> diff --git a/resume/style.css b/resume/style.css index cf51d56..3f62bdc 100644 --- a/resume/style.css +++ b/resume/style.css @@ -1,3 +1,30 @@ +/** + * ============================================================================= + * Resume — 样式表 + * ============================================================================= + * + * 文件结构: + * 1. Reset & CSS 变量 (默认值) + * 2. @media print — 打印样式(隐藏UI、多页分页、sidebar转顶部) + * 3. @media screen — 屏幕端基础布局 + * 4. Editable — contenteditable 交互样式 + * 5. Grid Layout — 双栏布局 + * 6. Typography — 标题、段落、字体 + * 7. Sidebar — 侧边栏(联系方式、技能、教育) + * 8. Sidebar Custom Sections — 动态自定义栏目 + * 9. Main Content — 主内容区 + * 10. Section Cards — 经历/项目卡片 + * 11. Bullet Lists — 要点列表 + * 12. Add Buttons — 添加按钮 + * 13. Toolbar — 顶部工具栏 + * 14. DESIGN THEMES — 12 套版式布局(.page.theme-xxx) + * 15. Responsive — 移动端适配 + * + * 颜色不在本文件中硬编码;全部通过 CSS 变量控制,由 app.js 动态注入。 + * 设计主题通过 .page 上的 CSS class 切换(如 .page.theme-timeline)。 + * ============================================================================= + */ + /* ===== Reset & Variables ===== */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }