🧭 Stepwise 网站操作指南生成器 · 项目文档汇总

Stepwise · 网站操作指南生成器

一个完全离线的浏览器扩展:在网页上拖框选区域,自动截图、标注、打码,生成带图解的 Word 操作指南。零账号、零服务器、零遥测。

项目概览

4
协作模块(popup / content / background / editor)
3
标注类型(紫框截图 / 红框标记 / 马赛克打码)
30
次提交 · 2026-07-20 ~ 08-18
0
第三方运行时依赖

一句话理解它:你在一堆网页上点一点、框一框,它把操作过程变成一份带截图和标注的 Word 说明文档。

核心产品理念:把「记什么、记哪里」的控制权交还给用户——用最熟悉的「拖框」方式,而不是让机器自动猜。

紫框 = 截图区域 红框 = 标记重点 马赛克 = 打码敏感

01需求设计

做什么、给谁用、为什么做成这样。

一、背景与问题

痛点一句话:把一个网页操作过程,变成一份别人能照着做的说明文档,这件事又费时又容易出错。

传统做法是:一边操作一边手动截图,再贴到 Word 里,一张张标箭头、写字。做一份 20 步的操作指南,可能要花一两个小时,而且截图经常截到无关内容、重点标不清楚。

市面上有 Tango 这类工具能自动做这件事,但它们往往是在线服务:要注册账号、数据上传云端、依赖服务器。对于内网系统、隐私敏感的教程、或者就是想离线用的人来说,这些都不合适。

所以本项目的定位很明确:做一个完全离线的浏览器扩展,把「录操作 → 生成带标注的 Word 指南」这件事做成零门槛、零配置。

通俗类比:它不是「云端剪辑软件」,而是「装在浏览器里的拍立得」——拍下来、当场就能看到、数据全留在你电脑里。

二、目标用户

用户场景诉求
客服 / 培训师给客户写「怎么用某个系统」的教程快、截图干净、能标注重点
技术支持写复现步骤、给用户发操作指引步骤清晰、能导出成正式文档
产品 / 项目经理记录演示流程、沉淀操作手册离线、可编辑、可反复修改
个人用户给爸妈写「怎么用手机银行网页版」零学习成本、无需账号

共同点:要的是「能交付的正式文档」,不是「录屏视频」或「一堆散截图」。

三、核心需求

按重要性排序:

  1. 手动框选截图:由用户自己拖框决定「截哪里」,而不是机器自动猜。这是本产品的第一性需求(详见 04-重难点 · 为什么从自动走向手动)。
  2. 三种标注合一:一次框选同时表达三种诉求——紫框 = 截图区域、红框 = 标记重点、马赛克框 = 打码敏感。
  3. 步骤可管理:每一步都能补文字、删、改、拖动排序。
  4. 导出 Word(.docx):生成 A4 纵向、截图直接嵌入的正式文档。
  5. 完全离线:不联网、无账号、无服务器、无遥测,数据存在浏览器本地。

四、功能清单

录制端(在网页上)

  • 点工具栏图标弹出控制面板,一键「开始录制」
  • 页面右下角出现状态栏,点「选择截图区域」进入截图模式
  • 截图模式覆盖一层蒙版,禁止误点网页
  • 拖拽画紫框(截图区域)
  • 紫框之上可选画红框(标记)、马赛克框(打码)
  • 点「完成」对该区域截图,自动合成红框 + 马赛克
  • 右下角滑出步骤确认条:「丢弃 / 确认保留(3 秒倒计时自动保留)」

编辑端(本地文档库)

  • 左侧文档列表,切换 / 选择文档
  • 修改文档标题、每步文案
  • 拖动步骤排序
  • 删除单步 / 插入纯文字步骤
  • 替换或上传步骤截图
  • 一键「导出文档」生成 .docx

明确的「不做」

  • 不做自动录制(点击/输入/滚动自动记)——机器猜不准重点
  • 不做在线同步、云存储、协作——违背离线定位
  • 不做视频导出——需求是「文档」不是「录像」

五、交互方案

为什么是「拖框」,而不是「点一下自动截」:早期方案试过「自动录制」和「hover 高亮 + 快捷键」,都因「截图不干净」「找不准重点」「有学习成本」被放弃。最终收敛为截图软件式的拖框——拖框选区域人人都会、零学习成本;框选是像素级的、不依赖网页 DOM 结构;三种颜色各司其职。

状态机(录制流程)

idle(未录制)
  └─ 开始录制 ──> record(选择截图区域阶段)
                    └─ 点「选择截图区域」──> edit(画框阶段)
                                              ├─ 画完紫框 ──> 显示红框/马赛克工具 + 「完成」
                                              └─ 点「完成」──> 截图 → 回到 record(可继续下一步)

反馈闭环:每完成一步,右下角滑出确认条,给用户「反悔」的机会,但用 3 秒倒计时自动保留兜底——既不强制多点一下,又给足了确认空间。

六、设计原则

  1. 离线优先:零账号、零服务器、零遥测,数据只在本机 chrome.storage.local
  2. 开箱即用npm install && npm run build 后加载 dist/ 目录即可用。
  3. 所见即所得:画框时看到的(实线紫框、黑色马赛克底、红色标记框)就是导出文档里的样子。
  4. 克制与减法:没被要求的东西一律不要。
  5. 兼容老环境:目标 Edge 89,不使用超出该版本能力的新特性。

02开发过程

