From 3a969d733b0b84031098284f98bd325a27d21d21 Mon Sep 17 00:00:00 2001 From: masonsxu Date: Fri, 3 Apr 2026 11:00:37 +0800 Subject: [PATCH 1/4] feat: add multi-page editing and printing Add multi-page editor and preview support so users can manage separate A4 pages and print them correctly. Update the README to explain the new editing and printing workflow. --- README.md | 26 +++- src/main.ts | 351 ++++++++++++++++++++++++++++++++---------------- src/markdown.ts | 47 ++++++- src/samples.ts | 148 -------------------- src/style.css | 224 +++++++++++++++++++++--------- 5 files changed, 461 insertions(+), 335 deletions(-) diff --git a/README.md b/README.md index 164ca38..2291bf8 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,31 @@ bun install bun run dev ``` -浏览器打开后,在左侧粘贴 Markdown 内容,右侧即时预览 A4 效果。按 `⌘P` 打印或导出 PDF。 +浏览器打开后,在左侧编辑内容,右侧即时预览 A4 效果。按 `⌘P` 打印或导出 PDF。 + +## 使用说明 + +### 基本使用流程 + +1. 在左侧“页面编辑区”输入或粘贴 Markdown 内容 +2. 右侧会实时生成对应的 A4 预览 +3. 在下方“样式设置”中调整主题、字体、页边距、行高、段落间距和首行缩进 +4. 确认预览无误后,点击右上角“打印”或直接按 `⌘P` + +### 多页编辑 + +- 默认会创建 1 页 +- 点击 `+ 添加页面` 可新增一页独立内容 +- 每一页都有独立的 Markdown 编辑区和对应的 A4 预览页 +- 点击页面卡片右上角“删除”可移除当前页(至少保留 1 页) +- 顶部状态栏会显示当前总页数 + +### 打印说明 + +- 打印时会按每个预览页输出为独立 A4 页面 +- 建议在浏览器打印对话框中保持纸张为 **A4** +- 如果浏览器提供缩放选项,建议使用默认值,避免额外缩放破坏版式 +- 导出 PDF 时,效果与右侧预览页保持一致 ### 预览操作 diff --git a/src/main.ts b/src/main.ts index cf7fc99..ff10aea 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,17 +8,23 @@ import './style.css' let debounceTimer: ReturnType | null = null let lastLoadedFont = '' -let a4Content: HTMLElement -let a4Page: HTMLElement let a4Wrapper: HTMLElement -let a4Placeholder: HTMLElement +let editorsList: HTMLElement let statusFontSize: HTMLElement let statusOverflow: HTMLElement let statusZoom: HTMLElement -let textarea: HTMLTextAreaElement +let statusPages: HTMLElement let fitScale = 1 let userZoom = 1 +interface PageState { + editor: HTMLTextAreaElement + page: HTMLElement + content: HTMLElement +} + +let pageStates: PageState[] = [] + function buildDOM(): void { const app = document.getElementById('app')! app.className = 'app' @@ -29,7 +35,7 @@ function buildDOM(): void { const title = document.createElement('div') title.className = 'topbar-title' - title.textContent = '一页印 PrintFit' + title.textContent = '多页印 PrintFit' const statusArea = document.createElement('div') statusArea.className = 'topbar-status' @@ -42,37 +48,58 @@ function buildDOM(): void { statusOverflow.className = 'status-overflow' statusOverflow.textContent = '内容溢出' + statusPages = document.createElement('span') + statusPages.className = 'status-pages' + statusPages.textContent = '1 页' + statusZoom = document.createElement('span') statusZoom.className = 'status-zoom' statusZoom.textContent = '100%' statusZoom.title = '⌘+滚轮缩放,双击重置' - const printBtn = document.createElement('button') - printBtn.className = 'btn-print' - printBtn.textContent = '打印 ⌘P' - printBtn.addEventListener('click', () => window.print()) + const btnPrint = document.createElement('button') + btnPrint.className = 'btn-print' + btnPrint.textContent = '打印 ⌘P' + btnPrint.addEventListener('click', preparePrint) - statusArea.append(statusFontSize, statusZoom, statusOverflow, printBtn) + statusArea.append(statusFontSize, statusZoom, statusPages, statusOverflow, btnPrint) topbar.append(title, statusArea) // Left panel const leftPanel = document.createElement('div') leftPanel.className = 'left-panel' - const textareaWrapper = document.createElement('div') - textareaWrapper.className = 'textarea-wrapper' + const editorsHeader = document.createElement('div') + editorsHeader.className = 'editors-header' + + const editorsTitle = document.createElement('span') + editorsTitle.textContent = '页面编辑区' + + const addPageBtn = document.createElement('button') + addPageBtn.className = 'btn-add-page' + addPageBtn.textContent = '+ 添加页面' + addPageBtn.addEventListener('click', () => addPage()) - const textareaHeader = document.createElement('div') - textareaHeader.className = 'textarea-header' + editorsHeader.append(editorsTitle, addPageBtn) - const headerLabel = document.createElement('span') - headerLabel.textContent = '粘贴 / 编辑 Markdown' + const editorsWrapper = document.createElement('div') + editorsWrapper.className = 'editors-wrapper' + + editorsList = document.createElement('div') + editorsList.className = 'editors-list' + + const sampleBar = document.createElement('div') + sampleBar.className = 'sample-bar' + + const sampleLabel = document.createElement('span') + sampleLabel.textContent = '加载示例:' + sampleLabel.className = 'sample-label' const sampleSelect = document.createElement('select') sampleSelect.className = 'sample-select' const emptyOpt = document.createElement('option') emptyOpt.value = '' - emptyOpt.textContent = '加载示例…' + emptyOpt.textContent = '选择...' sampleSelect.appendChild(emptyOpt) for (const s of SAMPLES) { const opt = document.createElement('option') @@ -80,23 +107,15 @@ function buildDOM(): void { opt.textContent = s.label sampleSelect.appendChild(opt) } - sampleSelect.value = SAMPLES[0].value sampleSelect.addEventListener('change', () => { const sample = SAMPLES.find(s => s.value === sampleSelect.value) if (sample) { - textarea.value = sample.content - scheduleUpdate() + loadSample(sample.content) } }) - textareaHeader.append(headerLabel, sampleSelect) - - textarea = document.createElement('textarea') - textarea.className = 'input-textarea' - textarea.placeholder = '在此粘贴 Markdown 内容...\n\n支持粘贴后编辑修改\n\n# 标题\n\n正文内容...\n\n- 列表项' - textarea.spellcheck = false - - textareaWrapper.append(textareaHeader, textarea) + sampleBar.append(sampleLabel, sampleSelect) + editorsWrapper.append(sampleBar, editorsList) const controlsSection = document.createElement('div') controlsSection.className = 'controls-section' @@ -112,34 +131,18 @@ function buildDOM(): void { }) controlsSection.append(controlsHeader, controlsBody) - leftPanel.append(textareaWrapper, controlsSection) + leftPanel.append(editorsHeader, editorsWrapper, controlsSection) // Right panel const rightPanel = document.createElement('div') rightPanel.className = 'right-panel' - a4Page = document.createElement('div') - a4Page.className = 'a4-page' - - a4Placeholder = document.createElement('div') - a4Placeholder.className = 'a4-placeholder' - a4Placeholder.textContent = '在左侧粘贴内容以预览' - - a4Content = document.createElement('div') - a4Content.className = 'a4-content' - - a4Page.append(a4Placeholder, a4Content) - a4Wrapper = document.createElement('div') a4Wrapper.className = 'a4-wrapper' - a4Wrapper.appendChild(a4Page) rightPanel.appendChild(a4Wrapper) app.append(topbar, leftPanel, rightPanel) - // Events: textarea supports both paste and live editing - textarea.addEventListener('input', scheduleUpdate) - // Auto-scale A4 page to fit the right panel const resizeObserver = new ResizeObserver(() => updateA4Scale()) resizeObserver.observe(rightPanel) @@ -190,10 +193,123 @@ function buildDOM(): void { applyScale() updateZoomStatus() }) + + // Cmd+P to print + document.addEventListener('keydown', (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'p') { + e.preventDefault() + preparePrint() + } + }) + + // Initialize with one page + addPage() +} + +function preparePrint(): void { + document.body.classList.add('is-printing') + + // Remove inline styles for printing + for (const state of pageStates) { + state.page.style.transform = '' + state.page.style.transformOrigin = '' + } + + window.print() +} + +// Restore scale after printing +window.addEventListener('afterprint', () => { + document.body.classList.remove('is-printing') + applyScale() +}) + +function addPage(initialContent = ''): void { + const index = pageStates.length + const pageNum = index + 1 + + // Create editor + const editorCard = document.createElement('div') + editorCard.className = 'editor-card' + + const editorHeader = document.createElement('div') + editorHeader.className = 'editor-card-header' + + const pageNumLabel = document.createElement('span') + pageNumLabel.className = 'page-num-label' + pageNumLabel.textContent = `第 ${pageNum} 页` + + const deleteBtn = document.createElement('button') + deleteBtn.className = 'btn-delete-page' + deleteBtn.textContent = '删除' + deleteBtn.addEventListener('click', () => removePage(index)) + + editorHeader.append(pageNumLabel, deleteBtn) + + const textarea = document.createElement('textarea') + textarea.className = 'editor-textarea' + textarea.placeholder = `在此输入第 ${pageNum} 页的 Markdown 内容...` + textarea.spellcheck = false + textarea.value = initialContent + textarea.addEventListener('input', scheduleUpdate) + + editorCard.append(editorHeader, textarea) + editorsList.appendChild(editorCard) + + // Create A4 page + const page = document.createElement('div') + page.className = 'a4-page' + + const content = document.createElement('div') + content.className = 'a4-content' + + page.appendChild(content) + a4Wrapper.appendChild(page) + + pageStates.push({ editor: textarea, page, content }) + + updatePageLabels() + scheduleUpdate() +} + +function removePage(index: number): void { + if (pageStates.length <= 1) return + + const state = pageStates[index] + state.editor.closest('.editor-card')!.remove() + state.page.remove() + pageStates.splice(index, 1) + + updatePageLabels() + scheduleUpdate() +} + +function updatePageLabels(): void { + pageStates.forEach((state, i) => { + const label = state.editor.closest('.editor-card')!.querySelector('.page-num-label')! + label.textContent = `第 ${i + 1} 页` + state.editor.placeholder = `在此输入第 ${i + 1} 页的 Markdown 内容...` + }) + statusPages.textContent = `${pageStates.length} 页` +} + +function loadSample(content: string): void { + // Clear all pages except the first one + while (pageStates.length > 1) { + const state = pageStates.pop()! + state.editor.closest('.editor-card')!.remove() + state.page.remove() + } + + // Load content into first page + if (pageStates.length > 0) { + pageStates[0].editor.value = content + } + + scheduleUpdate() } const PAGE_W = 794 -const PAGE_H = 1123 function updateA4Scale(): void { const rightPanel = a4Wrapper.parentElement @@ -201,18 +317,17 @@ function updateA4Scale(): void { const padding = 32 const availW = rightPanel.clientWidth - padding * 2 - const availH = rightPanel.clientHeight - padding * 2 - fitScale = Math.min(availW / PAGE_W, availH / PAGE_H) + fitScale = Math.min(availW / PAGE_W, 1) applyScale() } function applyScale(): void { const scale = fitScale * userZoom - a4Page.style.transform = `scale(${scale})` - a4Page.style.transformOrigin = 'top left' - a4Wrapper.style.width = `${PAGE_W * scale}px` - a4Wrapper.style.height = `${PAGE_H * scale}px` + for (const state of pageStates) { + state.page.style.transform = `scale(${scale})` + state.page.style.transformOrigin = 'top center' + } } function updateZoomStatus(): void { @@ -227,20 +342,9 @@ function scheduleUpdate(): void { } async function update(): Promise { - const markdown = textarea.value const settings = getSettings() - if (!markdown.trim()) { - a4Content.textContent = '' - a4Placeholder.style.display = '' - statusFontSize.textContent = '—' - statusOverflow.classList.remove('visible') - return - } - - a4Placeholder.style.display = 'none' - - // Only load font when it changes + // Load font if needed if (lastLoadedFont !== settings.fontFamily) { await Promise.all([ document.fonts.load(`16px "${settings.fontFamily}"`), @@ -249,68 +353,77 @@ async function update(): Promise { lastLoadedFont = settings.fontFamily } - // Extract blocks and find optimal font size via Pretext - const blocks = extractBlocks(markdown) - const { fontSize, overflow } = findOptimalFontSize(blocks, settings) - - // Update status bar - statusFontSize.textContent = `${fontSize.toFixed(1)}px` - statusOverflow.classList.toggle('visible', overflow) - - // Render Markdown to HTML and apply to A4 page - const html = await parse(markdown) - let currentFontSize = fontSize - applyStyles(settings, currentFontSize) - - // Use DOMParser to safely set content - const doc = new DOMParser().parseFromString(html, 'text/html') - a4Content.replaceChildren(...Array.from(doc.body.childNodes).map(n => n.cloneNode(true))) - - // DOM fallback: if content overflows, binary search for fitting font size (~5 reflows instead of ~20) - const pageStyle = getComputedStyle(a4Page) - const availableHeight = a4Page.clientHeight - parseFloat(pageStyle.paddingTop) - parseFloat(pageStyle.paddingBottom) - - if (a4Content.scrollHeight > availableHeight && currentFontSize > 6) { - let lo = 6 - let hi = currentFontSize - while (hi - lo > 0.25) { - const mid = (lo + hi) / 2 - applyStyles(settings, mid) - if (a4Content.scrollHeight <= availableHeight) { - lo = mid - } else { - hi = mid + let globalOverflow = false + let displayFontSize = 0 + + for (let i = 0; i < pageStates.length; i++) { + const state = pageStates[i] + const markdown = state.editor.value + + if (!markdown.trim()) { + state.content.textContent = '' + continue + } + + const blocks = extractBlocks(markdown) + const { fontSize, overflow } = findOptimalFontSize(blocks, settings) + if (overflow) globalOverflow = true + if (i === 0) displayFontSize = fontSize + + const html = await parse(markdown) + let currentFontSize = fontSize + applyStyles(state.page, settings, currentFontSize) + + const doc = new DOMParser().parseFromString(html, 'text/html') + state.content.replaceChildren(...Array.from(doc.body.childNodes).map(n => n.cloneNode(true))) + + // DOM fallback + const pageStyle = getComputedStyle(state.page) + const availableHeight = state.page.clientHeight - parseFloat(pageStyle.paddingTop) - parseFloat(pageStyle.paddingBottom) + + if (state.content.scrollHeight > availableHeight && currentFontSize > 6) { + let lo = 6 + let hi = currentFontSize + while (hi - lo > 0.25) { + const mid = (lo + hi) / 2 + applyStyles(state.page, settings, mid) + if (state.content.scrollHeight <= availableHeight) { + lo = mid + } else { + hi = mid + } + } + currentFontSize = Math.floor(lo * 4) / 4 + applyStyles(state.page, settings, currentFontSize) + if (i === 0) displayFontSize = currentFontSize + + if (currentFontSize <= 6.25 && state.content.scrollHeight > availableHeight) { + globalOverflow = true } } - currentFontSize = Math.floor(lo * 4) / 4 - applyStyles(settings, currentFontSize) - statusFontSize.textContent = `${currentFontSize.toFixed(1)}px` - statusOverflow.classList.toggle('visible', currentFontSize <= 6.25 && a4Content.scrollHeight > availableHeight) } + + statusFontSize.textContent = displayFontSize > 0 ? `${displayFontSize.toFixed(1)}px` : '—' + statusOverflow.classList.toggle('visible', globalOverflow) + applyScale() } -const THEME_CLASSES = [ - 'theme-classic', 'theme-warm', 'theme-academic', 'theme-editorial', - 'theme-smartisan', 'theme-noir', 'theme-mint', 'theme-ink', 'theme-tech', 'theme-kraft', -] - -function applyStyles(settings: StyleSettings, fontSize: number): void { - // Theme class - a4Page.classList.remove(...THEME_CLASSES) - a4Page.classList.add(`theme-${settings.theme}`) - - a4Page.style.padding = `${settings.marginMm}mm` - a4Page.style.fontFamily = `"${settings.fontFamily}", -apple-system, sans-serif` - a4Page.style.fontSize = `${fontSize}px` - a4Page.style.lineHeight = String(settings.lineHeightRatio) - a4Page.style.setProperty('--ps', `${settings.paragraphSpacing}em`) - a4Page.style.setProperty('--fi', `${settings.firstLineIndent}em`) +function applyStyles(page: HTMLElement, settings: StyleSettings, fontSize: number): void { + const themeClasses = [ + 'theme-classic', 'theme-warm', 'theme-academic', 'theme-editorial', + 'theme-smartisan', 'theme-noir', 'theme-mint', 'theme-ink', 'theme-tech', 'theme-kraft', + ] + page.classList.remove(...themeClasses) + page.classList.add(`theme-${settings.theme}`) + + page.style.padding = `${settings.marginMm}mm` + page.style.fontFamily = `"${settings.fontFamily}", -apple-system, sans-serif` + page.style.fontSize = `${fontSize}px` + page.style.lineHeight = String(settings.lineHeightRatio) + page.style.setProperty('--ps', `${settings.paragraphSpacing}em`) + page.style.setProperty('--fi', `${settings.firstLineIndent}em`) } -// Init document.addEventListener('DOMContentLoaded', () => { buildDOM() - // Pre-fill with default sample (resume) - textarea.value = SAMPLES[0].content - scheduleUpdate() }) diff --git a/src/markdown.ts b/src/markdown.ts index 5faabbb..d6305e6 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -1,6 +1,6 @@ import { type Token, type Tokens, lexer } from 'marked' -export type BlockType = 'paragraph' | 'heading' | 'code' | 'blockquote' | 'listitem' | 'hr' | 'space' +export type BlockType = 'paragraph' | 'heading' | 'code' | 'blockquote' | 'listitem' | 'hr' | 'space' | 'pagebreak' export interface Block { type: BlockType @@ -9,6 +9,11 @@ export interface Block { listIndentLevel?: number } +export interface PageBlocks { + pages: Block[][] + hasPageBreaks: boolean +} + function extractPlainText(tokens: Token[]): string { let result = '' for (const token of tokens) { @@ -106,3 +111,43 @@ export function extractBlocks(markdown: string): Block[] { const tokens = lexer(markdown) return processTokens(tokens) } + +export function extractPages(markdown: string): PageBlocks { + const tokens = lexer(markdown) + const blocks = processTokens(tokens) + + // Check for page breaks + const hasPageBreaks = blocks.some(b => b.type === 'hr') + + if (!hasPageBreaks) { + return { pages: [blocks], hasPageBreaks: false } + } + + // Split blocks by hr (page break) + const pages: Block[][] = [] + let currentPage: Block[] = [] + + for (const block of blocks) { + if (block.type === 'hr') { + // End current page if it has content + if (currentPage.length > 0) { + pages.push(currentPage) + currentPage = [] + } + } else { + currentPage.push(block) + } + } + + // Add last page if it has content + if (currentPage.length > 0) { + pages.push(currentPage) + } + + // If no pages were created, return empty + if (pages.length === 0) { + return { pages: [[]], hasPageBreaks: false } + } + + return { pages, hasPageBreaks: true } +} diff --git a/src/samples.ts b/src/samples.ts index 7567108..57287e5 100644 --- a/src/samples.ts +++ b/src/samples.ts @@ -326,154 +326,6 @@ zhang.wei@email.com | +86 138-0000-0000 - 保修期 12 个月,扫描包装内二维码注册延保至 24 个月 **客服热线:** 400-888-7700 | **官网:** www.airpulse.com -`, - }, - { - label: '活动传单', - value: 'flyer', - content: `# 2026 上海开发者大会 - -## DevFest Shanghai - -**日期:** 2026 年 5 月 17 日(周六) -**时间:** 09:00 - 18:00 -**地点:** 上海世博中心 3 号厅 - ---- - -## 主题演讲 - -### 上午场(09:30 - 12:00) - -- **09:30** — 开幕致辞 -- **10:00** — 《AI 原生应用的工程实践》 — 张明宇,某大厂 AI 平台负责人 -- **11:00** — 《从单体到 Serverless:百万级应用的架构演进》 — 李婷,云服务架构师 - -### 下午场(13:30 - 17:30) - -- **13:30** — 《Rust 在生产环境中的实战经验》 — 王浩然,系统工程师 -- **14:30** — 《设计系统:从 0 到 1 搭建企业级组件库》 — 陈思雨,前端负责人 -- **15:30** — 茶歇 & 自由交流 -- **16:00** — 圆桌讨论:《开源的未来》 -- **17:00** — 闪电演讲(每人 5 分钟 × 6 位) - ---- - -## 工作坊(需提前报名) - -| 时段 | 主题 | 人数上限 | -|------|------|:--------:| -| 10:00 - 12:00 | AI Agent 动手实验室 | 40 | -| 13:30 - 15:30 | WebAssembly 入门到实战 | 30 | -| 16:00 - 18:00 | 开源贡献者第一步 | 50 | - -## 参会信息 - -- **票价:** 早鸟 ¥99 / 标准 ¥199 / 学生免费(凭学生证) -- **报名:** 扫描下方二维码或访问 devfest-sh.dev -- **交通:** 地铁 13 号线世博大道站 3 号口步行 5 分钟 - -**主办方:** 上海开发者社区 | **赞助商:** 诚邀合作,联系 sponsor@devfest-sh.dev -`, - }, - { - label: '菜单价目表', - value: 'menu', - content: `# 山间咖啡 Mountain Brew - -*精品手冲 · 自制甜品 · 安静空间* - ---- - -## 咖啡 - -| 品名 | 中杯 | 大杯 | -|------|:----:|:----:| -| 美式 Americano | ¥22 | ¥28 | -| 拿铁 Latte | ¥28 | ¥34 | -| 卡布奇诺 Cappuccino | ¥28 | ¥34 | -| 澳白 Flat White | ¥30 | ¥36 | -| 摩卡 Mocha | ¥32 | ¥38 | -| 冷萃 Cold Brew | ¥32 | ¥38 | -| 手冲单品 Pour Over | ¥38 | — | - -> 可选豆:耶加雪菲 / 瑰夏 / 曼特宁 / 哥伦比亚 -> 加浓 +¥5 | 换燕麦奶 +¥6 | 加香草/榛果糖浆 +¥4 - -## 特调饮品 - -| 品名 | 价格 | -|------|:----:| -| 桂花拿铁 | ¥35 | -| 生椰冷萃 | ¥36 | -| 柚子美式 | ¥32 | -| 抹茶燕麦拿铁 | ¥36 | -| 黑糖脏脏拿铁 | ¥35 | - -## 茶饮 - -| 品名 | 价格 | -|------|:----:| -| 茉莉花茶 | ¥22 | -| 伯爵红茶 | ¥22 | -| 抹茶拿铁 | ¥30 | -| 柠檬气泡水 | ¥20 | - -## 甜品 - -| 品名 | 价格 | -|------|:----:| -| 巴斯克芝士蛋糕 | ¥38 | -| 提拉米苏 | ¥36 | -| 肉桂苹果派 | ¥32 | -| 可颂 / 巧克力可颂 | ¥22 / ¥26 | -| 司康(原味/蔓越莓) | ¥20 | - ---- - -**营业时间:** 周一至周五 08:00-21:00 | 周末 09:00-22:00 -**地址:** 上海市长宁区愚园路 1088 号 102 室 -**电话:** 021-6288-7700 | **Wi-Fi:** MountainBrew(密码见吧台) -`, - }, - { - label: '求职信', - value: 'coverletter', - content: `# 求职信 - -**应聘职位:** 高级前端工程师 -**申请人:** 林晓薇 - ---- - -尊敬的招聘负责人: - -您好!我是林晓薇,一名拥有 6 年前端开发经验的工程师。通过贵公司官网了解到高级前端工程师的招聘信息,我对这个职位非常感兴趣,特此投递简历并附上这封求职信。 - -## 为什么选择贵公司 - -我长期关注贵公司在智能协作工具领域的产品创新。去年发布的实时协作编辑器在技术实现上令人印象深刻——CRDT 算法的运用、毫秒级的同步延迟、以及流畅的离线体验,这些正是我热衷钻研的技术方向。我希望能加入团队,与优秀的工程师一起打造下一代生产力工具。 - -## 我能带来什么 - -**技术深度:** 在现公司主导了编辑器内核从 Draft.js 到 ProseMirror 的迁移,重写了富文本渲染管线,首屏加载时间从 3.2 秒降至 0.8 秒。对浏览器渲染原理、性能优化有深入理解。 - -**工程能力:** 搭建了前端 Monorepo 基础设施(Turborepo + pnpm),统一了 5 个产品线的组件库和构建流程。推行自动化测试,将核心模块覆盖率从 30% 提升至 85%。 - -**团队协作:** 作为 3 人前端小组的 Tech Lead,制定技术规范、主持 Code Review、负责新人培养。善于将复杂技术决策转化为团队共识。 - -## 期望与展望 - -我期望在一个技术驱动的环境中持续成长。无论是编辑器底层、性能优化、还是设计系统建设,我都愿意深入投入。相信我的技术积累和工程思维能为团队创造实际价值。 - -期待有机会与您进一步交流。感谢您的时间! - -此致 -敬礼 - -**林晓薇** -xiaowei.lin@email.com | +86 139-0000-0000 -2026 年 3 月 30 日 `, }, ] diff --git a/src/style.css b/src/style.css index b9b0c97..7488f3a 100644 --- a/src/style.css +++ b/src/style.css @@ -98,6 +98,14 @@ body { font-weight: 600; } +.status-pages { + background: var(--accent-dim); + color: var(--accent); + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; +} + .status-overflow { color: var(--danger); font-weight: 600; @@ -139,24 +147,62 @@ body { min-width: 0; } -.textarea-wrapper { - flex: 1 1 0; +/* ─── Editors Header ─────────────────────── */ +.editors-header { display: flex; - flex-direction: column; - min-height: 200px; - overflow: hidden; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; } -.textarea-header { - padding: 12px 16px 8px; +.editors-header > span { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); +} + +.btn-add-page { + background: var(--accent); + color: #ffffff; + border: none; + padding: 5px 12px; + border-radius: var(--radius); + font-family: var(--font-ui); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} + +.btn-add-page:hover { + opacity: 0.85; +} + +/* ─── Editors Wrapper ────────────────────── */ +.editors-wrapper { + flex: 1 1 0; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +.sample-bar { display: flex; align-items: center; - justify-content: space-between; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.sample-label { + font-size: 11px; + color: var(--text-muted); } .sample-select { @@ -167,9 +213,6 @@ body { color: var(--text-primary); font-family: var(--font-ui); font-size: 11px; - font-weight: 400; - text-transform: none; - letter-spacing: normal; cursor: pointer; outline: none; } @@ -178,23 +221,69 @@ body { border-color: var(--border-focus); } -.input-textarea { +.editors-list { flex: 1 1 0; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ─── Editor Card ────────────────────────── */ +.editor-card { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + flex-shrink: 0; +} + +.editor-card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--bg-input); + border-bottom: 1px solid var(--border); +} + +.page-num-label { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); +} + +.btn-delete-page { + background: none; + border: none; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + padding: 2px 6px; + border-radius: 3px; + transition: all 0.15s; +} + +.btn-delete-page:hover { + background: var(--danger); + color: #ffffff; +} + +.editor-textarea { width: 100%; - padding: 0 16px 16px; + min-height: 120px; + padding: 12px; background: transparent; border: none; - resize: none; + resize: vertical; font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-primary); outline: none; - overflow-y: auto; - min-height: 0; } -.input-textarea::placeholder { +.editor-textarea::placeholder { color: var(--text-muted); } @@ -290,7 +379,6 @@ body { min-height: 0; min-width: 0; cursor: grab; - /* subtle cross pattern */ background-image: radial-gradient(circle, #d5d0c6 1px, transparent 1px); background-size: 20px 20px; @@ -302,10 +390,12 @@ body { user-select: none; } -/* ─── A4 Wrapper (handles scaling) ────────── */ +/* ─── A4 Wrapper ─────────────────────────── */ .a4-wrapper { - position: relative; - margin: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; } /* ─── A4 Page ────────────────────────────── */ @@ -314,12 +404,9 @@ body { height: 297mm; overflow: hidden; box-sizing: border-box; - position: absolute; - top: 0; - left: 0; + flex-shrink: 0; font-family: 'Noto Sans SC', 'Inter', sans-serif; - /* Theme variables (defaults = classic) */ --page-bg: #ffffff; --page-text: #1a1a1a; --heading-color: #1a1a1a; @@ -332,7 +419,6 @@ body { --table-border: #d8dee4; --table-header-bg: #f6f8fa; - /* Layout variables (set by JS) */ --ps: 0.5em; --fi: 0; @@ -343,19 +429,7 @@ body { 0 0 0 1px rgba(0, 0, 0, 0.05); } -.a4-placeholder { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - color: #b0a898; - font-size: 14px; - font-style: italic; - pointer-events: none; -} - -/* ─── A4 Typography (all em-based) ───────── */ +/* ─── A4 Typography ──────────────────────── */ .a4-page h1, .a4-page h2, .a4-page h3, .a4-page h4, .a4-page h5, .a4-page h6 { color: var(--heading-color); @@ -495,9 +569,7 @@ body { font-weight: 600; } -/* ─── Theme: Classic (default, variables set on .a4-page) ── */ - -/* ─── Theme: Warm Paper ─────────────────── */ +/* ─── Themes ─────────────────────────────── */ .a4-page.theme-warm { --page-bg: #faf5eb; --page-text: #3c3226; @@ -512,7 +584,6 @@ body { --table-header-bg: #f3ead6; } -/* ─── Theme: Academic ───────────────────── */ .a4-page.theme-academic { --page-bg: #ffffff; --page-text: #222222; @@ -536,7 +607,6 @@ body { border-top-width: 2px; } -/* ─── Theme: Editorial / Magazine ───────── */ .a4-page.theme-editorial { --page-bg: #ffffff; --page-text: #1a1a1a; @@ -565,7 +635,6 @@ body { border-top-width: 3px; } -/* ─── Theme: Smartisan Notes (锤子便签) ──── */ .a4-page.theme-smartisan { --page-bg: #faf8f2; --page-text: #333333; @@ -600,7 +669,6 @@ body { border-left-width: 3px; } -/* ─── Theme: Noir (暗夜) ──────────────────── */ .a4-page.theme-noir { --page-bg: #1a1a2e; --page-text: #d4d4dc; @@ -619,7 +687,6 @@ body { border-top-width: 1px; } -/* ─── Theme: Mint (薄荷) ──────────────────── */ .a4-page.theme-mint { --page-bg: #f2faf6; --page-text: #1a2e26; @@ -639,7 +706,6 @@ body { border-bottom: 1.5px solid #2ea07a; } -/* ─── Theme: Ink (水墨) ────────────────────── */ .a4-page.theme-ink { --page-bg: #f7f5ef; --page-text: #1a1a1a; @@ -673,7 +739,6 @@ body { border-left-color: #1a1a1a; } -/* ─── Theme: Tech (科技) ──────────────────── */ .a4-page.theme-tech { --page-bg: #f8f9fc; --page-text: #1e293b; @@ -701,7 +766,6 @@ body { color: #0958d9; } -/* ─── Theme: Kraft (牛皮纸) ────────────────── */ .a4-page.theme-kraft { --page-bg: #e8dcc8; --page-text: #3d3122; @@ -737,34 +801,62 @@ body { background: white; } - body * { + body.is-printing { + background: white; + } + + body.is-printing * { visibility: hidden; } - .a4-wrapper, .a4-wrapper * { + body.is-printing #app, + body.is-printing .app, + body.is-printing .right-panel, + body.is-printing .a4-wrapper, + body.is-printing .a4-wrapper * { visibility: visible !important; } - .a4-wrapper { - position: absolute !important; - left: 0 !important; - top: 0 !important; - width: 210mm !important; - height: 297mm !important; + body.is-printing #app, + body.is-printing .app { + display: block !important; + height: auto !important; + overflow: visible !important; } - .a4-page { - position: absolute; - left: 0; - top: 0; - width: 210mm; - height: 297mm; + body.is-printing .topbar, + body.is-printing .left-panel { + display: none !important; + } + + body.is-printing .right-panel { + display: block !important; + overflow: visible !important; + padding: 0 !important; + min-height: auto !important; + min-width: auto !important; + background: white !important; + background-image: none !important; + cursor: default !important; + } + + body.is-printing .a4-wrapper { + position: static !important; + display: block !important; + } + + body.is-printing .a4-page { + width: 210mm !important; + height: 297mm !important; box-shadow: none; margin: 0; transform: none !important; + break-after: page; + page-break-after: always; } - .a4-placeholder { - display: none !important; + body.is-printing .a4-page:last-child { + break-after: auto; + page-break-after: auto; } } From 5a10ab7322ddc7dbc04a5884ccef999a46fefd83 Mon Sep 17 00:00:00 2001 From: masonsxu Date: Fri, 3 Apr 2026 11:03:55 +0800 Subject: [PATCH 2/4] fix: restore missing sample templates Restore the flyer, menu, and cover letter sample templates that were accidentally removed while updating the multi-page workflow. --- src/samples.ts | 148 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/src/samples.ts b/src/samples.ts index 57287e5..7567108 100644 --- a/src/samples.ts +++ b/src/samples.ts @@ -326,6 +326,154 @@ zhang.wei@email.com | +86 138-0000-0000 - 保修期 12 个月,扫描包装内二维码注册延保至 24 个月 **客服热线:** 400-888-7700 | **官网:** www.airpulse.com +`, + }, + { + label: '活动传单', + value: 'flyer', + content: `# 2026 上海开发者大会 + +## DevFest Shanghai + +**日期:** 2026 年 5 月 17 日(周六) +**时间:** 09:00 - 18:00 +**地点:** 上海世博中心 3 号厅 + +--- + +## 主题演讲 + +### 上午场(09:30 - 12:00) + +- **09:30** — 开幕致辞 +- **10:00** — 《AI 原生应用的工程实践》 — 张明宇,某大厂 AI 平台负责人 +- **11:00** — 《从单体到 Serverless:百万级应用的架构演进》 — 李婷,云服务架构师 + +### 下午场(13:30 - 17:30) + +- **13:30** — 《Rust 在生产环境中的实战经验》 — 王浩然,系统工程师 +- **14:30** — 《设计系统:从 0 到 1 搭建企业级组件库》 — 陈思雨,前端负责人 +- **15:30** — 茶歇 & 自由交流 +- **16:00** — 圆桌讨论:《开源的未来》 +- **17:00** — 闪电演讲(每人 5 分钟 × 6 位) + +--- + +## 工作坊(需提前报名) + +| 时段 | 主题 | 人数上限 | +|------|------|:--------:| +| 10:00 - 12:00 | AI Agent 动手实验室 | 40 | +| 13:30 - 15:30 | WebAssembly 入门到实战 | 30 | +| 16:00 - 18:00 | 开源贡献者第一步 | 50 | + +## 参会信息 + +- **票价:** 早鸟 ¥99 / 标准 ¥199 / 学生免费(凭学生证) +- **报名:** 扫描下方二维码或访问 devfest-sh.dev +- **交通:** 地铁 13 号线世博大道站 3 号口步行 5 分钟 + +**主办方:** 上海开发者社区 | **赞助商:** 诚邀合作,联系 sponsor@devfest-sh.dev +`, + }, + { + label: '菜单价目表', + value: 'menu', + content: `# 山间咖啡 Mountain Brew + +*精品手冲 · 自制甜品 · 安静空间* + +--- + +## 咖啡 + +| 品名 | 中杯 | 大杯 | +|------|:----:|:----:| +| 美式 Americano | ¥22 | ¥28 | +| 拿铁 Latte | ¥28 | ¥34 | +| 卡布奇诺 Cappuccino | ¥28 | ¥34 | +| 澳白 Flat White | ¥30 | ¥36 | +| 摩卡 Mocha | ¥32 | ¥38 | +| 冷萃 Cold Brew | ¥32 | ¥38 | +| 手冲单品 Pour Over | ¥38 | — | + +> 可选豆:耶加雪菲 / 瑰夏 / 曼特宁 / 哥伦比亚 +> 加浓 +¥5 | 换燕麦奶 +¥6 | 加香草/榛果糖浆 +¥4 + +## 特调饮品 + +| 品名 | 价格 | +|------|:----:| +| 桂花拿铁 | ¥35 | +| 生椰冷萃 | ¥36 | +| 柚子美式 | ¥32 | +| 抹茶燕麦拿铁 | ¥36 | +| 黑糖脏脏拿铁 | ¥35 | + +## 茶饮 + +| 品名 | 价格 | +|------|:----:| +| 茉莉花茶 | ¥22 | +| 伯爵红茶 | ¥22 | +| 抹茶拿铁 | ¥30 | +| 柠檬气泡水 | ¥20 | + +## 甜品 + +| 品名 | 价格 | +|------|:----:| +| 巴斯克芝士蛋糕 | ¥38 | +| 提拉米苏 | ¥36 | +| 肉桂苹果派 | ¥32 | +| 可颂 / 巧克力可颂 | ¥22 / ¥26 | +| 司康(原味/蔓越莓) | ¥20 | + +--- + +**营业时间:** 周一至周五 08:00-21:00 | 周末 09:00-22:00 +**地址:** 上海市长宁区愚园路 1088 号 102 室 +**电话:** 021-6288-7700 | **Wi-Fi:** MountainBrew(密码见吧台) +`, + }, + { + label: '求职信', + value: 'coverletter', + content: `# 求职信 + +**应聘职位:** 高级前端工程师 +**申请人:** 林晓薇 + +--- + +尊敬的招聘负责人: + +您好!我是林晓薇,一名拥有 6 年前端开发经验的工程师。通过贵公司官网了解到高级前端工程师的招聘信息,我对这个职位非常感兴趣,特此投递简历并附上这封求职信。 + +## 为什么选择贵公司 + +我长期关注贵公司在智能协作工具领域的产品创新。去年发布的实时协作编辑器在技术实现上令人印象深刻——CRDT 算法的运用、毫秒级的同步延迟、以及流畅的离线体验,这些正是我热衷钻研的技术方向。我希望能加入团队,与优秀的工程师一起打造下一代生产力工具。 + +## 我能带来什么 + +**技术深度:** 在现公司主导了编辑器内核从 Draft.js 到 ProseMirror 的迁移,重写了富文本渲染管线,首屏加载时间从 3.2 秒降至 0.8 秒。对浏览器渲染原理、性能优化有深入理解。 + +**工程能力:** 搭建了前端 Monorepo 基础设施(Turborepo + pnpm),统一了 5 个产品线的组件库和构建流程。推行自动化测试,将核心模块覆盖率从 30% 提升至 85%。 + +**团队协作:** 作为 3 人前端小组的 Tech Lead,制定技术规范、主持 Code Review、负责新人培养。善于将复杂技术决策转化为团队共识。 + +## 期望与展望 + +我期望在一个技术驱动的环境中持续成长。无论是编辑器底层、性能优化、还是设计系统建设,我都愿意深入投入。相信我的技术积累和工程思维能为团队创造实际价值。 + +期待有机会与您进一步交流。感谢您的时间! + +此致 +敬礼 + +**林晓薇** +xiaowei.lin@email.com | +86 139-0000-0000 +2026 年 3 月 30 日 `, }, ] From 785119b270d675c64ae958b4e8020257b51732fc Mon Sep 17 00:00:00 2001 From: masonsxu Date: Fri, 3 Apr 2026 11:12:37 +0800 Subject: [PATCH 3/4] fix: address review feedback for multi-page flow Fix page deletion so buttons always remove the correct page, improve the font size status when the first page is empty, and remove unused markdown pagination helpers. --- src/main.ts | 23 +++++++++++++---------- src/markdown.ts | 47 +---------------------------------------------- 2 files changed, 14 insertions(+), 56 deletions(-) diff --git a/src/main.ts b/src/main.ts index ff10aea..767aec9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -225,8 +225,7 @@ window.addEventListener('afterprint', () => { }) function addPage(initialContent = ''): void { - const index = pageStates.length - const pageNum = index + 1 + const pageNum = pageStates.length + 1 // Create editor const editorCard = document.createElement('div') @@ -242,7 +241,6 @@ function addPage(initialContent = ''): void { const deleteBtn = document.createElement('button') deleteBtn.className = 'btn-delete-page' deleteBtn.textContent = '删除' - deleteBtn.addEventListener('click', () => removePage(index)) editorHeader.append(pageNumLabel, deleteBtn) @@ -266,18 +264,23 @@ function addPage(initialContent = ''): void { page.appendChild(content) a4Wrapper.appendChild(page) - pageStates.push({ editor: textarea, page, content }) + const pageState = { editor: textarea, page, content } + deleteBtn.addEventListener('click', () => removePage(pageState)) + + pageStates.push(pageState) updatePageLabels() scheduleUpdate() } -function removePage(index: number): void { +function removePage(pageState: PageState): void { if (pageStates.length <= 1) return - const state = pageStates[index] - state.editor.closest('.editor-card')!.remove() - state.page.remove() + const index = pageStates.indexOf(pageState) + if (index === -1) return + + pageState.editor.closest('.editor-card')!.remove() + pageState.page.remove() pageStates.splice(index, 1) updatePageLabels() @@ -368,7 +371,7 @@ async function update(): Promise { const blocks = extractBlocks(markdown) const { fontSize, overflow } = findOptimalFontSize(blocks, settings) if (overflow) globalOverflow = true - if (i === 0) displayFontSize = fontSize + if (displayFontSize === 0) displayFontSize = fontSize const html = await parse(markdown) let currentFontSize = fontSize @@ -395,7 +398,7 @@ async function update(): Promise { } currentFontSize = Math.floor(lo * 4) / 4 applyStyles(state.page, settings, currentFontSize) - if (i === 0) displayFontSize = currentFontSize + if (displayFontSize === fontSize) displayFontSize = currentFontSize if (currentFontSize <= 6.25 && state.content.scrollHeight > availableHeight) { globalOverflow = true diff --git a/src/markdown.ts b/src/markdown.ts index d6305e6..5faabbb 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -1,6 +1,6 @@ import { type Token, type Tokens, lexer } from 'marked' -export type BlockType = 'paragraph' | 'heading' | 'code' | 'blockquote' | 'listitem' | 'hr' | 'space' | 'pagebreak' +export type BlockType = 'paragraph' | 'heading' | 'code' | 'blockquote' | 'listitem' | 'hr' | 'space' export interface Block { type: BlockType @@ -9,11 +9,6 @@ export interface Block { listIndentLevel?: number } -export interface PageBlocks { - pages: Block[][] - hasPageBreaks: boolean -} - function extractPlainText(tokens: Token[]): string { let result = '' for (const token of tokens) { @@ -111,43 +106,3 @@ export function extractBlocks(markdown: string): Block[] { const tokens = lexer(markdown) return processTokens(tokens) } - -export function extractPages(markdown: string): PageBlocks { - const tokens = lexer(markdown) - const blocks = processTokens(tokens) - - // Check for page breaks - const hasPageBreaks = blocks.some(b => b.type === 'hr') - - if (!hasPageBreaks) { - return { pages: [blocks], hasPageBreaks: false } - } - - // Split blocks by hr (page break) - const pages: Block[][] = [] - let currentPage: Block[] = [] - - for (const block of blocks) { - if (block.type === 'hr') { - // End current page if it has content - if (currentPage.length > 0) { - pages.push(currentPage) - currentPage = [] - } - } else { - currentPage.push(block) - } - } - - // Add last page if it has content - if (currentPage.length > 0) { - pages.push(currentPage) - } - - // If no pages were created, return empty - if (pages.length === 0) { - return { pages: [[]], hasPageBreaks: false } - } - - return { pages, hasPageBreaks: true } -} From e7f0c64e9c11e4b950cab81374a26477b279a073 Mon Sep 17 00:00:00 2001 From: masonsxu Date: Fri, 3 Apr 2026 11:22:37 +0800 Subject: [PATCH 4/4] feat: add a multi-page resume sample Support sample templates that span multiple editor pages and add a two-page frontend resume example to make the multi-page workflow easier to review and test. --- src/main.ts | 18 ++++--- src/samples.ts | 129 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 8 deletions(-) diff --git a/src/main.ts b/src/main.ts index 767aec9..f377dd5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -110,7 +110,7 @@ function buildDOM(): void { sampleSelect.addEventListener('change', () => { const sample = SAMPLES.find(s => s.value === sampleSelect.value) if (sample) { - loadSample(sample.content) + loadSample(sample) } }) @@ -296,19 +296,23 @@ function updatePageLabels(): void { statusPages.textContent = `${pageStates.length} 页` } -function loadSample(content: string): void { - // Clear all pages except the first one - while (pageStates.length > 1) { +function loadSample(sample: { content?: string; pages?: string[] }): void { + const pages = sample.pages ?? [sample.content ?? ''] + + while (pageStates.length > pages.length) { const state = pageStates.pop()! state.editor.closest('.editor-card')!.remove() state.page.remove() } - // Load content into first page - if (pageStates.length > 0) { - pageStates[0].editor.value = content + while (pageStates.length < pages.length) { + addPage() } + pageStates.forEach((state, index) => { + state.editor.value = pages[index] ?? '' + }) + scheduleUpdate() } diff --git a/src/samples.ts b/src/samples.ts index 7567108..11886ec 100644 --- a/src/samples.ts +++ b/src/samples.ts @@ -1,10 +1,137 @@ export interface Sample { label: string value: string - content: string + content?: string + pages?: string[] } export const SAMPLES: Sample[] = [ + { + label: '多页前端简历', + value: 'multi-page-resume', + pages: [ + `# 林予安 + +**资深前端工程师 · React / Next.js / Web 性能优化方向** + +6 年前端研发经验,聚焦中后台系统、增长型 Web +应用与前端工程体系建设。擅长在复杂业务场景下推进组件化设计、性能治理、工程规范落地与跨团队协作,能够将前端交付从“页面实现”升级为“体验与效率并重”的工程体系。 + +## 核心亮点 + +- 主导 Aurora Console 前端从单仓页面集合演进为 **12 个业务模块** 的统一平台,建立 Design System、模块边界与状态管理规范。 +- 搭建增长分析平台与运营工作台,沉淀 **40+ 可复用组件**、**20+ 页面模板**,支撑多业务线快速交付。 +- 推动首屏加载从 **4.2s → 1.6s**,构建产物体积 **-38%**,线上 JS 错误率 **-52%**,需求交付效率 **+45%**。 + +## 联系方式 + +- **Email**: [linyuan@example.com](mailto:linyuan@example.com) +- **GitHub**: [github.com/linyuan-demo](https://github.com/linyuan-demo) +- **Website**: [linyuan-portfolio.dev](https://linyuan-portfolio.dev/) +- **Location**: 现居杭州 · 意向城市:上海 / 杭州 / 深圳 + +## 个人简介 + +以用户体验和工程效率双目标驱动前端建设,能够承担核心页面架构设计、性能优化、组件体系沉淀与前后端协作推进,也能把团队开发模式从“页面堆砌”推进到“标准化、可维护、可复用”的工程体系。目标岗位 +为高级前端工程师 / 前端架构师 / 技术负责人。 + +- 推动中后台系统统一布局、权限路由、表单规范与可视化配置方案落地,形成可复用的产品交付底座。 +- 建立 Web Vitals、Sentry 与埋点分析联动链路,缩短线上问题排查路径,提升前端稳定性交付能力。 +- 沉淀组件库、脚手架、代码规范与发布流程,减少重复造轮子与跨项目迁移成本。 +- 输出项目文档 **28 份**、累计约 **9000 行**,覆盖架构、规范、组件说明、接入文档与交接资料,提升团队协作与知识传承效率。 + +## 工作经历 + +### 前端架构师 / 技术负责人 +**杭州星帆数字科技有限公司** +2024.02 - 至今 + +技术架构与 Web 平台建设 · 核心技术成员 + +- 在多业务域并行推进场景下主导前端平台升级,完成覆盖 **12 个核心模块** 的中后台统一控制台建设,支撑多团队协同开发与独立迭代。 +- 建立 Design Token、组件分层、目录约束与 Monorepo 工程规范,将页面开发从“单点实现”推进为“体系化复用”。 +- 推动 10 人前端协作机制标准化,围绕代码评审、组件复用、视觉一致性与发布流程建立可复制的工程方案。 +- 建设前端监控、埋点与错误告警联动链路,打通从性能指标到异常定位的排障路径,提升线上止损效率。 +- 完成结构化文档与交接资产沉淀,为团队扩展、新成员接入与平台持续演进提供稳定基础。 + +### 前端开发工程师 +**杭州星帆数字科技有限公司** +2020.06 - 2024.02 + +- 基于 React、TypeScript 与 React Query 重构核心工作台页面,优化状态流转与接口缓存策略,使页面响应效率提升 **43%**。 +- 推动前端工程从传统多页面构建迁移到 Vite + 模块化架构,将本地启动时间从 **95 秒缩短至 18 秒**,整体研发效率提升 **60%**。 +- 通过资源拆分、图片懒加载与按需加载治理,使首屏加载时间下降 **62%**,用户留存相关页面转化率提升 **18%**。 +- 多次在活动大促与关键版本发布期间完成线上问题快速定位与修复,降低核心业务页面异常影响范围。`, + `## 项目经历 + +### Aurora Console 企业级中后台平台 +**技术负责人 · 生产系统** +2024.02 - 至今 + +**技术栈**:React、TypeScript、Next.js、Zustand、TanStack Query、Tailwind CSS、Radix UI、Vite、pnpm、Turborepo + +- 统一建设 **12 个业务模块** 的管理控制台,覆盖权限管理、数据报表、配置中心、运营工作台等核心场景。 +- 建立 Design System 与前端规范体系,沉淀按钮、表格、表单、弹窗、筛选器等 **40+ 通用组件**,显著降低重复开发成本。 +- 推动权限路由、菜单配置、动态表单与页面模板标准化,使复杂后台页面具备更强的扩展性与一致性。 +- 实现错误监控、性能采集与核心操作埋点联动,提升问题发现效率与业务分析能力。 + +### Nova Growth 增长分析平台 +**核心开发 · 生产系统** +2022 - 2025 + +**技术栈**:React、TypeScript、ECharts、Ant Design、React Hook Form、Sentry、Node.js BFF + +- 建设营销分析、漏斗转化、用户分群与活动追踪等核心页面,支撑运营团队日常决策与增长实验。 +- 实现多维筛选、复杂图表联动与大表格虚拟滚动,提升大数据量场景下的交互体验与可用性。 +- 通过接口聚合、缓存策略与骨架屏方案优化,显著改善报表页面加载体验与交互流畅度。 +- 推动前端与 BFF 协作重构聚合接口,减少页面串行请求数量,提升复杂页面整体渲染效率。 + +### 组件库与工程化实践 +**架构实践 / 内部平台建设** +2023 - 至今 + +**项目地址**:[github.com/linyuan-demo/frontend-ui-example](https://github.com/linyuan-demo/frontend-ui-example) + +- 基于业务经验沉淀内部组件库与模板工程,覆盖主题系统、表单能力、表格能力、图表封装与权限能力。 +- 推动前端脚手架、提交规范、自动化校验与 CI 工作流落地,提升从开发到发布的端到端效率。 +- 建立 Storybook 文档与组件示例体系,降低新成员接入成本并增强跨项目复用能力。 + +## 技能 + +### 前端架构与应用开发 +React / Next.js / TypeScript / JavaScript / Zustand / Redux Toolkit / TanStack Query / React Hook Form / SSR / SSG + +### UI 系统与交互体验 +Tailwind CSS / Sass / Radix UI / Ant Design / Design System / Design Token / 响应式布局 / 无障碍基础 / 组件封装 + +### 性能优化与稳定性治理 +Web Vitals / Lighthouse / 代码分割 / 懒加载 / 虚拟列表 / 首屏优化 / Sentry / 埋点分析 / 错误监控 + +### 工程化与协作 +Vite / Webpack / pnpm / Turborepo / Monorepo / ESLint / Prettier / CI/CD / Storybook / Playwright / Vitest + +## 教育 + +### 华东理工大学 +**数字媒体技术** +2016.09 - 2020.06 + +**荣誉奖项**:2019 · 校级优秀毕业设计预备项目|2018 / 2019 · 校级一等奖学金(2 次)|2017 · 创新实践优秀团队奖 + +## 其他 + +- 个人主页:[linyuan-portfolio.dev](https://linyuan-portfolio.dev/) +- 开源实践:持续维护前端组件封装示例项目,并参与若干 UI 生态问题讨论与示例贡献。 + +--- + +**LINYUAN.DEV** +最后更新:2026 年 4 月 +GitHub:[github.com/linyuan-demo](https://github.com/linyuan-demo) + +> 说明:以上内容为完全虚构的前端工程师简历,仅用于示例数据、页面展示与模板演示,不对应任何真实个人或真实公司履历。`, + ], + }, { label: '个人简历', value: 'resume',