个人安全检查清单与本地进度
个人安全检查清单通过分类建议、优先级和三态标记,帮助用户记录个人网络安全实践的落实情况。核心组件 SecurityChecklist 将进度按检查项 slug 保存在浏览器 localStorage 中,再从当前语言的数据集重建展示列表。
目的与范围
本页覆盖清单数据的按需加载、分类导航、完成与忽略操作、进度计算、本地恢复和重置,以及与应用事件的连接点。它是一份人工自查记录,不能把勾选结果当作自动安全检测或安全认证。
IP、DNS 泄漏、浏览器指纹等检测机制不属于本页范围;成就规则、公共国际化回退器和通用抽屉组件只讨论清单侧已核实的调用方式。本次证据集中于 SecurityChecklist.vue 的模板与主要业务逻辑;共享加载器、事件消费者及独立测试的内部实现未在本次阅读中核实。
概述
界面分为三个相互联动的区域:
- 总览面板:显示总数、已完成、已忽略、未完成数量,以及分类堆叠进度条和四种优先级圆环。
- 分类导航:选择当前安全领域,显示该领域的完成比例;达到计算意义上的 100% 时显示勾选图标。
- 分类详情:显示标题、描述、可展开介绍、条目优先级,以及完成按钮和忽略开关。用户可以筛选全部、未完成、已完成或已忽略条目。
默认分类是 authentication,优先级的显示顺序为 Basic、Optional、Essential、Advanced。这些值是程序数据标识,而不是可自由翻译的状态值。页面统计来自实际加载的数据,不在统计函数中写死条目数量。
来源:SecurityChecklist.vue、状态与优先级定义。
架构与职责
Source: SecurityChecklist.vue
这里的状态分为两层:fullList 是按分类组织、携带布尔状态的展示模型;slugs 是按检查项标识组织的持久化模型。持久化只保存状态映射,不保存翻译文案,因此切换语言时可以重新加载内容,同时通过相同 slug 恢复进度。
代码中没有为进度保存调用后端接口。不过,组件确实调用 trackEvent 和 emitAppEvent;“进度存于本地”不等于整个应用完全没有遥测或事件处理。相关函数是否最终联网,需要另行核实其实现。
数据模型与稳定标识
| 对象 | 已核实字段或形态 | 职责 |
|---|---|---|
| 分类输入 | slug、title、description、intro、checklist | 提供分类内容 |
| 条目输入 | slug、point、priority,以及保留的其他字段 | 提供建议内容和优先级 |
fullList | 以分类 slug 为键的对象 | 供导航、筛选、统计使用 |
| 条目运行时状态 | checked: boolean、ignored: boolean | 由持久化字符串转换得到 |
slugs | 检查项 slug → 状态字符串 | 写入浏览器的完整映射 |
categories | 分类 slug 数组 | 控制导航和总览遍历顺序 |
持久化状态只有三个正常值:空字符串表示未完成,checked 表示已完成,ignored 表示忽略。初始化对已有键做合并,对新条目补空字符串,再写回整个映射;没有在这条路径上删除旧键或增加数据版本号。
因此,条目 slug 必须跨语言稳定且全局唯一。分类自身虽有命名空间,持久化键却没有附加分类前缀:同名条目会共享同一个保存位置。修改条目 slug 会使其按新条目处理,旧记录仍可能留在存储中。
加载与恢复流程
按需加载当前语言
组件通过 import.meta.glob 收集清单语言包加载入口,并创建 Map 缓存传给 loadLocaleDataset。源码注释说明每种语言的数据集约为 30 KB gzip,因此选择按需加载,避免将所有清单内容放入初始国际化包。共享加载器的注释说明它会沿语言回退链尝试加载,但具体回退顺序、缓存命中与异常处理细节未在本次源代码阅读中核实。
实际加载入口如下:
1const loadSecurityChecklist = async () => {
2 fullList.value = null;
3 const loc = locale.value;
4 const pack = await loadLocaleDataset(securityDataLoaders, loc, securityDataCache);
5 if (loc !== locale.value) return; // stale load — the locale changed mid-flight
6 if (!pack) {
7 console.error('Security checklist dataset unavailable in every locale');
8 return;
9 }
10 securityChecklist.value = pack.data;
11 fullList.value = initSecurityList(pack.data);
12};Source: SecurityChecklist.vue
首先清空 fullList,模板据此显示 Spinner;加载完成后,只有请求开始时的语言仍等于当前语言才接受结果。随后保存原始数据到 securityChecklist,并调用 initSecurityList 生成展示模型。
初始化和语言切换
挂载时等待加载结束,再通过 300 ms 定时器调用 changeList('authentication', false);语言改变时等待重新加载,然后调用 changeList(currentList.value, false)。后一条路径保留当前分类,但会清除筛选条件。两条路径均关闭分类切换滚动。
注意:加载函数没有通过返回值告诉调用者“成功”“过时”或“失败”。即使内部提前返回,外层仍继续执行 changeList。这对加载失败和快速切换语言具有实际影响,详见后面的边界说明。
从本地恢复状态
下面的源码展示了读取与单项更新的全部逻辑:
1const loadLocalSlugs = () => {
2 const storedSlugs = localStorage.getItem('securityChecklistSlugs');
3 if (storedSlugs) {
4 const parsedSlugs = JSON.parse(storedSlugs);
5 Object.keys(parsedSlugs).forEach(key => { slugs.value[key] = parsedSlugs[key]; });
6 }
7};
8
9const updateSlugs = (slug, value) => {
10 slugs.value[slug] = value;
11 setLocalSlugs();
12};Source: SecurityChecklist.vue
恢复不是替换整个响应式对象,而是将已保存键逐个合并进去。初始化只将严格等于 checked 或 ignored 的值识别为对应状态;其他字符串不会产生这两个布尔标记。每次单项更新调用的 setLocalSlugs 都用 JSON.stringify(slugs.value) 覆盖同一个存储键。
条目操作、筛选与重置
三态转换规则
Source: SecurityChecklist.vue
忽略状态优先于完成状态:忽略一个已完成条目会清除完成标记,取消忽略后也不会恢复之前的完成状态。模板禁用忽略条目的完成按钮,函数内部再做一次判断,形成 UI 与业务逻辑两层约束。
1const ignoreItem = (item) => {
2 item.ignored = !item.ignored;
3 item.checked = false;
4 updateSlugs(item.slug, item.ignored ? 'ignored' : '');
5};
6
7const checkItem = (item) => {
8 if (item.ignored) return;
9 item.checked = !item.checked;
10 updateSlugs(item.slug, item.checked ? 'checked' : '');
11 // Achievement rules (SurfaceCheck / HalfwayThere / FullySecured) live in
12 // data/achievement-rules.js.
13 emitAppEvent('securitychecklist:progress', {
14 checked: checkedItems.value,
15 total: totalItems.value,
16 });
17};Source: SecurityChecklist.vue
checkItem 在保存成功后发布 securitychecklist:progress,载荷包含全清单的完成数和总数。取消完成也会发布该事件;ignoreItem 不发布它。事件中的 total 没有扣除忽略项,不能直接用它替代 UI 的有效分母。源码注释提及三个成就名称,但具体解锁阈值不能从本组件推断。
筛选和分类导航
filterChecklist(filter) 仅针对当前分类处理四种筛选:
| 筛选值 | 条件 |
|---|---|
all | 直接使用当前分类的原始数组 |
ignored | item.ignored |
checked | item.checked |
unchecked | !item.checked && !item.ignored |
非 all 筛选调用数组 filter 生成新数组,但条目仍引用原对象。因此状态修改会更新条目显示;这几个操作函数本身没有重新运行筛选,不能假定条目修改后会立即从当前筛选结果中移除。
changeList(listName, shouldScroll = true) 先设置展示数组和当前分类,再重置筛选为 all。只有允许滚动且分类发生变化才记录 ChangeList 事件。滚动通过 nextTick 寻找 #checklist 最近的 overflowY 为 auto 或 scroll 的祖先;已完整可见时不滚动,否则只对该祖先调用 scrollBy,保留 8 px 偏移。
该实现的注释明确指出:避免 scrollIntoView 向页面根滚动器传播,防止 iOS Safari 下与抽屉的 body 滚动锁发生冲突。
重置的真实语义
重置按钮直接调用 resetAllslugs(),所读模板没有确认步骤。函数记录 Reset 事件,将所有已知 slugs 键设为空字符串,保存并重新初始化数据,保留当前分类,最后恢复 all 筛选。
这不是删除存储键,也不是只重置当前分类;旧数据遗留的键也会被清空。重置函数没有发送 securitychecklist:progress。
来源:SecurityChecklist.vue、重置实现。
进度统计:三个不同口径
数量与有效完成率
countItems({ action, category, priority }) 根据分类选出条目;若传入 priority,先按优先级精确过滤,再执行对应统计。数量和百分比的计算分支如下:
1const actionMap = {
2 total: (items) => items.length,
3 ignored: (items) => items.filter(item => item.ignored).length,
4 checked: (items) => items.filter(item => item.checked).length,
5 percentage: (items) => {
6 const total = items.length;
7 const ignored = items.filter(item => item.ignored).length;
8 const checked = items.filter(item => item.checked).length;
9 const denominator = total - ignored;
10 return denominator === 0 ? 100 : Math.round((checked / denominator) * 100);
11 },
12};Source: SecurityChecklist.vue
- 总览未完成数为:
totalItems - checkedItems - ignoredItems。 - 单分类有效完成率为:已完成数 ÷(总数 − 忽略数),再四舍五入到整数。
- 有效分母为零时返回
100,包括分类中所有条目都被忽略的情况。
因此,导航上的勾选图标表示该分类计算所得百分比等于 100,不一定意味着每个原始条目都已完成。四舍五入也意味着比例接近 100% 时可能显示勾选。
总览堆叠条与优先级圆环
| 展示位置 | 分母 | 忽略项的表现 |
|---|---|---|
| 顶部分类堆叠条 | 分类原始总数 | 单独显示忽略段,完成段与忽略段分别计算宽度 |
| 当前分类详情进度条 | 分类总数减忽略数 | 不进入有效分母 |
| 优先级圆环 | 全分类同优先级总数减忽略数 | 分母为零时显示替代圆环和“已忽略”标签 |
总览堆叠条由 getProgressStyle(category)(action) 计算宽度,不使用 percentage 分支。优先级圆环则分别累计数量后做比值,其分母由 priorityDenom(priority) 提供。
一个容易误用的接口细节:countItems 对各分类的结果执行求和。category: 'all' 配合 percentage 会得到分类百分比之和,不是全局完成率。现有模板只对单分类调用百分比分支;全局圆环使用完成数量和有效总数,绕开了这一问题。
来源:SecurityChecklist.vue、统计归约逻辑。
配置与内部调用参考
本组件没有在所读实现中声明服务端配置、环境变量或 HTTP API。下面列的是内部约定,不是可直接从外部传入的配置接口。
| 约定 | 类型 | 初始值或固定值 | 影响 |
|---|---|---|---|
| 本地存储键 | 字符串 | securityChecklistSlugs | 保存完整条目状态映射 |
currentList | 响应式字符串 | authentication | 初始分类,挂载后也显式选中该分类 |
filterTag | 响应式字符串 | all | 切换分类或重置后恢复此值 |
priorities | 字符串数组 | Basic、Optional、Essential、Advanced | 圆环顺序及对应的标签样式 |
securityDataCache | Map | 空 Map | 传入共享语言包加载器 |
| 挂载后导航延迟 | 毫秒 | 300 | 数据加载返回后的分类初始化延迟 |
| 进度颜色 | 十六进制颜色 | 完成 #22c55e,信息 #0ea5e9 | 圆环使用的颜色 |
| 圆环背景 | 十六进制颜色 | 深色 #262626,浅色 #e5e5e5 | 随 store.isDarkMode 切换 |
来源:SecurityChecklist.vue、生命周期。
内部函数契约
这些是 JavaScript 组件内部函数,并非经过类型声明的公共 SDK。表中返回行为依据实现归纳,不补造 TypeScript 签名。
| 实际函数形式 | 输入与返回行为 | 前置条件或副作用 |
|---|---|---|
loadSecurityChecklist = async () => | 无参数;异步完成,没有显式业务返回值 | 清空列表、加载语言包、初始化;过时或缺包时提前返回 |
initSecurityList = (securityChecklist) => | 分类数组;返回按分类 slug 建立的对象 | 读取并写回存储;输入需具备 checklist.map 所需结构 |
updateSlugs = (slug, value) => | 条目标识与状态值;无显式返回值 | 修改内存并同步写入本地存储 |
checkItem = (item) => | 带状态和 slug 的条目;无显式返回值 | 切换完成状态,保存并发布进度事件;忽略项直接返回 |
ignoreItem = (item) => | 条目;无显式返回值 | 切换忽略状态,清除完成标记并保存 |
countItems = ({ action, category, priority }) => | action 为统计动作,category 为 slug 或 all,priority 可省略;返回数字 | 依赖非空 fullList;未知动作按零处理,缺少分类清单时跳过 |
filterChecklist = (filter) => | 四种筛选字符串;无显式返回值 | 更新筛选标签与数组;未知值只改标签,不更新数组 |
changeList = (listName, shouldScroll = true) => | 分类 slug 与可选滚动开关;无显式返回值 | 分类必须存在;重置筛选,可记录行为事件及滚动 |
resetAllslugs = () => | 无参数;无显式返回值 | 清空所有已知状态,重建当前分类视图 |
相关函数没有显式声明错误类型,也没有在本组件这些路径上捕获存储、解析或数据结构错误;不要据此认为调用永远成功。
失败模式、边界与并发
存储损坏或不可写
JSON.parse 没有 try/catch,解析结果也没有做对象结构校验。损坏的 JSON 会中断初始化;即使是合法 JSON,也不代表其形态满足 Object.keys 和状态映射要求。初始化结束时的保存、用户操作时的保存都可能因浏览器存储访问或容量限制而失败,组件没有降级到纯内存模式的分支。
用户操作先改内存状态,再调用存储写入。如果保存抛错,可能出现“当前界面已变化,但刷新后没有保留”的不一致;checkItem 后面的进度事件也不会执行。排查时应区分列表加载失败、状态解析失败和保存失败,而不是将三者都归因于网络。
语言切换竞态和加载失败
- 过时结果防护有限:函数通过比较
loc与当前语言拒绝不同语言的旧结果,但不是请求序号或取消机制。快速 A → B → A 切换时,旧 A 请求仍可能通过检查。 - 后续导航没有成功守卫:旧请求提前返回后,语言监听器仍调用
changeList;若新请求尚未重建fullList,可能访问空值。 - 全部语言数据不可用:加载函数记录控制台错误并保持
fullList为空;没有独立错误状态或重试按钮。外层挂载定时器和语言监听器仍可能继续执行导航。 - 数据集需要保持分类一致:
changeList直接访问分类的checklist;默认分类不存在或语言包缺少当前分类时没有回退处理。初始化只向categories追加尚未出现的分类,也没有清除旧分类的步骤。
这些结论来自控制流审阅,不是本次执行了故障注入测试。来源:加载实现、分类初始化与导航、生命周期调用方。
多标签页与隐私边界
写入策略是整份 slugs JSON 覆盖,而不是逐项原子合并。所读路径没有跨标签页冲突协调逻辑:不同页面实例各自持有旧映射并写回时,有覆盖其他实例更新的风险。不能将“刷新后恢复”描述成“多标签实时同步”。
保存值也没有加密步骤,并且没有账户、用户 ID 或清单版本参与存储键。它应被理解为当前站点浏览器存储中的自查记录,而不是账户级、可跨设备恢复的安全档案。清除站点数据后,不应期望组件能从服务器恢复进度。
统计和展示边界
- 单分类百分比对零有效分母做了处理,但
getProgressStyle直接除以分类总数,没有处理空分类的零分母。 - 已忽略项不计入未完成数;“全部忽略”和“全部完成”在单分类百分比上都可以是 100%,但顶部数量与优先级替代圆环仍可区分它们。
- 条目说明展开状态使用
checklistInfoOpen[index],而不是条目 slug。筛选或分类切换改变索引含义后,展开位置可能对应到其他条目,不能把它当成按条目保存的阅读状态。
性能、扩展与验证建议
性能与运行注意事项
按语言懒加载降低初始包负担,但首次打开或切换语言要等待数据加载。Map 被提供给共享加载器,不能在未阅读该加载器的情况下承诺缓存有效期或并发请求去重。
统计函数没有预先维护分组索引:每次按所选分类遍历条目,并通过 filter 计算状态数量。总览模板会按分类、优先级重复调用它。当前实现简单直观;若数据规模显著扩大,可考虑在保持统计口径不变的前提下预计算聚合结果。
每次勾选和忽略都会同步序列化、写回完整映射,没有防抖或批量保存。这使保存时机明确,但写入成本随记录规模增长。挂载后的 300 ms 是导航定时器,不是网络超时或重试间隔。
安全扩展的约束
- 新增条目:使用稳定、全局唯一的 slug;不要通过翻译改变状态键。
- 新增分类:保持所有语言包的分类结构兼容;图标映射未命中时
getChecklistIcon回退到Info。 - 新增优先级:同时考虑
priorities、标签文案和priorityBadgeClass。未出现在优先级数组中的值不会自动增加圆环;未命中样式映射时返回空字符串。 - 新增持久化状态:同步修改初始化转换、状态切换、筛选和分母规则;当前只有三种状态约定,没有通用状态机接口。
- 扩展事件消费:不要假定忽略、重置、恢复本地进度都会发布
securitychecklist:progress。若消费者需要完整快照通知,应显式设计额外触发点。
测试证据与建议
本次没有读取到清单专用测试文件,不能声称下列场景已有自动化覆盖。基于实现,建议验证:
- 勾选、取消勾选、忽略、取消忽略、刷新恢复,以及忽略已完成项时是否清除完成状态。
- 全部忽略、空分类、百分比四舍五入、优先级无有效条目时的展示。
- 保存内容损坏、存储不可访问、写入失败时 UI 与持久化的一致性。
- 快速语言切换、缺失默认分类、所有数据包加载失败的后续导航。
- 在已完成或未完成筛选中改变状态后,列表成员是否符合产品预期。
- 多标签页交替操作导致的整份映射覆盖,以及 iOS 抽屉内切换分类时的滚动稳定性。
相关链接
- 清单交互与进度面板:界面状态和操作入口。
- 本地进度恢复、写入与重置:排查刷新后进度和重置行为。
- 进度事件与统计口径:扩展事件消费者或修改进度展示时的关键契约。
公共国际化回退、成就解锁和检测工具的实现应分别查阅其对应专题;本页不将这些外部模块未核实的行为纳入清单自身的保证。