22 个工作日、30 次提交,一条「先跑通、再打磨、后沉淀」的曲线。

一、总体节奏

  • 时间范围:2026-07-20(周一)~ 2026-08-18(周二)
  • 工作日:22 个(除开周末)
  • 提交次数:30 次(每次提交都有明确的单一意图)
  • 开发方式:迭代开发——先跑通最小闭环,再逐项打磨,最后沉淀文档。

整个过程的节奏是一条很自然的曲线:前期密集搭骨架(每天 1~2 次提交),中期逐项打磨(版本号从 0.11.1 一路涨到 0.11.18),后期收敛到写文档。

通俗类比:像盖房子——先打地基立框架(骨架),再砌墙装窗(功能),最后粉刷收边(打磨),交付前整理验收资料(文档)。

二、五个阶段

阶段一:项目奠基(07-20 ~ 07-24,提交 1~10)

从零到「能跑通」:搭好 Vue 3 + Vite 的构建骨架,再按「存储 → 后台 → 内容脚本 → 弹窗 → 编辑器 → 导出」的顺序,一块块把功能立起来。

#日期提交说明
107-20chore: 初始化 Vite + Vue 3 项目骨架选型:Vue 3 + Vite,锁定 Vue 3.4
207-20feat: 添加 Manifest V3 扩展清单声明权限、入口、content_scripts
307-21feat: 本地文档存储层chrome.storage.local 封装
407-21feat: background 录制会话管理 + 消息路由后台管家
507-22feat: content 脚本入口(Shadow DOM + store)注入页面、样式隔离
607-22feat: 状态栏 + 蒙版 + 三种框紫框/红框/马赛克框
707-23feat: 截图后期处理(马赛克/红框/裁剪)内存画布合成
807-23feat: 工具栏弹窗开始/完成/放弃录制
907-24feat: 本地文档库编辑器步骤编辑
1007-24feat: 手写 DOCX + ZIP 生成,导出 Word打通「录 → 导」完整链路

本阶段踩的坑:captureVisibleTab 第一个参数要传 windowId 而非 tabId,一开始传错了导致截图静默失败,调试了一下午。

阶段二:精简打磨(07-28 ~ 08-11,提交 11~28)

完整链路跑通后,进入「减法」和「对齐」阶段:版本号从 0.11.1 一路涨到 0.11.18,几乎每一版都在删东西、对齐视觉、精修文案,而不是加功能。

#日期提交类别
1107-28截图区域按钮直接触发画紫框,框仅边缘可拖动交互收敛
1207-28删除紫框回到初始状态,恢复步骤缩略图删除交互收敛
1307-29完整恢复步骤确认条(滑入动画 + 3 秒倒计时)反馈闭环
1407-29截图模式加蒙版禁止网页交互 + 未画紫框提示语防误操作
1507-30去掉「处理中…」文案与框阴影/外发光视觉减法
1607-30popup 副标题改「网站操作指引制作工具」文案精修
1707-31红框创建预览改为实线(与完成态一致)所见即所得
1807-31去掉 popup 版本标签视觉减法
1908-03popup 副标题改「网站操作指南制作工具」文案精修
2008-03popup 状态文案精简为「准备就绪。」文案精修
2108-04未填写说明不再写占位 + 插件名改「网站操作指南生成器」文案精修
2208-04插件名调整为「Stepwise - 网站操作指南生成器」文案精修
2308-05红框/马赛克工具画完保持激活,可连续创建提效
2408-05紫框创建态改实线、马赛克创建态改黑底(所见即所得)所见即所得
2508-06popup 简化(去暂停按钮)+ editor 排版精修减法
2608-07去掉录制临时提示组件 flashHint减法
2708-10去掉 editor notice 组件 + 移除暂停功能残留减法
2808-11去掉 editor 空状态 + 隐藏清空按钮 + 按钮禁用文本选中减法

本阶段的规律:大量提交是「文案精修」和「减法」,印证了「克制与减法」——做出来的东西,删掉不必要的一直是重头戏。

阶段三:文档沉淀(08-14 ~ 08-18,提交 29~30)

#日期提交说明
2908-14docs: 技术实现概览 + 关键技术细节技术文档(面向小白 + 技术深入)
3008-18docs: 项目全周期文档 + HTML 汇总展示页需求/过程/细节/重难点/Vue + 汇总页

三、关键里程碑

时间里程碑意义
07-24导出 Word 链路打通(0.11.0)「录 → 导」完整可用,MVP 成立
07-29步骤确认条完整恢复反馈闭环定型
08-05创建态 = 完成态(所见即所得)视觉一致性收官
08-10暂停功能彻底移除减法到位的标志
08-18全周期文档 + 汇总页可交付、可维护

四、过程反思

  1. 先跑通,再打磨:阶段一 10 个提交就把完整链路立起来,哪怕粗糙;阶段二才慢慢收敛细节。
  2. 减法是高频动作:18 个打磨提交里,超过一半是「删、去、精简」。
  3. 文案即产品:副标题、状态文案、插件名反复改,一字之差都值得单独一次提交。
  4. 版本号跟着每次改动走:每次改动都同步 bump manifest.json 的 version。

03技术细节

架构、数据流、关键实现决策。

一、总体架构:四个角色

浏览器扩展不是一段代码,而是几个各自独立、分工不同的模块,运行在不同地方,靠消息通信。

