commit 1304439fcfdc5a57a30ff863c5fa37c6c26eeb3d Author: liyp Date: Tue Aug 25 21:35:15 2026 -0400 first commit 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 new file mode 100644 index 0000000..57f5136 --- /dev/null +++ b/resume/app.js @@ -0,0 +1,1055 @@ +/** + * ============================================================================= + * 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 传入 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 []; + 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 结构(简化示意): + // + // ... + // ...... + // ...... + // ... + //
<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(' ') : ''; + 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); diff --git a/resume/index.html b/resume/index.html new file mode 100644 index 0000000..a9cdbde --- /dev/null +++ b/resume/index.html @@ -0,0 +1,161 @@ +<!DOCTYPE html> +<!-- + ============================================================================= + Resume — 可编辑简历构建器 + ============================================================================= + 纯静态页面,浏览器直接打开即可使用。 + 依赖:style.css(样式) + app.js(逻辑) + 所有文字 contenteditable,所见即所得。 + 工具栏:打印PDF | 导出XML | 导入XML | 配色◀▶ | 版式◀▶ | 重置 + ============================================================================= +--> +<html lang="zh-CN"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>个人简历 + + + + + +
+ + + + + + 经典蓝 + + + + + 经典双栏 + + + + +
+
💡 点击文字直接编辑 · 点击 [+ 添加] 增条目 · 悬停条目右侧删 · 改完 Ctrl+P 出 PDF
+ + +
+ +
+ + + + + +
+ + +
+

个人概述

+

+
+ + +
+

工作经历

+
+ +
+
+ +
+
+ + +
+

项目经历

+
+ +
+
+ +
+
+ + +
+

其他

+
+ +
+
+ +
+
+ +
+
+ +
+ + + + \ No newline at end of file diff --git a/resume/style.css b/resume/style.css new file mode 100644 index 0000000..3f557e4 --- /dev/null +++ b/resume/style.css @@ -0,0 +1,692 @@ +/** + * ============================================================================= + * 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; } + +:root { + --ink: #1a1a1a; + --ink-muted: #555; + --accent: #1e5fa8; + --accent-light: #e8f0fa; + --border: #ddd; + --bg: #fff; + --surface: #f8f9fa; + --radius: 6px; + --font-sans: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", system-ui, -apple-system, sans-serif; + --font-mono: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono", "Monaco", monospace; +} + +/* ===== Print ===== */ +@media print { + @page { size: A4; margin: 0; } + body { + background: #fff; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + .page { + box-shadow: none; + margin: 0 auto; + padding: 36px 44px; + max-width: 210mm; + /* No min-height — natural multi-page flow */ + } + .section-card { break-inside: avoid; page-break-inside: avoid; } + h2 { break-after: avoid; page-break-after: avoid; } + /* Keep heading with the first card that follows */ + section > h2 + * { break-before: avoid; } + .no-print { display: none !important; } + [contenteditable]:focus { outline: none; } + [contenteditable]:empty::before { display: none; } + + /* Collapse sidebar into compact header on page 1; page 2+ is full-width main */ + .resume-grid { + grid-template-columns: 1fr; + gap: 0; + } + .sidebar { + position: static; + display: flex; + flex-wrap: wrap; + gap: 6px 20px; + align-items: flex-start; + padding-bottom: 14px; + margin-bottom: 20px; + border-bottom: 2px solid var(--accent); + } + .sidebar .name-section { + flex-basis: 100%; + margin-bottom: 4px; + } + .sidebar .name-section h1 { font-size: 22px; display: flex; gap: 12px; align-items: baseline; } + .sidebar .name-section h1 .sub { margin-top: 0; } + .sidebar section { + margin-bottom: 0; + min-width: 120px; + } + .sidebar h2 { + border-bottom: none; + margin-bottom: 2px; + padding-bottom: 0; + font-size: 11px; + } + .sidebar .info-item { margin-bottom: 1px; } + .skill-tags { gap: 3px; } + .skill-tag { font-size: 10px; padding: 1px 5px; } + .editable-block h3 { font-size: 12px; } + .sidebar .section-sub { font-size: 11px; } + .sidebar .date { font-size: 11px; } + + /* Sidebar custom sections in print */ + .sidebar-custom-section { min-width: 100px; } + .sidebar-custom-section h2 { font-size: 11px; margin-bottom: 2px; } + .sidebar-custom-section .sidebar-custom-content { font-size: 11px; } + + /* Main content full width */ + .main { width: 100%; } +} + +/* ===== Screen ===== */ +@media screen { + body { + background: var(--body-bg, #e8ecf1); + font-family: var(--font-sans); + color: var(--ink); + line-height: 1.6; + padding: 20px; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + } + .page { + background: var(--bg); + width: 210mm; + min-height: 297mm; + padding: 36px 44px; + box-shadow: 0 2px 20px rgba(0,0,0,.12); + position: relative; + } +} + +/* ===== Editable ===== */ +[contenteditable] { + outline: none; + border-radius: 3px; + transition: background .15s; + cursor: text; +} +[contenteditable]:hover { + background: rgba(30,95,168,.04); +} +[contenteditable]:focus { + background: rgba(30,95,168,.08); + box-shadow: 0 0 0 1.5px var(--accent); + border-radius: 3px; +} +[contenteditable]:empty::before { + content: attr(data-placeholder); + color: #bbb; + font-style: italic; +} + +/* ===== Layout ===== */ +.resume-grid { + display: grid; + grid-template-columns: 200px 1fr; + gap: 32px; + align-items: start; +} + +.sidebar { + position: sticky; + top: 36px; +} + +/* ===== Typography ===== */ +h1 { + font-size: 26px; + font-weight: 700; + letter-spacing: 1px; + line-height: 1.2; + margin-bottom: 4px; +} +h1 .sub { + display: block; + font-size: 13px; + font-weight: 400; + color: var(--ink-muted); + margin-top: 4px; + letter-spacing: 0; +} +h2 { + font-size: 14px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 2px; + color: var(--accent); + margin-bottom: 10px; + padding-bottom: 5px; + border-bottom: 2px solid var(--accent); +} +h3 { + font-size: 14px; + font-weight: 600; + margin-bottom: 2px; +} +h3 .meta { + font-weight: 400; + color: var(--ink-muted); + font-size: 12px; +} + +/* ===== Sidebar ===== */ +.sidebar section { + margin-bottom: 22px; +} +.sidebar h2 { + border-bottom-color: var(--border); + color: var(--ink); + font-size: 12px; +} +.name-section { + margin-bottom: 18px; +} +.info-item { + font-size: 12px; + margin-bottom: 5px; + display: flex; + gap: 5px; +} +.info-label { + color: var(--ink-muted); + flex-shrink: 0; +} +.skill-tags { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.skill-tag { + font-size: 11px; + background: var(--accent-light); + color: var(--accent); + padding: 2px 8px; + border-radius: 3px; + font-weight: 500; + position: relative; + cursor: text; +} +.skill-tag .tag-delete { + display: none; + position: absolute; + top: -6px; + right: -6px; + width: 16px; + height: 16px; + background: #e74c3c; + color: #fff; + border-radius: 50%; + font-size: 10px; + line-height: 16px; + text-align: center; + cursor: pointer; + border: none; +} +.skill-tag:hover .tag-delete { + display: block; +} + +/* ===== Sidebar custom sections ===== */ +.sidebar-custom-section { + margin-bottom: 16px; + position: relative; + padding: 8px; + border: 1px dashed transparent; + border-radius: var(--radius); + transition: border-color .15s; +} +.sidebar-custom-section:hover { + border-color: var(--border); +} +.sidebar-custom-section .sidebar-custom-title { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--ink); + margin-bottom: 4px; + padding-bottom: 4px; + border-bottom: 1.5px solid var(--border); + min-width: 2em; +} +.sidebar-custom-section .sidebar-custom-content { + font-size: 12px; + color: var(--ink-muted); + line-height: 1.5; + min-height: 1em; +} +.sidebar-custom-section .card-delete { + display: none; + position: absolute; + top: -4px; + right: -4px; + width: 18px; + height: 18px; + background: #e74c3c; + color: #fff; + border: none; + border-radius: 50%; + font-size: 10px; + line-height: 18px; + text-align: center; + cursor: pointer; + opacity: .7; +} +.sidebar-custom-section:hover .card-delete { + display: block; +} + +/* ===== Main ===== */ +.main section { + margin-bottom: 24px; +} + +/* Section card (experience, project) */ +.section-card { + position: relative; + margin-bottom: 14px; + padding-bottom: 14px; + border-bottom: 1px dashed var(--border); +} +.section-card:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} +.section-card .card-delete { + display: none; + position: absolute; + top: 0; + right: 0; + width: 22px; + height: 22px; + background: #e74c3c; + color: #fff; + border: none; + border-radius: 50%; + font-size: 12px; + line-height: 22px; + text-align: center; + cursor: pointer; + opacity: .7; + transition: opacity .15s; +} +.section-card:hover .card-delete { + display: block; +} +.section-card .card-delete:hover { + opacity: 1; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 2px; +} +.section-header .date { + font-size: 12px; + color: var(--ink-muted); + white-space: nowrap; + margin-left: 10px; +} +.section-sub { + font-size: 13px; + color: var(--ink-muted); + margin-bottom: 6px; +} + +/* Bullet list */ +ul.bullets { + list-style: none; + padding: 0; +} +ul.bullets li { + font-size: 13px; + margin-bottom: 3px; + padding-left: 14px; + position: relative; +} +ul.bullets li::before { + content: "—"; + position: absolute; + left: 0; + color: var(--accent); + font-weight: 700; +} + +p { + font-size: 13px; + margin-bottom: 4px; +} + +.date { + font-size: 12px; + color: var(--ink-muted); +} + +/* ===== Add Button ===== */ +.add-btn-group { + margin-top: 8px; +} +.add-btn { + font-size: 12px; + padding: 4px 12px; + border: 1px dashed var(--accent); + background: transparent; + color: var(--accent); + border-radius: var(--radius); + cursor: pointer; + font-family: var(--font-sans); + transition: all .15s; +} +.add-btn:hover { + background: var(--accent-light); + border-style: solid; +} + +/* ===== Toolbar ===== */ +.toolbar { + position: fixed; + top: 14px; + right: 14px; + display: flex; + gap: 8px; + z-index: 100; +} +.btn { + font-size: 12px; + padding: 7px 14px; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-weight: 600; + transition: all .15s; + font-family: var(--font-sans); +} +.btn-print { + background: var(--accent); + color: #fff; +} +.btn-print:hover { background: #164a85; } +.btn-xml { + background: #fff; + color: var(--accent); + border: 1px solid var(--accent); +} +.btn-xml:hover { background: var(--accent-light); } +.theme-switcher { + display: flex; + align-items: center; + gap: 2px; +} +.btn-theme { + background: transparent; + color: var(--ink-muted); + border: 1px solid var(--border); + padding: 5px 8px; + font-size: 11px; + min-width: auto; +} +.btn-theme:hover { background: var(--surface); color: var(--ink); } +.theme-label { + font-size: 11px; + color: var(--accent); + font-weight: 600; + min-width: 48px; + text-align: center; + user-select: none; +} +.btn-reset { + background: #fff; + color: var(--ink-muted); + border: 1px solid var(--border); +} +.btn-reset:hover { background: var(--surface); } +.edit-hint { + position: fixed; + bottom: 14px; + left: 50%; + transform: translateX(-50%); + font-size: 12px; + background: rgba(0,0,0,.7); + color: #fff; + padding: 5px 16px; + border-radius: 20px; + z-index: 100; + pointer-events: none; +} + +/* ===== Editable block ===== */ +.editable-block { + position: relative; +} + +/* ===== Summary ===== */ +.summary-text { + min-height: 1.6em; +} + +/* ===== Bullet list item controls ===== */ +.bullet-li { + display: flex; + align-items: flex-start; + gap: 4px; + margin-bottom: 3px; +} +.bullet-li .li-delete { + display: none; + flex-shrink: 0; + width: 16px; + height: 16px; + background: #e74c3c; + color: #fff; + border: none; + border-radius: 50%; + font-size: 10px; + line-height: 16px; + text-align: center; + cursor: pointer; + opacity: .7; + margin-top: 2px; +} +.bullet-li:hover .li-delete { + display: block; +} + +/* ===== Responsive ===== */ +@media screen and (max-width: 800px) { + .page { width: 100%; padding: 20px 16px; } + .resume-grid { grid-template-columns: 1fr; gap: 20px; } + .sidebar { position: static; } + .toolbar { top: 6px; right: 6px; } + .btn { padding: 5px 10px; font-size: 11px; } +} + +/* ============================================================ + DESIGN THEMES — 12 layout presets + Applied via .page.theme-xxx + ============================================================ */ + +/* 1. Classic — default, no overrides needed */ + +/* 2. Minimal — single column, sidebar as compact top header */ +.page.theme-minimal .resume-grid { + grid-template-columns: 1fr; + gap: 24px; +} +.page.theme-minimal .sidebar { + position: static; + display: flex; + flex-wrap: wrap; + gap: 6px 24px; + align-items: flex-start; + padding-bottom: 12px; + margin-bottom: 18px; + border-bottom: 2px solid var(--border); +} +.page.theme-minimal .sidebar .name-section { flex-basis: 100%; margin-bottom: 2px; } +.page.theme-minimal .sidebar .name-section h1 { font-size: 24px; } +.page.theme-minimal .sidebar section { margin-bottom: 0; min-width: 100px; } +.page.theme-minimal .sidebar h2 { + border-bottom: none; margin-bottom: 2px; padding-bottom: 0; font-size: 11px; +} + +/* 3. Right bar — sidebar on the right */ +.page.theme-rightbar .resume-grid { + grid-template-columns: 1fr 180px; +} +.page.theme-rightbar .sidebar { order: 2; } + +/* 4. Top bar — sidebar as full-width header, single column body */ +.page.theme-topbar .resume-grid { + grid-template-columns: 1fr; + gap: 0; +} +.page.theme-topbar .sidebar { + position: static; + display: flex; + flex-wrap: wrap; + gap: 6px 28px; + align-items: flex-start; + padding-bottom: 14px; + margin-bottom: 22px; + border-bottom: 2px solid var(--accent); +} +.page.theme-topbar .sidebar .name-section { flex-basis: 100%; margin-bottom: 4px; } +.page.theme-topbar .sidebar .name-section h1 { font-size: 24px; } +.page.theme-topbar .sidebar section { margin-bottom: 0; min-width: 100px; } +.page.theme-topbar .sidebar h2 { + border-bottom: none; margin-bottom: 2px; padding-bottom: 0; font-size: 11px; +} + +/* 5. Accent bar — thick accent left border on sidebar */ +.page.theme-accentbar .sidebar { + border-left: 4px solid var(--accent); + padding-left: 14px; +} + +/* 6. Wide side — 240px sidebar */ +.page.theme-wideside .resume-grid { + grid-template-columns: 240px 1fr; +} + +/* 7. Compact — tighter spacing everywhere */ +.page.theme-compact .resume-grid { gap: 20px; } +.page.theme-compact .sidebar section { margin-bottom: 14px; } +.page.theme-compact .main section { margin-bottom: 16px; } +.page.theme-compact .section-card { margin-bottom: 8px; padding-bottom: 8px; } +.page.theme-compact h1 { font-size: 22px; } +.page.theme-compact h2 { font-size: 12px; margin-bottom: 6px; padding-bottom: 3px; } +.page.theme-compact h3 { font-size: 12px; } +.page.theme-compact ul.bullets li { font-size: 11px; margin-bottom: 1px; } +.page.theme-compact p { font-size: 11px; } +.page.theme-compact .page { padding: 28px 32px; } + +/* 8. Cards — sections wrapped in card containers */ +.page.theme-cards .main section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + margin-bottom: 16px; +} +.page.theme-cards .main section h2 { + margin-top: -4px; +} + +/* 9. Timeline — left-positioned date markers on experiences/projects */ +.page.theme-timeline .section-card { + padding-left: 80px; + position: relative; +} +.page.theme-timeline .section-card .date { + position: absolute; + left: 0; + top: 0; + font-size: 11px; + color: var(--accent); + font-weight: 600; + white-space: nowrap; +} +.page.theme-timeline .section-card::before { + content: ''; + position: absolute; + left: 68px; + top: 4px; + width: 6px; + height: 6px; + background: var(--accent); + border-radius: 50%; +} +.page.theme-timeline .section-header .date { display: none; } + +/* 10. Duotone — sidebar has a subtle background colour */ +.page.theme-duotone .sidebar { + background: var(--surface); + margin: -16px; + padding: 16px; + border-radius: var(--radius); +} +.page.theme-duotone .resume-grid { gap: 28px; } + +/* 11. Lines — bold horizontal rules between sections, no card borders */ +.page.theme-lines .main section { + padding-bottom: 18px; + margin-bottom: 18px; + border-bottom: 2px solid var(--accent); +} +.page.theme-lines .main section:last-child { border-bottom: none; } +.page.theme-lines .section-card { + border-bottom: 1px solid var(--border); +} +.page.theme-lines .section-card:last-child { border-bottom: none; } +.page.theme-lines .sidebar section { + padding-bottom: 14px; + border-bottom: 1px solid var(--border); +} +.page.theme-lines .sidebar section:last-child { border-bottom: none; } + +/* 12. Frameless — no borders, no backgrounds, pure whitespace */ +.page.theme-frameless .sidebar h2 { border-bottom: none; padding-bottom: 0; } +.page.theme-frameless .main section h2 { border-bottom: none; padding-bottom: 0; } +.page.theme-frameless .section-card { border-bottom: none; padding-bottom: 10px; margin-bottom: 10px; } +.page.theme-frameless .sidebar-custom-section { border: none; } +.page.theme-frameless .sidebar-custom-section .sidebar-custom-title { border-bottom: none; } +.page.theme-frameless .add-btn { border-style: solid; opacity: .5; }