角色代码位置运行位置职责
popup(弹窗)src/popup/点工具栏图标弹出的窗口开始 / 完成 / 放弃录制
content(内容脚本)src/content/注入到网页内部画蒙版、三种框、状态栏、确认条
background(后台)src/background/浏览器后台截图调度、步骤存取、会话管理
editor(编辑器)src/editor/独立的本地页面步骤编辑、排序、导出 Word

通俗类比:content 是「派驻网页里的助手」,background 是「总部管家」,popup 和 editor 是「两个操作台」。助手不自己存数据,干完活给管家发消息。

二、数据流:一次录制的完整旅程

以「录制一步截图」为例,完整走一遍消息流:

[popup] ──recording:start──▶ [background] ──recorder:setMode──▶ [content]
                                 │ 创建草稿文档,记录会话
                                 │
[content] 用户拖紫框 + 画红框/马赛克(纯内部,无消息)
                                 │
[content] ──capture:step──▶ [background]
                                 │ 三段式截图:
                                 │  ① snapshot:prepare(content 藏 UI,回报框坐标)
                                 │  ② captureVisibleTab(background 截整屏)
                                 │  ③ snapshot:redact(content 打码/画红框/裁剪)
                                 │
                                 ▼
                       [background] 追加步骤到文档,存 chrome.storage.local
                                 │
[content] ◀── 缩略图 ── 滑出「丢弃/确认保留(3 秒倒计时)」

关键认知:真正的截图能力(captureVisibleTab)只有 background 有;而框的坐标在 content 里。所以必须来回传消息,这就是「三段式截图」的由来。

三、数据模型

所有数据存在 chrome.storage.local(本机,无服务器),用三组 key 区分(src/lib/storage.js):

key存什么
stepwise.guideIndex所有文档的摘要数组(id/title/time/stepCount,不含正文)
stepwise.guide.<id>每篇文档完整内容(含 steps,每步含截图 dataUrl)
stepwise.recording当前录制会话(在录哪篇、哪个标签页、状态)

为什么分 index 和正文:列表页只读摘要,不必把每篇的截图全读出来(截图很占内存)。

四、关键技术决策与取舍

1. 前端选型:Vue 3 + Vite,锁定 Vue 3.4

  • Vue 3(而非原生 JS 拼 DOM):界面 = 数据(store)的投影,改数据界面自动变。
  • Vite 5:多入口构建——popup/editor 走 ESM,content 走 IIFE(MV3 content_scripts 不支持 ESM)。
  • 锁定 Vue 3.4(而非 3.5):Vue 3.5 的响应式内部用到 Array.prototype.findLast,Edge 89 没有,会崩溃。

2. 样式隔离:Shadow DOM

content 脚本直接往网页里塞元素,会被网页的 CSS 污染。用 Shadow DOM 套一层「隔音罩」:里面自成小世界,外面影响不到里面,里面也不泄漏出去。

3. 框的交互:事件穿透 + 边缘拖动

  • 框体中间一片 pointer-events: none(穿透)——所以能在紫框内画红框,不被紫框抢占。
  • 只有边缘 5px 细条八个缩放手柄pointer-events: auto(可拖拽/缩放)。
  • 定位用 translate3d + 真实宽高:走 GPU 合成层,拖动流畅、不模糊。

4. 截图:背景截屏 + 内容加工

background 只能整屏截,content 负责在内存画布(canvas)上做三件事:打马赛克 → 画红框 → 裁剪到紫框。坐标换算:框坐标是「视口坐标」,截图是「像素坐标」,需要乘 devicePixelRatio 缩放系数对齐。

5. 导出:手写 DOCX + 手写 ZIP,零依赖

.docx 本质是 ZIP 压缩包,里面装 XML。所以「生成 Word」= 拼 XML 字符串 + 打包成 zip,纯手写,不依赖任何 npm 库。图片以 wp:inline + a:blip 嵌入,实现图文混排。

6. 数据:index + 正文分离

列表页读轻量索引,点开才读全文(含截图),避免一次性把所有截图读进内存。

04重难点

五个硬骨头,以及为什么没有更简单的路。

一、截图如何做到「干净」(无 UI 入镜)

难点:截图那一刻,扩展自己的状态栏、框、确认条还显示在页面上,会被一起截进去。产物是要给别人看的正式文档,任何 UI 残留都是硬伤。

为什么难:content(负责藏 UI)和 background(负责截屏)是两个不同的进程。content 藏好 UI 后,浏览器不一定立刻把「UI 已消失」这一帧画到屏幕上,background 就截早了——这是「竞态」。

解决:三个手段叠加,保证「藏完再截」:

  1. 藏 UI:截屏前设 store.uiHidden = true,Vue 用 v-if 把所有 UI 移除出 DOM。
  2. 等两帧(双 requestAnimationFrame):等浏览器把「移除 UI」这一帧真正合成到屏幕。
  3. 再缓冲 80ms:content 和 background 跨进程,还要等合成的传播,80ms 是经验值。

二、Edge 89 兼容

难点:目标浏览器是 Edge 89(Chromium 89,2021 年),很多新 API 用不了。硬套新写法会静默崩溃(尤其 Service Worker 崩了很难排查)。

新写法Edge 89 不支持本项目做法
crypto.randomUUID()Chrome 92+Date.now() + Math.random() 兜底,带 typeof 守卫
chrome.storage Promise 写法仅回调手动包一层 Promise
chrome.runtime.sendMessage PromiseChrome 99+包装成回调式 Promise
Array.prototype.findLastChrome 97+锁定 Vue 3.4(3.5 会用 findLast,崩溃)
chrome.actionChrome 88 才新增chrome.action 优先,browserAction 兜底(try-catch)
content 用 ESMMV3 content_scripts 不支持content 用 IIFE 打包
importScripts 模块SW 受限background 里把 storage 逻辑内联

一句话:绝不依赖 2021 年之后的新特性。

三、手写 DOCX / ZIP

难点:要导出 .docx,但扩展内不能用 docx 这类 npm 库(体积大、且要能在老环境跑)。.docx 是 OOXML 格式——一个 ZIP 包里装一堆 XML。

解决:认清楚「.docx = ZIP + XML」这一本质,分两层手写:

  1. 拼 XMLsrc/lib/docx.js):标题、步骤小标题、说明文字、图片,都是手写字符串。
  2. 打包 ZIPsrc/lib/zip.js):只用到「存储、不压缩」,手写 local file header + central directory + CRC32 + DOS 时间戳。

收获:零依赖、体积小、完全可控。代价是写的时候要对着 ZIP/OOXML 规范逐字段核对。

四、框的交互

难点:三种框叠在一起时,怎么让用户既能「在紫框内画红框」,又能「拖动紫框的边缘」?

矛盾:如果框整体都响应鼠标,在紫框内画红框会被紫框抢走;如果整体都不响应,又没法拖动紫框。

解决pointer-events 分层设计——

  • 框体中间一大片 → pointer-events: none(穿透,能在紫框内画红框)
  • 四条「边缘条」(贴边框内外各 5px)→ pointer-events: auto(拖拽)
  • 八个缩放手柄 → pointer-events: auto(缩放)

额外收获:定位用 translate3d + 真实宽高,走 GPU 合成层,拖动流畅、放大不模糊。

五、手动与自动的取舍

这是贯穿全程的第一性难点,也是本项目最值得记的一课。

难点:到底由「机器自动判断记什么」还是「用户手动指定」?

为什么难:自动看起来很省事,但撞上三堵墙——误录截图不干净找不准重点

解决:三次转变,一路从自动走向手动:

  1. 自动录制 → 被「截图不干净/误录/找不准重点」逼退
  2. hover 高亮 + F9 快捷键 → 快捷键有学习成本、DOM 元素定位不稳
  3. 纯手动拖框(紫/红/马赛克) → 截图软件心智模型,像素级精准,零学习成本

结论自动遇到难以逾越的障碍、成本过高或不如手动精准时,及时止损转向手动——把控制权交还用户,并用用户早已熟悉的方式(拖框)呈现。

总结:五个难点一句话

难点本质解法
截图干净跨进程竞态藏 UI → 双 rAF → 80ms 缓冲
Edge 89 兼容老环境缺新 API老写法 + 兜底,锁 Vue 3.4
手写 DOCX/ZIP二进制协议认清「docx = zip + xml」
框交互穿透 vs 拖拽的矛盾pointer-events 分层
手动 vs 自动精准 vs 省事的取舍自动受阻时转手动

05Vue 实现(零基础版)

从「数据是什么」讲起,用大白话 + 生活类比,讲清 Vue 怎么把这个工具做出来。下面每个词都会先讲明白再往下走。

预备1:数据、变量、对象(三个最底层的词)

数据 = 记下来的信息;变量 = 一个贴了标签的盒子,盒子里放一个值;对象 = 一个大收纳柜,里面分很多小格,每格贴一个标签。

let mode = 'idle'   // 变量:贴着 "mode" 标签的盒子,盒子里放 "idle"(空闲)

let store = {        // 对象:大收纳柜,名叫 store
  mode: 'idle',      //   第一格叫 mode
  crop: null,        //   第二格叫 crop,装 null(空)
  marks: [],         //   第三格叫 marks,装空列表 []
}

预备2:store:一本「总记事本」

store 是一个专门记「当前界面整体状态」的对象。界面上所有会变化的东西(蒙版、几个框、状态栏……)都记在这一本里。

类比:厨房里挂一块小黑板,写清「当前阶段 / 切了几样 / 要不要打码」。任何人看一眼黑板就知道现在什么情况。

export const store = reactive({
  mode: 'idle',      // 当前模式(空闲 / 录制中)
  crop: null,        // 紫框(截图区域)
  marks: [],         // 红框(标记区域)
  masks: [],         // 马赛克框
  uiHidden: false,   // 截图时是否隐藏界面
})

预备3:reactive():「自动更新」魔法

reactive(对象) 会给对象施一个魔法:你只要改对象里的数据,屏幕上显示的内容就自动跟着变,不用你手动去改屏幕。

没有魔法时:

let count = 1
count = 2          // 盒子里的数字改成 2
// ❌ 但屏幕上的数字还是 1!你得再手动去改屏幕

有魔法之后:

import { reactive } from 'vue'
let store = reactive({ count: 1 })
store.count = 2   // ✅ 屏幕上的数字自动变成 2

类比:没有魔法像写纸质通知(改了还要自己去张贴);有魔法像发群聊消息(改一下,所有人自动收到)。这就是 Vue 最核心的东西:你只管改数据,界面自动更新。

预备4:Vue 实例:「数据 → 界面」翻译机

Vue 实例是一台翻译机,唯一的活儿就是:盯着数据,把数据自动翻译成界面。createApp(App) 造机器,.mount(容器) 通电开机。

import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount(container)   // 造机器 + 装到 container 上,开机

预备5:组件:「乐高积木」

组件是一块可以反复用的「界面积木」,自己带两部分:长什么样(template)+ 怎么动(script)。一个 .vue 文件就是一块积木。

<script setup>
  // 积木的「逻辑」:数据、函数写这里
</script>

<template>
  <!-- 积木的「外观」:长什么样写这里 -->
</template>

这个项目里最主要的两块积木:src/content/App.vue(整面墙:蒙版、状态栏、三种框、确认条)、src/content/components/Box.vue(一个框,被 App 反复拼上去)。

预备6:props 和 emit:父子之间怎么传话

积木是「大套小」的(App 里套 Box)。它们之间传话,两个方向:props(下传)父把数据递给子,子只能读;emit(上传)子有事发生,喊一声,父听到再决定怎么办。

类比:props 像「父母递东西给娃」,emit 像「娃喊爸妈」。

// 子(Box.vue)
const props = defineProps({ box: { type: Object, required: true } })
const emit = defineEmits(['dragstart', 'delete'])
function onDragStart() {
  emit('dragstart', { boxId: props.box.id })   // 喊一声「我开始拖了」
}
<!-- 父(App.vue) -->
<Box :box="box" @dragstart="onBoxDragStart" @delete="onDeleteBox" />
<!--      ↑递数据   ↑听到"拖"就执行   ↑听到"删"就执行 -->

预备7:chrome 消息通信:不同房间「发邮件」

这个扩展有 4 个独立的部分,住在不同的「房间」里,互相不能直接碰对方的东西,只能靠「发消息」沟通。

chrome.runtime.sendMessage({ type: 'capture:step' })   // 发一封「请截一张图」的邮件
chrome.runtime.onMessage.addListener((msg) => { ... })  // 守着邮箱,收到就处理

类比:像公司 4 个部门不在一个办公室,不能直接改别人的电脑,只能发邮件。content 和 background 是两个独立进程,只能「寄信」协作。

预备8:顺带认识 ref / computed / v-if / v-model

一句话类比
ref(初值)单个值的盒子,也带自动更新魔法贴标签的小盒子,改了自动刷新
computed(() => ...)自动算出来的格子,原料变了自动重算Excel 公式:B1 改了,C1 的 =B1*2 自动变
v-if条件开关:满足才显示「如果下雨就带伞」
v-for循环:列表每项渲染一遍复印机:一份名单逐个打印
v-model双向绑定:输入框和数据同步镜子:你动它也动,两边一样
@click绑事件:点一下执行某函数门铃:一按就响

一句话记忆:ref/computed 管数据,v-if/v-for 管显示,v-model 管输入,@事件 管交互。

实现点 1:画一个框,其实只是「改一次数据」

store.crop = { id: 'abc', type: 'crop', x: 100, y: 80, width: 300, height: 200 }

因为 storereactive() 的,这一句赋值后,屏幕上的紫框自动出现。不用写任何「去画一个框」的代码。你在界面上看到的所有框、蒙版、按钮,背后都是 store 里的一份数据。界面只是数据的投影。

实现点 2:三种框,用一块积木复用

const boxes = computed(() => [
  ...(store.crop ? [store.crop] : []),
  ...store.marks,
  ...store.masks,
])
<Box v-for="box in boxes" :box="box" @dragstart="..." @delete="..." />

props.box.type 决定画成紫色(crop)、红色(mark)还是黑色马赛克(mask)——一套积木,画三种框

实现点 3:草稿态(ref)与正式态(store)分开

const draftRect = ref(null)  // 草稿:拖拽中的虚线框,临时
function onMouseMove(e) {
  draftRect.value = { x, y, width, height }   // 实时更新草稿
}
function commitBox(rect) {
  store.crop = { id: uid(), type: 'crop', ...rect }  // 松手才写进 store
}

规律:临时 UI 状态用局部 ref,业务数据用全局 store。

实现点 4:子组件传话(props 下传 + emit 上传)

Box 被拖拽 / 被删除时,它自己不改 store,而是 emit('dragstart' / 'delete') 喊给父组件 App,由 App 来改 store。数据改动集中由父组件负责,子组件只负责喊。

实现点 5:用 v-if 切换界面「阶段」

<template v-if="store.phase === 'record'">
  <button @click="onSelectCropArea">选择截图区域</button>
</template>
<template v-else>
  <button v-for="tool in TOOLS" ...>...</button>
  <button @click="onFinish">完成</button>
</template>

切换不是手动改 DOM,而是改 store.phasev-if 自动决定显示哪块。

实现点 6:确认条的滑入动画

const previewVisible = ref(false)  // 是否渲染出来
const previewSlidIn = ref(false)   // 是否滑到目标位置
const style = computed(() => ({
  transform: previewSlidIn.value ? 'translateX(0)' : 'translateX(120%)',
}))

实现点 7:编辑器拖拽排序

function onDrop(event, step) {
  const [moved] = guide.value.steps.splice(from, 1)  // 取出来
  guide.value.steps.splice(to, 0, moved)            // 插到新位置
}

数组顺序一变,v-for 渲染的卡片顺序自动跟着变。

实现点 8:v-model 双向绑定 + 自动保存

<input v-model="guide.title" @input="scheduleSave">
<textarea v-model="step.description" @input="scheduleSave"></textarea>

实现点 9:popup 用 v-if 切「解锁 / 应用」两个面板

<main v-if="!unlocked">  <!-- 未解锁:密码输入 -->
  <input v-model="unlockInput" ...>
</main>
<main v-else>  <!-- 已解锁:录制控制 -->
  <div>{{ statusText }}</div>
</main>

实现点 10:三台独立的 Vue 翻译机

翻译机挂在哪管什么界面
content网页内部(Shadow DOM 里)框、蒙版、状态栏、确认条
popup工具栏弹窗开始 / 完成 / 放弃
editor本地文档页列表、排序、导出 Word

三台机器互不直接调用,靠 chrome 消息协作,各自 createApp(App).mount(...) 开机。

一张图串起来

数据(store / ref / computed)
   │  reactive 魔法:改数据,界面自动跟着变
   ▼
模板(v-if / v-for / v-model / @事件 / :style)
   │  Vite 编译 + Vue 翻译机渲染
   ▼
界面(框、蒙版、按钮、卡片)
   │  用户操作触发 @事件 / emit 上传
   ▼
函数(改数据 / 发 chrome 消息给别的房间)
   │
   └──── 循环:改数据 → 界面自动更新 → 用户操作 → 再改数据

全文最该记住的一句话:界面 = 数据(响应式)的投影。你只管改数据,界面自动变;用户操作通过事件反过来改数据。其余所有概念——store、reactive、组件、props/emit、消息通信——都是在为这一句服务。

技术实现概览

读完能看懂整体结构、理解每个模块在干什么,并能用自己的话转述出来。用类比和故事讲,不要求懂底层细节。

一、这个扩展到底是干什么的

一句话:你在一堆网页上点一点、框一框,它帮你把操作过程变成一份带截图和标注的 Word 说明文档。

整个过程拆成两段:

  1. 录制段:你在网页上拖框选区域(紫框 = 截图、红框 = 标重点、马赛克 = 打码),每选好一个点「完成」,它就截一张图存起来。
  2. 整理段:录完打开本地编辑页,给每步补文字、调顺序,最后点「导出」得到 .docx 文件。

二、先认识四个「角色」

角色代码在哪运行在哪儿干什么
popupsrc/popup/点工具栏图标弹出的小窗口「开始录制 / 完成 / 打开文档库」的按钮
contentsrc/content/注入到网页内部画蒙版、紫框、红框、马赛克、状态栏——页面上所有框都是它画的
backgroundsrc/background/浏览器后台(不露面的管家)协调全局:真正执行截图、把步骤存本地、维护录制状态
editorsrc/editor/独立的本地页面编辑步骤文字、拖拽排序、导出 Word

它们之间靠发消息协作(就像互相发微信),而不是直接调用对方的函数。这是浏览器扩展的规则:这几个模块被浏览器隔离开,只能通过消息通信。

三、一次完整操作,数据是怎么流动的(故事线)

  1. 点 popup 的「开始录制」 → popup 给 background 发 recording:start → background 创建空文档草稿,给 content 发 recorder:setMode → content 在网页右下角显示状态栏。
  2. 点「选择截图区域」,拖出紫框 → 这一步完全在 content 内部完成:拖拽时 content 实时画紫色矩形(数据存在 store.crop),网页被蒙版盖住。
  3. 点「完成」 → content 发 capture:step → background 三段式流程拿到「裁好 + 打码 + 画框」的截图 → 追加到文档,返回缩略图 → content 滑出「丢弃 / 确认保留(3 秒倒计时)」。
  4. 重复 2、3 步,录完所有步骤。
  5. 点 popup 的「完成录制」 → background 打开 editor 页面。
  6. 在 editor 里补文字、调顺序,点「导出 Word」 → 组装成 .docx,浏览器自动下载。

四、三个最重要的技术概念(用类比讲)

1. Shadow DOM —— 「隔音罩」

网页有自己的 CSS,扩展直接往页面塞元素可能被污染(比如页面的 div { border: 0 } 把扩展的框弄乱)。Shadow DOM 给扩展界面套一层隔音罩:里面自成小世界,外面影响不到里面。

2. Vue 响应式 —— 「数据一变,界面自动跟着变」

传统写法要手动改界面;Vue 只管改数据,界面自动刷新。比如紫框,代码里只有一份数据 store.crop = { x, y, width, height },拖动时改的是数据,紫色矩形自动跟着动。

3. chrome.storage.local —— 「浏览器的本地硬盘」

扩展把数据存在浏览器自带的存储区里。存文档、存截图、存录制状态都放这里。注意:Edge 89 的 storage API 只支持回调,代码里用一层包装把它变成 Promise(见 关键技术细节 §7)。

五、每个文件是干嘛的(速查表)

文件作用
src/manifest.json扩展的「身份证」:名字、版本、权限、声明脚本
src/popup/App.vue工具栏弹窗界面
src/content/main.jscontent 入口:挂 Shadow DOM、监听消息、桥接 background
src/content/App.vuecontent 主界面:蒙版、状态栏、画框逻辑、确认条
src/content/store.js全局响应式数据 + 发消息的工具函数
src/content/components/Box.vue单个框(紫/红/马赛克)的绘制、拖拽、缩放、删除
src/content/snapshot.js截图后期处理:打马赛克、画红框、裁剪
src/background/background.js后台管家:录制会话、截图调度、步骤存取
src/editor/App.vue文档编辑器:编辑步骤、排序、导出 Word
src/lib/storage.js本地存储的读写封装
src/lib/docx.js把文档数据拼成 Word 的 XML 结构
src/lib/zip.js手写的 ZIP 打包器(.docx 本质是 zip)
vite.config.js / vite.content.config.js构建配置(把源码打包成 dist/

六、转述时可以抓住的核心骨架

  1. 四个角色各司其职:弹窗、内容脚本、后台、编辑器,靠消息通信。
  2. 界面即数据:所有框都是响应式数据(store)的投影,改数据界面自动变。
  3. 截图是「截图 → 后期加工 → 裁剪」三段式:先整屏截,再打码、画红框,最后裁到紫框。
  4. 数据都存在浏览器本地chrome.storage.local),不联网、无服务器。
  5. Word 文档是手工拼出来的:自己拼 XML + 自己打包 zip。

关键技术细节

逐个讲透核心功能的实现。建议先读「技术实现概览」建立整体框架,再看本文。文中代码均为精简摘录。

1. 蒙版:覆盖网页 + 禁止交互

目标:点「选择截图区域」后,整个网页盖上半透明遮罩,用户点不到网页上的任何东西,只能拖框。

const maskStyle = {
  position: 'fixed', inset: '0',        // 铺满整个视口
  zIndex: '2147483645',                  // 层级:低于框(…646)、状态栏(…647)
  background: 'rgba(16,24,40,.28)',      // 半透明深色
  pointerEvents: 'auto',                 // 关键:拦截鼠标事件
  cursor: 'crosshair',                   // 十字光标
}
<div v-if="store.mode === 'recording' && store.phase === 'edit' && !store.uiHidden" :style="maskStyle"></div>
  • pointer-events: auto 是「禁止交互」的核心:这个 div 覆盖最上层,鼠标事件全被它接住,传不到下面的网页元素。
  • z-index 分层是刻意设计的:蒙版(645) < 框(646) < 状态栏/确认条(647)

2. 选定区域截图:三段式消息流

为什么这么绕:真正的截图能力(captureVisibleTab)只有 background 有;而紫框/红框/马赛克的坐标数据在 content 里。所以必须两边来回传消息。

// ① 让 content 隐藏 UI,并回报框的坐标
const prepared = await notifyContent(tabId, { type: "snapshot:prepare" });

// 等浏览器把"UI 已隐藏"重绘完成(双 rAF + 80ms 缓冲)
await new Promise(r => setTimeout(r, 80));

// ② background 直接截整个可见视口
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
  format: "jpeg", quality: 88
});

// ③ 让 content 做后期加工(打码/画红框/裁剪),返回成品
const redacted = await notifyContent(tabId, {
  type: "snapshot:redact",
  payload: { dataUrl, viewport, crop, marks, masks }
});

三步:snapshot:prepare(藏 UI + 回报坐标)→ captureVisibleTab(截整屏,注意传 tab.windowId 而非 tabId)→ snapshot:redact(canvas 上打码、画红框、裁剪)。

export async function renderSnapshot(payload) {
  const source = await loadImage(payload.dataUrl)
  const canvas = document.createElement('canvas')
  canvas.width = source.naturalWidth
  canvas.height = source.naturalHeight
  const ctx = canvas.getContext('2d')
  ctx.drawImage(source, 0, 0)
  const scaleX = canvas.width / payload.viewport.width   // 视口坐标 → 像素坐标
  const scaleY = canvas.height / payload.viewport.height
  for (const rect of payload.masks ?? []) mosaicRect(ctx, rect, scaleX, scaleY)
  for (const rect of payload.marks ?? []) drawMarker(ctx, rect, scaleX, scaleY)
  if (payload.crop) { /* 新建画布,把紫框那块 drawImage 过来 */ }
  return cropped.toDataURL('image/jpeg', 0.88)
}

为什么要 scaleX/scaleY 换算:框坐标是「视口坐标」,截图可能是 2 倍像素(devicePixelRatio),所有框坐标要先乘缩放系数。

3. 马赛克:像素级打码

核心思路:把区域划分成若干小方块,每个方块用块内所有像素的平均色填充。方块够大,细节就没了,但整体色调还在。

const block = Math.max(6, Math.min(24, Math.round(Math.min(w, h) / 8)))
const imageData = ctx.getImageData(x, y, w, h)
const data = imageData.data
for (let by = 0; by < h; by += block) {
  for (let bx = 0; bx < w; bx += block) {
    let r = 0, g = 0, b = 0, count = 0
    for (let yy = by; yy < by + block; yy++)
      for (let xx = bx; xx < bx + block; xx++) {
        const idx = (yy * w + xx) * 4
        r += data[idx]; g += data[idx+1]; b += data[idx+2]; count++
      }
    const rr = Math.round(r/count), gg = Math.round(g/count), bb = Math.round(b/count)
    // 用平均值回填整个 block
  }
}
ctx.putImageData(imageData, x, y)
  • getImageData / putImageData 是 canvas 的「逐像素读写」接口。
  • 一个像素占 4 字节 [R, G, B, A],下标要 * 4
  • 只打码不「黑块盖住」,观感更自然(保留原区域色彩基调)。

4. 红框标注

红框在两个地方出现:

A. 截图时drawMarker):

function drawMarker(ctx, rect, scaleX, scaleY) {
  const x = rect.x * scaleX, y = rect.y * scaleY
  const w = rect.width * scaleX, h = rect.height * scaleY
  const lw = Math.max(3, 3 * Math.max(scaleX, scaleY))
  ctx.strokeStyle = '#d92d20'
  ctx.lineWidth = lw
  ctx.strokeRect(x, y, w, h)   // 只画 3px 实线描边
}

B. 编辑器里:针对旧版本录的数据(截图里没 baked 红框),导出前补画一次。两处都遵循同一原则:红框是纯 3px 实线,创建时和完成时一致(所见即所得)。

5. 框的拖拽 / 缩放 / 边缘识别

目标:框的内部要「穿透」(在紫框内画红框时不能误拖到紫框),只有边缘才能拖动。

const boxStyle = { ..., pointerEvents: 'none' }   // 容器:完全穿透
const bodyStyle = { ..., pointerEvents: 'none' }   // 框体视觉:纯展示
const EDGE = 5
const EDGE_STRIPS = [
  { dir: 'n', pos: { top: '-5px', height: '10px', ... } },   // 上边
  { dir: 's', pos: { bottom: '-5px', height: '10px', ... } }, // 下边
  { dir: 'w', pos: { left: '-5px', width: '10px', ... } },    // 左边
  { dir: 'e', pos: { right: '-5px', width: '10px', ... } },   // 右边
]

分层逻辑:框体中间一片 pointer-events: none(穿透)→ 四条边缘细条 pointer-events: auto(拖拽)→ 八个缩放手柄 pointer-events: auto(缩放)。定位用 translate3d + 真实宽高走 GPU 合成层。

6. 步骤自动生成 Word 文档

关键认知.docx 本质是 ZIP 压缩包,里面装 XML。所以生成 Word = 拼 XML 字符串 + 打包 zip,纯手写,不依赖任何库。

const body = [
  paragraph(textRun(guide.title), { style: "Title" })   // 标题
]
for (const step of guide.steps) {
  body.push(paragraph(textRun(`步骤 ${n}`), { style: "Heading2", ... }))
  if (step.description?.trim()) {
    body.push(paragraph(textRun(step.description), { ... }))
  }
  if (image) body.push(imageParagraph(image, relationshipId, imageIndex))
}
function textRun(value) {
  return `<w:r><w:t xml:space="preserve">${xml(value)}</w:t></w:r>`
}
function paragraph(content, opts) {
  return `<w:p>...<w:pPr>...</w:pPr>${content}</w:p>`
}

图片用 wp:inline + a:blip r:embed="rIdX" 引用关系表里的图片,实现图文混排。ZIP 打包(zip.js)手写 local file header + central directory + CRC32 + DOS 时间戳。

7. 本地文档如何保存

位置:chrome.storage.local(本机,无服务器)。三组 key(见 03-技术细节 · 数据模型)。为什么分 index 和正文:列表页只读摘要,不必把每篇截图全读出来。

Edge 89 的坑chrome.storage.local.get/set 在老版只支持回调,不支持 Promise。所以 storage.js 手动包一层:

function storageGet(keys) {
  return new Promise(resolve => {
    chrome.storage.local.get(keys, result => resolve(result || {}))
  })
}

8. 截图如何做到「干净」(无 UI 入镜)

问题:截图那一刻,扩展自己的状态栏、框还显示在页面上,就会被截进去。解法:截图前先把 UI 全藏起来,等浏览器重绘完成再截,截完再恢复。三个手段叠加:

  1. 藏 UIsnapshot:prepare 时设 store.uiHidden = true,Vue 用 v-if="!store.uiHidden" 全部移除。
  2. 等两帧(双 rAF):等浏览器把「移除 UI」这一帧真正画到屏幕。
  3. 再缓冲 80ms:跨进程传播,80ms 是经验值。

早期踩了很多坑(baseline 修补、预采、冷却期等 workaround),最终收敛到「藏 UI → 双 rAF → 80ms 缓冲」。

9. Edge 89 兼容性清单

新写法Edge 89 不支持本项目做法
crypto.randomUUID()Chrome 92+Date.now() + Math.random() 兜底
chrome.storage Promise 写法仅回调手动包 Promise
chrome.runtime.sendMessage PromiseChrome 99+包装成回调式 Promise
Array.prototype.findLastChrome 97+锁定 Vue 3.4
chrome.actionChrome 88 才新增action 优先,browserAction 兜底
content script 用 ESMMV3 不支持content 用 IIFE 打包
importScripts 模块SW 受限background 里内联 storage 逻辑

一句话:能用老写法的都用老写法,实在没有就用兜底逻辑,绝不依赖 2021 年之后的新特性。

总结:一张图串起所有技术点

[popup] ──消息──▶ [background] ──tabs.sendMessage──▶ [content]
                       │                                │
                  真正截图(captureVisibleTab)       画蒙版/框/状态栏
                  存本地(storage.local)             (Vue + Shadow DOM)
                       │                                │
                       └────snapshot:redact──────▶ 打码/画框/裁剪(canvas)
                                                            │
                                                    [editor] ──docx.js+zip.js──▶ .docx 下载
  • 界面 = Vue 响应式 + Shadow DOM 隔离(content 侧)
  • 截图 = background 截屏 + content 加工(三段式消息流)
  • 存储 = chrome.storage.local(index + 正文 + 会话)
  • 导出 = 手拼 XML + 手写 ZIP = .docx
  • 兼容 = 全程绕开 Edge 89 缺失的新 API