2026秋软件工程结对作业(第二次之程序实现)

校园失物招领 —— 结对作业二(福州大学 2026 软件工程实践)

作业信息

项 内容
结对同学博客链接 https://www.cnblogs.com/tech-notes-101/p/23256736
本作业博客链接(两人各自提交) https://www.cnblogs.com/JunYU111/p/23256640
GitHub 仓库地址 https://github.com/1cecream1/102401114-102401126
第一位同学 102401114 颜俊宇(逻辑层 / 单元测试 / 建仓与合并 / README)
第二位同学 102401126 林世颖(页面层 / 样式 / 走查截图 / 博客排版)
呈现形式 WEB 网页(PC 端 / 移动端自适应),统一使用 Google Chrome
运行方式 下载后双击 index.html,零依赖、零构建、无需服务器

班级群结对表与 GitHub 项目地址统计表已在 deadline1 前填写完成。


一、具体分工

成员 主要负责 具体产出
颜俊宇(102401114) 逻辑层 + 单元测试 + 仓库管理 js/constant.js(枚举与文案契约)、js/seed.js(24 条示例数据)、js/store.js(ItemStore 存取与发布者标识)、js/validate.js(表单校验)、js/search.js(搜索 / 筛选 / 排序)、js/status.js(状态文案)、js/reward.js(悬赏与打赏的纯函数);test/ 下 7 个测试文件 181 个用例;建仓、审 PR、合并、README
林世颖(102401126) 页面层 + 样式 + 走查 index.html(六个视图结构)、css/style.css(主题与卡片体系、移动端自适应)、js/app.js(渲染与事件绑定);附加特点的界面实现;浏览器走查与截图;博客撰写与排版

两人共同完成:需求讨论与功能范围界定、接口约定与联调走查、PSP 表格填写、博客互审、互相评价。

这样分工的理由:逻辑层 / 页面层各一人是班级里最主流的切法,接口先冻结,两人并行互不阻塞。甲先交出逻辑层接口约定(及可运行的实现)之后,乙当天就能把页面跑通;反过来,甲要写单元测试,被测对象正好就是自己写的逻辑层,测试与实现同源,返工最少。

唯一耦合点:js/constant.js(双方共用的枚举与文案字典)与逻辑层的方法签名。这两处先冻结,之后两人分别改各自的文件,全程没有出现过一次合并冲突。


二、PSP 表格

单位:分钟,为每人投入。预估时两人根据同一份《开发方案》的阶段拆解独立填写,数值恰好一致;实际耗时按各自开发记录回填。

PSP2.1 Personal Software Process Stages 预估耗时 实际耗时(甲)
Planning 计划 10 5
Estimate 估计这个任务需要多少时间 20 15
Development 开发
Analysis 需求分析(包括学习新技术) 100 130
Design Spec 生成设计文档 60 70
Design Review 设计复审 40 35
Coding Standard 代码规范(为目前的开发制定合适的规范) 20 20
Design 具体设计 60 70
Coding 具体编码 300 340
Code Review 代码复审 50 55
Test 测试(自我测试,修改代码,提交修改) 140 180
Reporting 报告
Test Report 测试报告 50 55
Size Measurement 计算工作量 15 15
Postmortem & Process Improvement Plan 事后总结,并提出过程改进计划 30 30
合计 795 1020

实际与预估的差异说明(这部分比数字本身更有意义):

  1. Analysis 超出预估(100 → 130 / 120):多出来的时间主要花在「用无头 Chrome 实测 file:// 环境」上——我们需要先确认双击 index.html 时 localStorage、crypto.randomUUID、navigator.clipboard 到底能不能用,这决定了技术选型能否成立。最后实测结论是三个都能用(isSecureContext === true),选型因此站得住。
  2. Coding 超出预估(300 → 340 / 360):页面层的 app.js 要同时处理六个视图的路由、三种列表渲染、双态表单、四处错误提示位,比预想的多;逻辑层则多写了大量「非法输入兜底」的分支。
  3. Test 超出预估(140 → 180 / 165):除了设计并写完 181 个单元测试用例,还额外写了一套浏览器端到端自检脚本(_verify/verify_app.py,88 项断言,用无头 Chrome 真实点击),这一层是预估时没有充分考虑的。
  4. 合计甲 1015 分钟、乙 1000 分钟,都比预估(785)多出不少,主要都花在「验证环境」「编码返工」和「补测试」上——恰好是这几件事后来帮我们省下了更多的时间(见第八节)。

三、解题思路描述与设计实现说明

3.1 代码实现思路(文字描述)

(1)技术选型:为什么是「纯静态 + localStorage」

作业 2.5 条的原话是「下载你的所有文件后,用谷歌浏览器运行 html 文件就能展现预期结果」。这一条直接否掉了需要构建步骤的方案——引入 Vue / React 就要打包,「双击 html 跑起来」就不再成立。所以技术栈定为 HTML + CSS + 原生 JavaScript,零依赖、零构建、数据用 localStorage 持久化。

数据放前端还有一个附带好处:逻辑层不依赖任何环境,可以被 Node 直接 require,单元测试才写得起来。反过来如果做了后端,「测试要连数据库」会立刻把整个测试计划拖垮。

(2)分层:三层,单向依赖

图1_总体架构与数据流

  • 页面层 js/app.js:视图路由、DOM 渲染、事件绑定。只调用逻辑层,不直接碰 localStorage。
  • 逻辑层 js/ 下 7 个模块:Constant(枚举 / 文案 / 字数上限契约)、Seed(示例数据)、Reward(悬赏与打赏的读写统计)、ItemStore(存取 + 发布者标识)、Validate(表单校验)、Search(搜索 / 筛选 / 排序)、Status(状态文案)。完全不碰 DOM。
  • 数据层 localStorage:只由 ItemStore 读写。

这条「页面层不越层」的纪律是刻意坚持的:所有「什么算合法数据」「什么算筛选中」的判断都只存在于逻辑层一处,页面层只负责把结果画出来。后来做单元测试时,这层分离直接兑现成了「测试 181 个用例全部针对逻辑层,不需要 mock DOM」。

(3)一条信息的完整旅程

以「发布一条寻物信息 → 别人搜到 → 联系我 → 我标记已找到」为例:

  1. 用户在发布页填表,点「立即发布」;app.js 把表单值收成一个普通对象 data(注意:此时它还是不可信的原始输入)。
  2. Validate.validateItem(data) 校验,返回 { valid, errors }。valid === false 就按 errors 的键逐项标红,流程到此为止。
  3. 通过后 ItemStore.save(data):自动补全 id(时间戳 + 自增序号 + 随机串)、ownerId、status = 'open'、views = 0、三个时间戳,然后 unshift 到列表并写回 localStorage。
  4. 首页再次渲染时,Search.filterItems(ItemStore.getAll(), filters) 按当前 tab / 筛选 / 排序返回新数组,页面层只负责把它画成卡片。搜索页和首页筛选走的是同一个函数,所以两处的匹配规则永远一致。
  5. 别人点卡片进详情页,浏览量 +1(update(id, { views }));联系方式默认显示 ●●●●●●●●,点「查看联系方式」才明文显示,再点「复制联系方式」直接拿走。
  6. 发布者本人在「我的发布」里点「标记为已找到」,update(id, { status: 'done' });closedAt 由 update 自动补上,卡片和详情页的状态标签同步变成「已找到」。

(4)三个关键的工程决策

① 模块「双导出」——决定单元测试能不能与线上同源。
七个模块统一写成:

(function (root) {
  const ItemStore = { /* ...真实实现... */ };
  if (typeof module !== 'undefined' && module.exports) module.exports = ItemStore;  // Node / Mocha
  else root.ItemStore = ItemStore;                                                  // 浏览器 window
})(typeof window !== 'undefined' ? window : globalThis);

于是测试里 require('../js/store.js') 拿到的是页面运行时用的同一份代码。这不是为了「方便测试」才补的写法,而是从一开始就按「同一份实现、两种加载方式」设计的——否则测试很容易退化成「为测试另抄一套实现」,那样测过了也不能说明线上是对的。

② 命名铁律:window 上不能挂小写的通用名。
这是往届踩过、我们提前实测避开的坑(详见第八节问题 1)。结论是模块名一律大写开头:Constant / Seed / Reward / ItemStore / Validate / Search / Status;存取模块刻意叫 ItemStore 而不是 Storage(window.Storage 是浏览器内置构造函数)。每个模块头部还加了依赖守卫,顺序错了直接抛异常,不许静默失败:

const C = (typeof module !== 'undefined' && module.exports) ? require('./constant.js') : root.Constant;
if (!C) throw new Error('[store.js] constant.js 未加载:请把 constant.js 放在本文件之前引入');

③ 时间要存两份:给人看的 lostTime 和给排序用的 lostAt。
一开始想直接用表单里那个中文时间(如「9月25日 下午」)排序,很快发现是错的——按字典序 '9月6日' 大于 '10月8日',但实际前者更早。所以数据里拆成两个字段:lostTime(中文文本,只负责展示)与 lostAt(时间戳,只负责排序),排序时若 lostAt 缺失则回落到 createdAt。同一类问题在「排序」一节还有更详细的说明(4.2)。

顺带说明:发布表单最初用的是自由文本时间(「如:9月25日 下午」),新数据只能拿「此刻」近似 lostAt,「最近丢失」因此退化。后来把该字段换成了 <input type="datetime-local">,展示文本与排序时间戳由同一个输入派生,两个字段天然一致——见 4.5 问题 3。

(5)身份方案:不做登录,用 ownerId 区分「我发的」

作业已豁免实名认证,我们也不引入账号体系,而是首次访问时用 crypto.randomUUID() 生成一个发布者标识存在 localStorage,此后每条信息都带上它,ItemStore.isOwner(item) 由此判断「这条是不是我发的」,只有本人才能看到「标记已找到 / 已归还」「撤销完结」「删除」这三个按钮。

这是我们刻意的简化,也如实说明它的边界:换浏览器、换设备、清空浏览器数据后,ownerId 会变,此时看不到自己以前的发布。它的价值在于——演示时无需注册登录就能完整体验「只有发布者能改状态」这条权限逻辑,而这正是作业正文要求的功能点。

3.2 关键实现的流程图 / 数据流图

图 1(见 3.1) 是整体架构与数据流;下面三张分别对应核心业务闭环、状态流转、以及容错实现。

(1)核心业务流程图——覆盖作业正文要求的「发布信息 — 浏览 / 搜索 — 查看详情 — 联系发布者 — 更新状态」全链路,三条泳道对应三类使用者动作:

图2_核心业务流程图

(2)状态流转图——status 只有 open / done 两个值,「已找到 / 已归还」是同一状态的两种展示文案,由类型决定:

图3_状态流转图

(3)存储读取与自愈流程——这是本项目容错设计的核心,也是我在逻辑层里最满意的部分:

图4_存储读取与自愈流程

这三张图对应到代码就是:app.js(页面层渲染与事件)、Status.getStatusText(文案)、ItemStore.ensure / pickStorage(存储),三者之间只通过「数组」和「字符串」这两种最简单的数据类型通信。

3.3 重要代码片段与解释

片段一:ItemStore 的存储探测与自愈(js/store.js)

// 真正可用的 localStorage 才用它:隐私模式、file:// 被禁用等情况会抛异常,一律降级
function pickStorage() {
  try {
    const ls = root.localStorage;
    if (!ls) return createMemoryStorage();
    const probe = '__campus_lf_probe__';
    ls.setItem(probe, '1');                       // 不只判断「存不存在」,而是真的写一次
    if (ls.getItem(probe) !== '1') return createMemoryStorage();
    ls.removeItem(probe);
    return ls;
  } catch (e) {
    return createMemoryStorage();                 // 写不进去 / 被禁用 → 内存替身
  }
}

function ensure() {
  const raw = readKey(C.STORAGE_KEY);
  if (raw !== null && raw !== undefined && raw !== '') {
    try {
      const parsed = JSON.parse(raw);
      if (Array.isArray(parsed)) return parsed;
    } catch (e) { /* JSON 损坏 → 落到下面重新灌示例数据,自愈 */ }
  }
  return writeSeed();     // 首次访问,或者数据已经损坏
}

为什么这段值得贴:

  1. pickStorage() 用「真的写一次」代替「判断对象存不存在」。 隐私模式、被策略禁用的浏览器里 localStorage 对象是存在的,只是 setItem 会抛异常——只判断 if (localStorage) 会在这些环境里埋雷。写一个探针键、读回来确认、再删掉,才能真正确认它可用。
  2. ensure() 把「首次访问」和「数据损坏」合并到同一条恢复路径上。 页面层永远只会拿到「一个数组」,永远不需要处理 null 或异常;localStorage 里的内容被别的东西写坏时,页面会自动重新灌入示例数据,而不是白屏。
  3. 降级是「行为一致、只是不持久化」,不是「功能消失」。 内存替身的 getItem / setItem / removeItem 与 localStorage 接口完全一致,所以上层的 ItemStore 一行都不用改。这也正是单元测试能在 Node 里直接跑的原因——Node 里没有 localStorage,于是自动走了这条降级路径。

片段二:校验结果的「按字段归类」(js/validate.js)

function validateItem(data) {
  const errors = {};
  const d = (data && typeof data === 'object') ? data : {};   // 传 null / 字符串也不抛异常

  if (!C.isValidType(d.type)) errors.type = '请选择「我丢了东西」或「我捡到东西」';

  const name = text(d.name);
  if (!name) errors.name = '请填写物品名称';
  else if (name.length > C.LIMITS.name) errors.name = tooLong('物品名称', name, C.LIMITS.name);

  if (!C.isValidCategory(d.category)) errors.category = '请选择物品分类';
  if (!C.isValidPlace(d.place)) errors.place = '请选择' + C.getPostTab(d.type).placeLabel;

  const contact = text(d.contact);
  if (!contact) errors.contact = '请填写联系方式,方便失主或拾获者联系你';
  else if (contact.length < C.LIMITS.contactMin) errors.contact = '联系方式至少 3 字,现在 N 字';

  return { valid: Object.keys(errors).length === 0, errors: errors };
}

为什么这段值得贴:

  1. 校验函数不知道 DOM 的存在。 它只返回一个大对象,页面层拿着 errors.name / errors.contact 各找各的提示位去渲染。所以「一次提交把所有错都标出来」是天然的——不需要页面层写任何「先查哪个字段」的逻辑。
  2. 提示文案是「动态」的,不是「静态的」。 「请选择丢失地点 / 请选择拾获地点」这一处,直接从 Constant.getPostTab(type).placeLabel 取,所以寻物态与招领态共用同一份校验代码,文案自动跟着表单切换。
  3. 错误提示里带真实字数(「不超过 30 字,现在 31 字」),这个细节后来被直接写成了单元测试的断言——测试会检查提示里是否包含 31 这个数字,防止有人把 > 顺手写成 >=。
  4. errors 的键名就是页面上的字段名,这条约定让页面层可以写成循环([['#f-name','#err-name'], ...]),也让「重新输入时自动清掉该项错误态」只花了三行代码。

片段三:详情页联系方式的「三态渲染」(js/app.js)

let contactHtml;
if (state.contactRevealed) {
  contactHtml =
    '<div style="text-align:center;font-size:16px;margin:8px 0">' + escapeHtml(item.contact) + '</div>' +
    '<button class="btn-outline" style="width:100%" onclick="copyContact(\'' + escapeHtml(item.contact) + '\')">' +
      Constant.TEXT.contactCopy + '</button>';
} else {
  contactHtml =
    '<div class="detail-contact-masked">' + Constant.TEXT.contactMasked + '</div>' +
    '<button class="btn-outline" style="width:100%" onclick="revealContact()">' +
      Constant.TEXT.contactReveal + '</button>';
}

为什么这段值得贴:

  1. 「打码 → 显示 → 复制」三态只用了一个布尔量 state.contactRevealed 表达,没有额外的 DOM 操作、没有显隐切换的中间态。重新渲染详情页时状态自然恢复,不会出现「返回列表再进来还留着上一条的明文」。
  2. 明文只在「用户明确点过」之后才渲染进 DOM。 列表页、搜索页的卡片里根本没有联系方式字段,也就是说没有「随手复制页面源码就能批量捞号」的路径——这也是我们在搜索模块里刻意让 contact 不参与关键词匹配的原因(KEYWORD_FIELDS 只有名称 / 分类 / 地点 / 描述四个字段)。
  3. 所有插值的文本都过 escapeHtml()。 用户输入的名称、描述、联系方式会原样插进 innerHTML,不转义就等于把 XSS 的门敞开——虽然是课程作业,但这条是底线。

四、附加特点设计与展示

对应评分细则:设计的创意独到之处与意义(3 分)/实现思路(3 分)/重要代码片段与解释(2 分)/实现成果展示(2 分)。

作业正文只要求「发布信息 — 浏览/搜索 — 查看详情 — 联系发布者 — 更新状态」这条最小闭环,而评分规则里明确写了「尽情丰富内容」,并且自己举了两个例子:按物品类别、遗失或拾取地点进行筛选、一键复制联系方式。我们以这两项为必做(直接命中规则举例),又补了六项,一共 8 项附加特点。它们全部在附件项目里点得到,也可以在本节的截图里看到。

# 附加特点 与作业要求的关系 入口
E1 分类 / 地点 / 状态组合筛选 正文只要求「集中浏览」,没要求筛选 首页三个下拉 + 类型 tab
E2 一键复制联系方式 正文只要求「查看联系方式」 详情页「复制联系方式」
E3 三种排序(最新发布 / 最近丢失 / 最多浏览) 正文未要求 首页排序栏
E4 相似物品匹配提示 正文未要求 发布寻物信息后,成功页给出可点击入口
E5 首页数据看板(累计 / 寻物 / 招领 / 已完结比例) 正文未要求 首页统计条
E6 信息新鲜度提示(「3 小时前发布」/ 超 30 天提示过时) 正文未要求 每张卡片的时间行
E7 24 条示例数据首次访问自动灌入 + 一键重置 正文未要求,但决定了「打开就能用」 自动灌入 / 「我的」页「恢复示例数据」按钮
E8 悬赏与打赏(寻物帖挂悬赏、招领帖收打赏) 正文未要求,只要求「联系发布者」 发布寻物帖填悬赏 / 招领帖详情页「打赏拾金不昧者」

说明:作业评分规则里写明「作业要求的内容不算额外特点」,所以上面每一条都刻意避开了正文点名的功能——筛选对应的是「集中浏览」,复制对应的是「查看联系方式」,都算在要求之外的加法。E8 同理:正文只要求「联系发布者」,悬赏与打赏是这条线之外、用来提高信息可信度的加法。

4.1 设计的创意独到之处与意义(3 分)

① 筛选不是「三个下拉」,而是「一个可以叠加的检索入口」。

校园失物招领的真实使用场景里,来找东西的人是带着记忆碎片来检索的:「我大概是在图书馆丢的」「应该算电子产品」。所以类型 tab(全部 / 寻物 / 招领)、分类、地点、状态这四个维度被设计成可以叠加的,而不是各筛各的装饰。实测:24 条示例数据里,叠加「寻物 + 电子产品 + 进行中」后剩 3 条,用户看到的是 3 张能直接点进详情、能直接联系的卡片。

它的意义不只是「能筛」,而是在下拉框里就把分类和地点做成了校园的封闭词表(8 个分类 / 8 个地点,写在 constant.js 里被两个人共用),用户填不出五花八门的自由文本,于是筛选才成立、关键词搜索才准。

② 联系方式「先打码、再显示、再复制」,三步各解决一个问题。

失物招领的联系方式一旦明文挂在列表上,就等于把同学的微信号交给爬虫。所以详情页默认显示 ●●●●●●●●,点「查看联系方式」才明文显示(多一次明确的用户意图),再点「复制联系方式」直接拿走——省掉手动选字复制的每一步。三步合起来:既不公开暴露隐私,也不让想联系的人被「长按选中、拖到边界、复制」这套操作劝退。

③ 让「发布」和「浏览」互相导流:相似物品提示。

发寻物信息的人最怕的一种情况是:发完之后才发现,其实早就有人捡到并发了招领。所以发布寻物信息时,系统拿刚填的分类去招领库里查一遍,命中就在发布成功页给出提示,并且这条提示本身就是一个入口:

库里有 2 条同类的招领信息,可能已被捡到,先去看看 ›

点「先去看看 ›」直接跳进那条招领帖的详情页(见 4.5 问题 2:最初它只是一条会自动消失、点不了的提示,后来补上了出口)。

这条提示的价值在于把两条本该相遇的信息提前撞上,减少的是「重复发布 → 无人应答 → 反复刷新」的无效流程。

④ 数据看板与新鲜度提示,回答「这个平台有人在用吗」。

首页顶部给出 4 个数字(本次运行:24 条累计 / 14 条寻物 / 10 条招领 / 33% 已完结),「已完结比例」尤其重要——它是这个平台是否真的在解决问题的证据;每张卡片上则给出「刚刚发布 / N 分钟前 / N 小时前 / N 天前发布」,超过 30 天的信息直接提示「发布时间已超过 30 天,信息可能已过时」,避免同学把精力花在一条早已失效的信息上。

⑤ 悬赏与打赏:「承诺」和「兑现」分别挂在两种帖子上(E8)。

失物招领里其实有两种完全不同的「给钱」动机,我们没有把它们混成一个功能:

  • 悬赏挂在寻物帖上——失主在发布时说「找回后自愿兑现 ¥N」。它是一句承诺,纯展示、不落任何流水,所以界面上(首页徽章、详情页橙色行)都写明「找回后自愿兑现,仅作展示,不涉及线上支付」。
  • 打赏挂在招领帖上——失主看到别人拾金不昧,在详情页点「🎁 打赏拾金不昧者」,落一条本机记录,形成一面「打赏墙」(每笔金额 + 一句留言 + 累计「共 N 次 · ¥M」)。

这样切分的好处有两个。第一,语义不重叠:承诺归承诺、兑现归兑现,同一张帖子上不会既喊悬赏又收打赏。第二,顺手挡掉一个怪逻辑:如果打赏也开放给寻物帖,就会出现「自己给自己打赏」——所以 Reward.canTip(item, isOwner) 明确要求「招领帖 + 非本人」才给出入口。

为什么是「记录」而不是「支付」:这个项目是纯静态、零后端的(4.1 节的技术选型),真实转账不可能闭环。与其做一个假的支付按钮,不如把界面上如实标注「演示:打赏仅在本机记录,不涉及真实支付」——这是诚实,不是缩水。

一个设计取舍:打赏总额由 tips 数组派生(Reward.getTipTotal(item)),而不是另存一个 tipTotal 字段。多存一个字段就多一个「改了数组忘改总额」的漂移源,而派生值永远不会自相矛盾。

总的说:这几项附加特点指向同一个目标——把「发布一条信息」这个单点动作,变成信息能被找到、能被信任、能被闭环。悬赏与打赏补的正是最后一环:让「愿意归还」这件事有正反馈。

4.2 实现思路(3 分)

总原则:附加特点不允许破坏核心闭环。 所以这 8 项里没有一项需要新增数据表或新增页面——它们要么是在已有结构上多挂一个筛选维度(E1 / E3 / E6),要么是复用逻辑层已有的纯函数(E4 直接复用 Search.filterItems、E8 复用 Constant.isValidAmount 与打赏弹层),要么是一个纯展示函数(E5 / E6)。这样做的直接好处是:附加特点的改动只落在 app.js、reward.js 与 constant.js 里,逻辑层的数据结构与原有 9 个接口一行都没动(E8 只给信息对象多加 rewardAmount / tips 两个字段),_localtest/ 的逻辑层用例始终全绿。

(1)组合筛选:把所有筛选项收敛成一个 filters 对象。

页面层不写「先按类型筛,再按分类筛」的瀑布式判断,而是把当前所有选择攒成一个对象交给同一个纯函数:

const items = Search.filterItems(all, {
  type: state.homeTab, category: state.filterCategory,
  place: state.filterPlace, status: state.filterStatus, sort: state.sort
});

关键词搜索走的是同一个函数(只是 filters 里只放了 keyword),所以「搜索页」和「首页筛选」永远不会出现判定不一致——规则只有一份。

(2)排序:把「按什么排」写成数据,而不是写成 if。

constant.js 里的 SORT_OPTIONS 直接声明每个排序读哪个字段、字段缺失时回落到哪个字段:

{ value: 'latest',   label: '最新发布', field: 'createdAt', fallbackField: 'createdAt' },
{ value: 'lostTime', label: '最近丢失', field: 'lostAt',    fallbackField: 'createdAt' },
{ value: 'hot',      label: '最多浏览', field: 'views',     fallbackField: 'views' }

排序函数只负责「按 opt.field 取值、一律倒序」,加一种排序只要在表里加一行。

这里踩过一个坑,值得记下来:数据里有两个时间——lostTime(给用户看的中文,如「10月9日 下午」)和 lostAt(时间戳)。一开始想直接拿 lostTime 排序,但中文日期按字典序比大小是错的('9月6日' 大于 '10月8日',而实际上前者更早)。所以展示与排序必须分成两个字段:中文文本只负责展示,时间戳只负责排序。示例数据里的 lostAt 是按「丢失时间比发布时间早多久」反推出来的真实丢失时刻,所以「最近丢失」和「最新发布」在演示数据上确实排出不同的顺序。

(3)一键复制:特性检测走主路径,其余全部走降级。

file:// 打开时,现代 Chrome 实测 window.isSecureContext === true,navigator.clipboard 可用,所以主路径直接用它;降级路径(临时 textarea + document.execCommand('copy'))负责兜住旧浏览器、非安全上下文和权限被拒的情况。注意降级是挂在 .catch 上的——主路径调用失败(文档未聚焦、权限被拒)时同样会落到降级,而不是只弹一句「复制失败」。这条路径最初挂错了位置,见 4.5 问题 1。

(4)相似物品提示:3 行代码,靠复用而不是新写函数。

没有必要为「找相似」写一个新方法——它本质上就是「类型 = 招领 + 分类 = 刚填的分类」的一次筛选,再把结果渲染成发布成功页上的一条可点击提示:

const similar = Search.filterItems(ItemStore.getAll(), {
  type: Constant.TYPE.FOUND, category: data.category
});
// 渲染进 #similar-tip,并给一个能点的入口(openDetail 是 app.js 内部函数,
// 没挂到 window,所以只能用闭包绑 onclick)
if (similar.length > 0) {
  tip.textContent = Constant.TEXT.similarTip.replace('{n}', similar.length);
  const a = el('a', 'similar-link', Constant.TEXT.similarTipLink);
  a.onclick = function () { openDetail(similar[0].id); };
  tip.appendChild(a);
  tip.classList.remove('hidden');
}

这正是 4.2(1) 那个「收敛成一个纯函数」的决定带来的复利:附加特点可以直接站在它上面。

(5)示例数据:把「打开就能用」也当成一个功能来做。

store.js 的 ensure() 在读不到数据时不只是返回空数组,而是灌入 24 条示例数据并落盘,并且把 JSON 解析失败也归到这条路径上——存储被写坏时页面会自愈,而不是白屏。另外用了一个独立的标记键 campus-lost-found:seeded 区分两种情况:「首次访问该灌数据」和「用户自己把信息全删光了」(后者不该被重新灌回来,否则删掉的东西会自己长出来)。ItemStore.reset() 则给答辩演示用:一条命令恢复 24 条示例数据。

(6)新鲜度与看板:纯函数 + 一次遍历。

时间文案由 fmtTime(ts) 一个纯函数处理(<1 分钟 / <60 分钟 / <24 小时 / <30 天 / ≥30 天 五档),阈值全部来自 Constant.TEXT,不写死在页面里;看板则是对 ItemStore.getAll() 的一次遍历,算出 4 个数字,不做缓存、不做增量——24 条数据的规模下,简单比"高效"重要。

(7)悬赏与打赏:新加一个纯函数模块,页面层一行 localStorage 都没碰(E8)。

打赏的记录读写与统计被收敛进新模块 js/reward.js(同样「浏览器挂 window / Node 用 module.exports」),页面层只做两件事:把用户选的金额交给纯函数算出一条记录,再交给 ItemStore.addTip() 落盘。

  • 金额判据只有一处:悬赏与打赏共用 Constant.isValidAmount(非 NaN 的整数、1~200),页面层的档位、自定义输入、发布表单三处都从它取,不存在「这个入口能填 0、那个入口不能」。
  • 累计值派生而非存储:getTipTotal(item) 现算 { count, total },所以打赏墙上「共 2 次 · ¥15」永远和明细对得上。
  • 弹层复用同一套模式:打赏弹层用的是和发布表单一样的「选中态 + 行内错误提示」写法,没有为它新造一套交互。

这就是 4.2 那条「页面层不越层」的纪律在 E8 上继续生效:app.js 里没有一个字 localStorage,全部经 ItemStore.addTip()。

4.3 重要代码片段与解释(2 分)

片段一:组合筛选 + 排序(js/search.js)

// 'all' / '' / null / undefined 都表示「这一项不筛」——统一哨兵,避免到处写裸 'all'
function isAll(v) {
  return v === undefined || v === null || v === '' || v === C.FILTER_ALL;
}

function filterItems(items, filters) {
  const list = Array.isArray(items) ? items.slice() : [];        // 先复制,不改动调用方的数组
  const f = (filters && typeof filters === 'object') ? filters : {};
  const keyword = lower(f.keyword).trim();

  const hit = list.filter(function (item) {
    if (!item || typeof item !== 'object') return false;         // 脏数据跳过,不抛异常
    if (!isAll(f.type)     && item.type     !== f.type)     return false;
    if (!isAll(f.category) && item.category !== f.category) return false;
    if (!isAll(f.place)    && item.place    !== f.place)    return false;
    if (!isAll(f.status)   && item.status   !== f.status)   return false;
    if (!isAll(f.ownerId)  && item.ownerId  !== f.ownerId)  return false;
    if (keyword && !matchKeyword(item, keyword)) return false;
    return true;
  });

  return sortItems(hit, f.sort);                                 // 筛完再排,一次返回
}

// 排序也是数据驱动:排哪个字段、缺失时回落哪个字段,全写在 constant.js 的 SORT_OPTIONS 里
function sortItems(list, sort) {
  const opt = C.findByValue(C.SORT_OPTIONS, sort) || C.findByValue(C.SORT_OPTIONS, C.DEFAULT_SORT);
  return list.sort(function (a, b) {
    const x = sortValue(a, opt.field, opt.fallbackField);
    const y = sortValue(b, opt.field, opt.fallbackField);
    if (x === y) return 0;
    return x > y ? -1 : 1;                                       // 一律倒序:越大越新 / 越热
  });
}

为什么这段值得贴:

  1. 六个维度是同一条式子。 五个等值条件 + 一个关键词,全部写成 !isAll(f.x) && item.x !== f.x,所以「再加一个筛选维度」= 再加一行,不需要动结构。类型 tab、分类、地点、状态、关键词、ownerId(「我的发布」页直接复用它)全都走同一段代码。
  2. isAll() 把「没选」的四种写法统一了。 页面层不需要判断「用户到底选没选」,filters 里少传一个键、传 ''、传 null 都不会误筛成空列表——这一条在 _localtest/smoke_logic.js 里专门有用例。
  3. 先 slice() 再排序。 Array.prototype.sort() 是原地排序。首页渲染时传进来的就是 ItemStore.getAll() 的返回值,不复制会顺手改掉调用方的数组顺序,下一次渲染的基准就乱了。
  4. 非法输入一律兜底。 filterItems(undefined)、数组里混 null、sort 传一个不存在的值,全都返回约定结果([] / 回落到默认排序)而不是抛异常——这些兜底后来直接变成了单元测试的用例来源。

片段二:一键复制联系方式(js/app.js)

window.copyContact = function (text) {
  const ok = function () { toast(Constant.TEXT.copySuccess); };

  // 降级实现:临时 textarea + execCommand(剪贴板 API 不可用、或调用失败时兜底)
  const legacyCopy = function () {
    const ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed'; ta.style.opacity = '0';
    document.body.appendChild(ta);
    ta.select();
    try { document.execCommand('copy'); ok(); }
    catch (e) { toast(Constant.TEXT.copyFail); }
    document.body.removeChild(ta);
  };

  if (navigator.clipboard && window.isSecureContext) {
    navigator.clipboard.writeText(text).then(ok).catch(legacyCopy);   // ← 降级挂在 catch 上
  } else {
    legacyCopy();
  }
};

为什么这段值得贴:

  1. 同一个能力有两条路径,而且主路径是靠特性检测选的,不是靠"猜浏览器"。file:// 下现代 Chrome 已把页面视为安全上下文(实测 isSecureContext === true、读写 localStorage 均可用),所以两条路径里走的是前者。
  2. 降级用的 textarea 必须脱离布局流、且不可见但可选中:用 position: fixed 让它不参与页面布局(否则插入/删除的瞬间页面会抖一下),用 opacity: 0 而不是 display: none(display: none 的元素无法 select())。
  3. 降级挂在 .catch 上,而不是只挂在 else 上。 这一条是走了弯路才定下来的:最初 .catch(fail) 只弹了一句「复制失败」,真正能用的 execCommand 降级写在 else 分支里,只有 navigator.clipboard 不存在时才轮到它。于是「API 存在但这次调用失败」的情况下,既没复制成功、也没去试那条写好的降级路径。过程见 4.5 问题 1。

片段三:打赏累计值派生而非存储(js/reward.js)

// 累计:条数与总额都由数组现算;amount 不是有限数字的记录计入条数但不计入金额
function getTipTotal(item) {
  const list = getTipList(item);
  let total = 0;
  for (let i = 0; i < list.length; i++) {
    if (typeof list[i].amount === 'number' && isFinite(list[i].amount)) {
      total += list[i].amount;
    }
  }
  return { count: list.length, total: total };
}

// 打赏只出现在「招领帖」且「不是自己发的」详情页
// —— 寻物帖走「悬赏」那条线,自己给自己打赏没有意义
function canTip(item, isOwner) {
  return !!item && item.type === C.TYPE.FOUND && isOwner !== true;
}

为什么这段值得贴:

  1. 不存冗余字段。 打赏总额如果单独存一个 tipTotal,就多了一处「改了 tips 数组、忘了改总额」的漂移可能;现算虽然每次都遍历一遍,但在这个数据规模下代价可以忽略,换来的是永远不可能对不上。
  2. 入口判定是一个显式函数,而不是散在页面里的 if。 canTip(item, isOwner) 同时服务三处:详情页要不要画按钮、首页要不要显示打赏次数徽章、「我的发布」要不要把这条算进「收获打赏」。规则一处写、三处用——和 4.2(1) 里筛选函数的做法是同一个思路。
  3. 脏数据兜底。 amount 被别的东西改成字符串时,记录仍计入条数但不计入金额,不会因为一个 '10' 把整面墙的合计变成 NaN。逻辑层的每条兜底都配了单元测试(test/reward.test.js,27 个用例)。

4.4 实现成果展示(2 分)

下面 9 张图由自检脚本 _verify/shoot_extras.py 一次性跑出来(headless Chrome 真实渲染 + 真实点击,不是设计稿),脚本还会把每一步的数据打在终端里,图上的文字和终端里的数字是同一份。

① 组合筛选:类型「寻物」+ 分类「电子产品」+ 状态「进行中」→ 24 条中剩 3 条

三个条件同时生效,下拉框和 tab 上都能看到当前的选择;顶部统计条与筛选无关(它是全库数据),所以仍显示 24 / 14 / 10 / 33%。

08_附加_组合筛选

② 排序切换:「最多浏览」

切到「最多浏览」后,排在最前的是浏览量最高的一条(蓝牙耳机,👁 88 次),而它在「最新发布」里并不排在第一位——说明排序真的换了维度。

09_附加_排序_最多浏览

③ 一键复制联系方式

详情页联系方式明文显示,点「复制联系方式」后弹出「联系方式已复制,去联系 TA 吧」。

10_附加_一键复制联系方式

④ 相似物品匹配提示

发布一条「寻物·日用品」,系统在招领库里查到 2 条同类信息,于是在发布成功页给出提示:「库里有 2 条同类的招领信息,可能已被捡到,先去看看 ›」。这个「先去看看 ›」是可以点的,点下去直接进入那条招领帖的详情页——不再是开局那种「有提示、点不了、1.8 秒后自己消失」的 toast(对比见 4.5 问题 2)。

11_附加_相似物品提示

⑤ 搜索空态 + 热门词

搜「航空母舰」→ 0 条结果,页面给出空态引导(而不是一片空白),上方是 6 个热门搜索词,点一下直接搜。

12_附加_搜索空态

⑥ 信息新鲜度与过时提示

同一条卡片上,「刚刚发布 / N 分钟前 / N 小时前 / N 天前」是活的;超过 30 天的信息(这条是 33 天前发布的「宿舍钥匙」)直接提示「发布时间已超过 30 天,信息可能已过时」。

13_附加_新鲜度与过时提示

⑦ 悬赏:只挂在寻物帖上

点开一条带悬赏的寻物帖(「保温水杯」),详情页给出橙色悬赏行「🎁 悬赏 ¥10,找回后自愿兑现」。同一张页面上没有打赏入口——悬赏归悬赏、打赏归打赏,语义不重叠(见 4.1 ⑤)。

15_附加_悬赏_寻物帖悬赏行

⑧ 打赏:只挂在别人的招领帖上

点开一条他人发布的招领帖(「篮球」),详情页出现「🎁 打赏拾金不昧者」按钮 + 已收到的打赏墙;点按钮后弹层从底部升起,4 个固定档位(¥2 / ¥5 / ¥10 / ¥20)默认选中 ¥2,下方是自定义金额、留言框和一句「演示:打赏仅在本机记录,不涉及真实支付」。

16_附加_打赏_弹层与打赏墙

⑨ 一键重置的界面入口:把「答辩演示」也当成一个功能

ItemStore.reset() 早就实现了,但最初只能在控制台调用。现在「我的发布」页底部有一个「恢复示例数据」按钮,点击后二次确认(「将清空本机全部数据并恢复 24 条示例信息,确定继续吗?」),确认后数据回到 24 条并弹出成功提示——演示前不用再开控制台。

17_附加_恢复示例数据入口

复现方式(在项目同级目录 _verify/ 下):

python shoot_extras.py

脚本开头会先调用 ItemStore.reset() 把数据恢复成 24 条示例数据,所以每次跑出来的图都是干净可比的;结束时打印筛选条数、复制 toast 文案、排序首条的浏览量等真实数据。

4.5 自查发现的问题与修复(诚实记录)

写这一节时我逐项点了自己实现的功能,抓到三个问题。它们在截图里看不出来,是"点下去"才暴露的,所以我认为值得原样写进博客。

后来(写完博客初稿、又补完悬赏打赏那批功能之后)我们把这三条实际修掉了,并且每一条都补了对应的端到端断言(_verify/verify_app.py 从 78 项加到 88 项)。下面每一条都保留「当时是怎么发现的」,再写「最后怎么修的」——发现问题的那部分才是真正值钱的经验。

问题 1:复制的降级路径挂错了位置(真 bug)

  • 现象:用脚本自动点击「复制联系方式」时,toast 弹的是「复制失败,请手动选中后复制」;而同一个页面里 window.isSecureContext 是 true、navigator.clipboard 也存在——特性检测是通过的,调用却失败了(无头环境下文档未聚焦 / 权限被拒)。
  • 原因:.catch(fail) 只负责弹一句"复制失败",真正能用的 execCommand 降级路径写在 else 分支里,只有 navigator.clipboard 不存在时才会走。于是"API 存在但这次调用失败"的情况下,代码既没有复制成功,也没有去试那条明明写好的降级路径。
  • 修法:把降级抽成一个函数,if 的两个分支和 catch 都指向它:
window.copyContact = function (text) {
  const ok = function () { toast(Constant.TEXT.copySuccess); };
  const legacyCopy = function () {          // 临时 textarea + execCommand
    const ta = document.createElement('textarea');
    ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
    document.body.appendChild(ta); ta.select();
    try { document.execCommand('copy'); ok(); }
    catch (e) { toast(Constant.TEXT.copyFail); }
    document.body.removeChild(ta);
  };

  if (navigator.clipboard && window.isSecureContext) {
    navigator.clipboard.writeText(text).then(ok).catch(legacyCopy);   // ← 挂到 catch 上
  } else {
    legacyCopy();
  }
};
  • 状态:✅ 已修复(js/app.js)。端到端脚本里补了「把 navigator.clipboard.writeText 改成必定 reject,再调用 copyContact」的用例,断言降级路径确实被执行、且 toast 是成功文案而不是失败文案。
  • 收获:特性检测只能说明"这个 API 存在",不能说明"这次调用会成功"。 凡是写了降级路径的功能,降级应该挂在失败回调上,而不是挂在 if 上。这个 bug 是靠"用脚本真的去点一下"发现的——人在浏览器里手点,因为文档处于聚焦状态,是复现不出来的。

问题 2:相似物品提示只有「提示」,没有「出口」。

  • 现象:文案写的是「可能已被捡到,先去看看?」,但它是一条 1.8 秒后自动消失的 toast,点不了;同时 index.html 里那个 <div class="similar-tip hidden" id="similar-tip">(样式已经在 style.css 里写好)从头到尾没有被用上——说明当时的想法是"内联展示",最后落成了 toast。提示给出了行动建议,却没有给出行动本身。
  • 修法:把 #similar-tip 真正用起来,并给一个能点的链接。这里有两个细节:
    1. 位置要在发布成功页。发布成功后页面会切到「发布成功」视图,如果提示留在发布表单里,用户根本看不到它;
    2. 不能用 onclick 属性。openDetail 是 app.js 内部的函数、没有挂到 window 上,在 HTML 属性里写 onclick="openDetail(...)" 会因为找不到全局函数而静默失败(不报错、点了没反应——比报错更难查)。正确做法是用 addEventListener / 闭包直接调用:
const similar = Search.filterItems(ItemStore.getAll(), {
  type: Constant.TYPE.FOUND, category: data.category
});
const tip = document.querySelector('#similar-tip');
if (similar.length) {
  tip.textContent = Constant.TEXT.similarTip.replace('{n}', similar.length);
  const a = el('a', 'similar-link', Constant.TEXT.similarTipLink);
  a.onclick = function () { openDetail(similar[0].id); };   // ← 闭包,不依赖全局函数
  tip.appendChild(a);
  tip.classList.remove('hidden');
}
  • 状态:✅ 已修复(index.html / js/app.js / css/style.css)。端到端脚本追加了三条断言:提示在成功页可见、里面确实有一个 <a>、点它之后详情页标题等于那条招领帖的名字。
  • 收获:给出了建议,就要同时给出执行建议的入口。 另外「内部函数不要通过 HTML 属性调用」这条也值得记——这类错误不会报错,只会让点击悄无声息地失效。

问题 3:「最近丢失」排序对新发布的信息会退化成「最新发布」。

  • 现象:示例数据里的 lostAt 是按「丢失时间比发布时间早多久」反推的时间戳,所以排序有区分度;但新发布时,发布页的时间字段是一个自由文本输入框(如:9月25日 下午),app.js 只能拿当前时刻近似:data.lostAt = Date.now()。也就是说,用户自己发的那几条信息,「最近丢失」实际上等于按发布时间排——排序里出现了一个只有对示例数据才成立的假象。
  • 修法:把时间字段换成 <input type="datetime-local">,让两个字段由同一个输入派生:
    • lostAt = new Date(value).getTime()(给排序用,是用户真正选的丢失时刻);
    • lostTime 由一个纯函数 fmtLostTime(value) 把该时刻格式化回中文(10月9日 傍晚),给人看。时段按小时分档(凌晨 / 早上 / 上午 / 中午 / 下午 / 傍晚 / 晚上),刻意和示例数据用同一套措辞——否则新发的那几条在列表里会显得和示例数据不是一家。
// 展示与排序由同一个输入派生,天然一致,也不需要写任何中文日期解析
const timeRaw = $('#f-time').value;            // '2026-10-09T18:00' 或 ''
data.lostTime = fmtLostTime(timeRaw);          // '10月9日 傍晚'(给人看)
const lostTs = new Date(timeRaw).getTime();
data.lostAt = isFinite(lostTs) ? lostTs : Date.now();   // 时间戳(给排序用)
  • 不动的部分:seed.js 里示例数据的 lostTime(中文文本)与 lostAt(反推的时间戳)保持原样——它们本来就是自洽的,改了反而会让演示数据失真。validate.js 也不用改:时间字段仍然是必填,没填时 value 是空串,原有校验照常拦下。
  • 状态:✅ 已修复(index.html / js/app.js)。端到端脚本追加了两条断言:新发布信息的 lostAt 必须等于所选时刻的时间戳、lostTime 必须是格式化后的中文。(顺带说明:_verify/verify_app.py 里原来给时间字段赋的是中文字符串,改成 datetime-local 后必须改赋 '2026-10-09T18:00' 并派发 change,否则表单会被拦下、后面十几条断言连锁失败——改一处功能,要连带检查所有依赖它的断言。)

补记:同一批里还顺带补上的两处

  • E7 的界面入口:「一键重置」原本只有 ItemStore.reset(),界面没有任何入口,演示时要开控制台。现在「我的」页底部有一个「恢复示例数据」按钮,带二次确认(文案 Constant.TEXT.confirmReset 原本就写好了,一直没接上)。端到端脚本补了三条断言:入口存在、点「取消」不重置、点「确定」回到 24 条。
  • 打赏入口刻意复用了同一套弹层模式:给招领帖加打赏时,弹层沿用「档位选中态 + 行内红字错误提示 + 底部两个按钮」这套已经验证过的写法,没有再犯问题 2 那种「有提示、没出口」的错——发布成功页的相似提示、打赏成功后的 toast,每个提示后面都跟着一个明确的结果或入口。

本节配图位置:D:\Lost and Found\_verify\shots\(08 / 09 / 10 / 11 / 12 / 13 / 15 / 16 / 17 共 9 张)
本节自检脚本:D:\Lost and Found\_verify\shoot_extras.py


五、目录说明与使用说明

5.1 目录是如何组织的(2.5 分)

仓库里只放「运行所需的文件」,开发者本地的单测脚本、自检脚本、工作文档全部在仓库之外(并写进 .gitignore 防御)。助教把项目拿到本地后,看到的就是一棵干净、每个文件都有明确职责的树:

102401114-102401126/
├── index.html              # 唯一页面:六个视图(首页 / 发布 / 搜索 / 详情 / 我的发布 / 发布成功)+ 底部导航
├── css/
│   └── style.css           # 全局样式:主题色 #07C160、卡片体系、表单与标签、移动端自适应
├── js/
│   ├── constant.js         # 【契约文件】全局枚举与文案字典:类型 / 状态 / 8 个分类 / 8 个地点 / 排序 / 字数上限 / 提示文案
│   ├── seed.js             # 24 条示例数据:覆盖 8 个分类与 8 个地点,含本人 / 他人、进行中 / 已完结、悬赏与打赏的组合
│   ├── reward.js           # Reward:悬赏与打赏的读写与统计(纯函数:记录只增不改、累计值派生)
│   ├── store.js            # ItemStore:信息存取(getAll / getById / save / update / addTip / remove / isOwner / reset)+ 发布者标识
│   ├── validate.js         # Validate:发布表单校验,返回 { valid, errors },逐字段给中文提示
│   ├── search.js           # Search:关键词搜索 + 类型/分类/地点/状态组合筛选 + 三种排序
│   ├── status.js           # Status:状态与类型的展示文案(进行中 / 已找到 / 已归还……)
│   └── app.js              # 页面层:视图路由、列表渲染、表单提交、详情交互、打赏弹层、Toast 提示
├── test/                   # 单元测试(Mocha + Chai,7 个文件 181 个用例)
│   ├── constant.test.js    # 常量契约:枚举齐全、8 分类 8 地点、长度上限与页面 maxlength 一致
│   ├── seed.test.js        # 示例数据:字段结构、分类/地点覆盖度、本人与他人条数
│   ├── store.test.js       # ItemStore:9 个方法的正常路径与兜底、localStorage 落盘/损坏自愈/降级
│   ├── validate.test.js    # 表单校验:必填、长度边界、文案随类型切换、非法入参兜底
│   ├── search.test.js      # 搜索筛选排序:四字段匹配、不搜隐私字段、组合交集、排序回落
│   ├── status.test.js      # 状态文案:进行中/已找到/已归还,非法输入返回空串
│   ├── reward.test.js      # 悬赏与打赏:金额边界、脏数据兜底、累计值派生、入口判定
│   └── helpers/
│       └── fixtures.js     # 测试公用夹具(加载逻辑层模块、示例数据规模、合法表单)
├── package.json            # 只声明测试脚本(npm test)与 mocha / chai 依赖,页面运行不需要它
├── README.md               # 项目介绍 + 目录说明 + 使用说明(与本节内容一致)
└── .gitignore              # 忽略 node_modules、本地自检脚本目录、编辑器与系统文件

组织思路:

  1. 按「层」分目录,而不是按「页面」分文件。 这一版是单页应用(六个视图在同一个 index.html 里用 active 类切换),所以 CSS 与 JS 各自集中一份即可,方便两人分工:甲改 js/ 的逻辑模块,乙改 index.html / css/ / app.js,文件不重叠,也就不会冲突。
  2. js/ 内部按「依赖顺序」而不是按「字母顺序」排列,文件名本身就是加载顺序:constant → seed → reward → store → validate → search → status → app。每个模块头部都有依赖守卫,顺序错了会直接抛异常并告诉你该把谁放前面。
  3. 契约文件单独一个 constant.js。 枚举(8 分类、8 地点)、文案、字数上限全部集中在这里,页面层与逻辑层都从这里取——改一处,两个人都生效,不会出现「下拉框 8 个、校验认 7 个」的漂移。
  4. 测试放 test/,与 js/ 平级。 测试文件 require('../js/xxx.js') 加载的就是页面运行时用的同一份代码,不存在「副本」。

5.2 测试人员如何运行(2.5 分)

第一步:运行网页(不需要任何环境)

  1. 从 GitHub 仓库下载 ZIP,或 git clone https://github.com/1cecream1/102401114-102401126.git;
  2. 用 Google Chrome 打开根目录下的 index.html(双击即可);
  3. 页面自带 24 条示例数据,打开就有一屏内容,不需要先发布任何东西。

不需要安装依赖、不需要启动服务器、不需要配置任何东西。页面按移动端(约 375px)宽度设计,建议把浏览器窗口调窄,或用 F12 的移动端模拟查看,能看到完整效果。

第二步:按这张表把每个功能都点一遍(也欢迎助教用这条 30 秒路径快速验收)

想看什么 怎么操作
浏览 / 筛选 / 排序 打开即首页;点顶部「全部 / 寻物 / 招领」切换类型,用下方三个下拉框按分类、地点、状态筛选(可叠加),点「最新发布 / 最近丢失 / 最多浏览」切换排序
查看详情与联系方式 点任意一张卡片进详情页;点「查看联系方式」显示明文,再点「复制联系方式」复制
发布一条信息 点底部导航中间的「+」→ 选「我丢了东西 / 我捡到东西」→ 填物品名称、分类、地点、时间(原生日期时间选择器)、联系方式(带 * 为必填)→ 点「立即发布」→ 进入发布成功页(若是寻物且库里有同类招领,这里会出现一个可点击的「先去看看」入口)
悬赏与打赏 发布寻物信息时可填「悬赏金额」(选填);点开别人的招领帖详情页可看到「🎁 打赏拾金不昧者」,点开弹层选档位或自填金额
搜索物品 点首页顶部搜索框或底部导航进搜索页,输入关键词后点「搜索」或回车;也可以点「热门:」后面的词直接搜
管理自己的发布 点底部导航右侧「我的」,卡片上可「标记为已找到 / 已归还」「撤销完结」「删除」,也可进详情页操作;页面底部有「恢复示例数据」(二次确认)
数据持久化 发布/完结/删除后按 F5 刷新,数据仍在(localStorage)

第三步:跑单元测试(可选,需要 Node.js)

页面运行不需要 Node.js;只有想复现单元测试时才需要:

npm install     # 首次运行,安装 mocha 与 chai(联网)
npm test        # 运行全部单元测试

预期输出 181 passing。详见第六节。


六、单元测试

6.1 测试工具与学习过程(附简易教程)

工具选型:Mocha + Chai。 理由有三条:

  1. 对原生 JS 友好。 我们的逻辑层是七个 IIFE 模块(constant / seed / reward / store / validate / search / status),本来就有「浏览器挂 window、Node 用 module.exports」的双导出写法。Mocha 直接 require 就能测,不需要 Babel、不需要构建,也不需要在测试里 eval 读文件。
  2. 学习成本低、结构直观。 describe / it 两层结构读起来就是「模块 → 用例」,expect(...).to.equal(...) 的断言链比 assert 可读性更好。
  3. npm test 一条命令跑完,和作业要求一致,结果里直接给 181 passing。

学习过程。 我先照着课程附录里提到的廖雪峰的 JS 教程把 describe / it / 断言的基本写法过了一遍,再读《Mocha 实例教程》确认了「怎么组织测试文件、怎么让 npm test 找到它们」,然后才开始设计用例。真正花时间的不是框架语法,而是想清楚每个函数该测哪些分支——也就是 6.3 那一段。

简易教程(我们自己用到的最小集):

npm init -y                      # 1. 生成 package.json
npm i -D mocha chai              # 2. 装开发依赖(-D:只用于开发,页面运行不需要)
// 3. package.json 里把测试入口固定下来,之后统一用 npm test
{ "scripts": { "test": "mocha test/*.test.js" } }
// 4. test/xxx.test.js —— 用 describe 分组、it 写用例、expect 断言
const { expect } = require('chai');
const C = require('../js/constant.js');

describe('Constant 常量契约', function () {
  it('8 个分类、8 个地点,均无重复', function () {
    expect(C.CATEGORY).to.have.lengthOf(8);
    expect(new Set(C.CATEGORY).size).to.equal(8);
  });
});
npm test                         # 5. 跑,看到一串 ✓ 和 passing 即通过

6.2 测试代码、被测函数与运行结果

测试代码在仓库的 test/ 目录下,共 7 个用例文件、181 个用例,全部通过:

测试文件 用例数 被测对象 / 关注点
test/constant.test.js 15 constant.js 常量契约:枚举齐全、8 分类 8 地点、长度上限与页面 maxlength 一致
test/seed.test.js 22 seed.js 示例数据:15 个字段结构、分类 / 地点覆盖度、本人与他人条数、closedAt 与状态自洽
test/store.test.js 44 ItemStore 九个方法:正常路径 + 兜底,以及 localStorage 落盘 / 损坏自愈 / 写失败降级
test/validate.test.js 21 Validate.validateItem:必填、长度边界、提示文案随类型切换、非法入参兜底
test/search.test.js 47 Search.filterItems:四字段匹配、「不搜什么」、组合交集、三种排序与非法值回落
test/status.test.js 5 Status 状态文案:进行中 / 已找到 / 已归还,非法输入返回约定值
test/reward.test.js 27 Reward:悬赏 / 打赏的金额边界、脏数据兜底、累计值派生、canTip 入口判定

关键前提:测的就是页面跑的那份代码。 因为七个模块都写成「浏览器挂 window / Node 用 module.exports」,测试里 require('../js/store.js') 拿到的是同一份实现,而不是为测试另抄一遍。store.js 还主动做了降级:Node 里没有 localStorage 时自动换成进程内的内存替身,所以测试不需要 mock DOM、不需要 jsdom、也不需要额外的配置文件。

下面挑三个最能说明问题的片段。

① 用测试把「不搜什么」钉死(test/search.test.js)

关键词搜索实现里,参与匹配的字段是写死在 search.js 里的四个:

// js/search.js
const KEYWORD_FIELDS = ['name', 'category', 'place', 'description'];

「联系方式不参与搜索」是一条容易被后来者顺手改掉的设计约定(毕竟 contact 也在数据里),所以我们专门为它写了一条用例,谁把它加进 KEYWORD_FIELDS,测试立刻红:

it('不搜联系方式(隐私字段不参与匹配)', function () {
  expect(Search.filterItems(ITEMS, { keyword: 'wx-secret' })).to.deep.equal([]);
});

it('不搜 type / status 这类内部枚举值', function () {
  expect(Search.filterItems(ITEMS, { keyword: 'lost' })).to.deep.equal([]);
});

这两条用的是一小段自造夹具(3 条数据,每条只在一个字段上有独特关键词),而不是直接用 24 条示例数据——这样「命中」和「不命中」都来自我们已知的字段,不会因为示例数据里恰好出现某个词而「碰巧通过」。集成验证则另开一组用例,拿真实 24 条数据核对条数(如「关键词『图书馆』命中 4 条,而按地点筛选只有 3 条」,恰好证明描述字段也参与了匹配)。

② 测试真实的 localStorage 路径,包括它坏掉的样子(test/store.test.js)

store.js 里有一段开箱即用的持久化逻辑,光靠「换台机器试试」是测不全的(隐私模式、配额写满、数据被别的东西写坏,都不是随手能造的场景)。所以我们往 globalThis 上挂一个可控的假 localStorage,删掉 require 缓存后重新装载 store.js,就能精确构造这些场景:

function withFakeStorage(fakeStorage, fn) {
  globalThis.localStorage = fakeStorage;
  try { return fn(freshStore()); }
  finally { delete globalThis.localStorage; MODULE_FILES.forEach(clearCache); }
}
it('有可用的 localStorage 时,三个键都真的落盘,内容是合法 JSON', function () {
  withFakeStorage(makeFakeStorage(), function (store) {
    store.getAll();                       // 触发首次灌入
    const raw = store.save(validForm({ name: '落盘测试' }));
    const keys = Object.keys(globalThis.localStorage.map);
    expect(keys).to.include(C.STORAGE_KEY);
    expect(keys).to.include(C.OWNER_KEY);
    expect(keys).to.include(C.SEED_FLAG_KEY);
    expect(JSON.parse(globalThis.localStorage.getItem(C.STORAGE_KEY))).to.have.lengthOf(25);
  });
});

it('localStorage 里的数据损坏(非法 JSON)时自愈:重新灌入示例数据,不白屏', function () {
  const fake = makeFakeStorage();
  fake.map[C.STORAGE_KEY] = '{ 这不是合法 JSON';
  withFakeStorage(fake, function (store) {
    expect(store.getAll()).to.have.lengthOf(24);
    expect(JSON.parse(globalThis.localStorage.getItem(C.STORAGE_KEY))).to.have.lengthOf(24);
  });
});

it('localStorage 写入抛异常(配额满 / 隐私模式)时降级到内存,不抛异常', function () {
  const throwing = { getItem: () => null, setItem: () => { throw new Error('QuotaExceededError'); }, removeItem: () => {} };
  withFakeStorage(throwing, function (store) {
    expect(function () { store.getAll(); }).to.not.throw();
    expect(store.save(validForm()).name).to.equal('学生证');
  });
});

这三条对应的都是用户真实会遇到、但我们手动点不出来的情况。

③ 长度边界用「刚好通过 + 超一个字」成对写(test/validate.test.js)

表单校验最容易出的错是「边界差一」(> 写成 >=)。所以凡是长度限制,一律成对写:上限值必须通过、上限 + 1 必须报错,并且错误提示里要带上真实字数:

it('物品名称:必填;30 字通过,31 字报错', function () {
  expect(Validate.validateItem(validForm({ name: repeat('字', C.LIMITS.name) })).valid).to.equal(true);

  const tooLong = Validate.validateItem(validForm({ name: repeat('字', C.LIMITS.name + 1) }));
  expect(tooLong.errors).to.have.property('name');
  expect(tooLong.errors.name).to.contain(String(C.LIMITS.name + 1));  // 提示里要带实际字数
});

④(附带亮点)拿测试守住两份文件之间的一致性(test/constant.test.js)

constant.js 里的 LIMITS 和 index.html 上 input 的 maxlength 是两处独立维护的数字,很容易改一边忘一边。与其靠人记得,不如让测试去读 index.html 来核对:

it('长度上限与 index.html 的 maxlength 一致(防止两处各自漂移)', function () {
  const html = fs.readFileSync(path.join(JS_DIR, '..', 'index.html'), 'utf8');
  const maxlengths = Array.from(html.matchAll(/maxlength="(\d+)"/g)).map(function (m) { return Number(m[1]); });
  expect(maxlengths).to.include(C.LIMITS.name);
  expect(maxlengths).to.include(C.LIMITS.description);
});

运行结果:npm test 输出 181 passing (134ms),无失败、无跳过(毫秒数每次运行略有浮动)。下面节选一段实际输出:

  ItemStore 持久化介质
    √ Node 环境(没有 localStorage)自动降级为内存 shim,读写照常
    √ 有可用的 localStorage 时,三个键都真的落盘,内容是合法 JSON
    √ localStorage 里的数据损坏(非法 JSON)时自愈:重新灌入示例数据,不白屏
    √ localStorage 写入抛异常(配额满 / 隐私模式)时降级到内存,不抛异常

  Search 关键词:只搜名称 / 分类 / 地点 / 描述四个字段
    √ 命中描述(说明不只搜名称)
    √ 忽略大小写
    √ 不搜联系方式(隐私字段不参与匹配)

  181 passing (134ms)

14_单元测试_npm_test

上图说明:npm test 的真实输出(全量 236 行)渲染成的终端画面,出图脚本 _verify/shoot_npm_test.py
(脚本直接跑 npm test 取输出,不改写任何一行,只做终端配色)。为控制图高,图中省略了中间 188 行,并在省略处画出了明确的省略标记;开头的命令与结尾的 181 passing 均为原样。脚本默认截断,加 --full 可渲染全量 236 行。
复现方式:在仓库根目录执行 npm install 后 npm test(页面运行本身不需要 Node.js)。

6.3 测试数据构造思路与「刁难」考虑

测试用例按白盒思路设计:先把每个函数的条件分支列出来,再给每个分支配数据。具体分五类:

类别 做法 例子
正常路径 合法输入应得到预期结果 合法表单 valid === true;关键词「校园卡」命中 2 条
边界值 上限 / 下限刚好取到与差一 名称 30 字通过、31 字报错;联系方式 2 字报错、3 字通过
空值与缺省 不传、传空串、传空白串 filterItems(undefined) 返回 [];名称「三空格」按空处理
异常输入 传错类型、传不存在的 id save(null) 返回 null;update('不存在的 id') 返回 null;getStatusText(null) 返回 ''
组合与顺序 多条件同时生效、排序单调性 「招领 + 已完结 = 6 条」;createdAt 严格递减

为什么要专门「刁难」异常输入。 因为页面层会把这些函数用在数据已经不可靠的时刻:详情页可能拿一条已经被删掉的记录去取文案,localStorage 里的内容可能被别的东西写坏,搜索框里可能传进 undefined。这些路径一旦抛异常,表现就是整页白屏。所以我们给七个模块的入口都写了兜底(Array.isArray、data && typeof data === 'object'、|| ''),并为每一条兜底都补了一条用例——store.test.js 的 44 条用例里,有一批专门盯这类「非法输入或存储异常时不抛异常」的场景:传入 undefined/null、查不存在的 id、changes 传非法值、JSON 损坏、setItem 抛异常、存储探测不通过……每一条都断言「不抛异常且返回约定值」。reward.test.js 也一样:打赏记录里混进 null、amount 被改成字符串,都要求不抛异常、且合计不会变成 NaN。

哪些没测,以及为什么。 页面层(js/app.js 与 DOM 渲染)没有写单元测试:它依赖真实 DOM、焦点、剪贴板权限和 localStorage,用 jsdom 模拟的投入产出比不高,而且很多问题(比如第四章 4.5 提到的复制降级 bug)恰恰只在真实浏览器里才暴露。这一层我们的做法是手动走查 + 一个自研的浏览器端到端脚本(headless Chrome 驱动真实点击,覆盖 88 项断言)。所以整体策略是:逻辑层用自动化测试锁死,交互层用真实浏览器走查——各用各的长处。

一个真实的收益。 把示例数据的期望条数(24 条 / 本人 6 条 / 寻物 14 条 / 招领 10 条 / 已完结 8 条)写进断言之后,seed.js 的口径就被固定住了:以后任何人改动示例数据,测试会立刻失败并指出是哪一项对不上,而不是等演示时才发现某个筛选下拉点开是空的。这条约束反过来也让第四章的附加功能演示变得可靠——每次演示前跑一次 npm test,就知道数据是否还满足演示前提。


七、GitHub 代码签入记录(1 分)

下图由脚本(_verify/draw_diagrams.py)直接调用 git log 取真实数据渲染而成,不是手工整理:

图5_GitHub签入记录

签入记录说明:

  1. 共 46 次提交(含 5 次合并提交),甲 26 次、乙 20 次;提交信息统一用「类型 + 描述」的格式:feat: 新功能、fix: 修缺陷、test: 测试、docs: 文档、chore: 杂项。
  2. 每个 commit 只装一个功能点,message 能对上代码:例如 f2a00c7 feat: search.js 关键词扩展到名称/分类/地点/描述四字段 对应的就是 search.js 里 KEYWORD_FIELDS 从 1 个字段扩到 4 个字段那一次改动。
  3. 单元测试是独立提交的(b038d0d test: 单元测试纳入仓库(Mocha + Chai,6 个文件 119 个用例)),不是和功能代码混在一起。
  4. 合并走 Pull Request:乙 fork 主仓库后在分支上开发,通过 PR #1、PR #3、PR #4 合并进 main(记录里能看到 f54640a Merge pull request #1 from diahull/main、636d3cd Merge pull request #3 from diahull/main、d82498e Merge pull request #4 from diahull/main),符合作业 2.1「一人建仓、一人 fork + pull request」的形式要求。
  5. 如实说明(乙这边走过一段弯路):乙最早有两次提交是通过 GitHub 网页上传完成的(Add files via upload、Rename app.js to js/app.js),之后又有一批以补充注释为主的提交(style: / refactor: / docs:,合并后的净改动只有几行)。这批记录不够规范——提交次数够了,但「message 与实质改动」是虚的。发现这个问题后,我们按第七节末尾的顺序把第四章 4.5 里那四个真实待改进项逐个做成了独立提交(fix: / feat:,每个都能在代码里指出对应改动),这批记录才立得住。
  6. 提交数与内容对不上时,宁可少而实。 这一点也写进了第十节的小结。

复现方式:在仓库根目录执行 git log --pretty=format:"%h|%an|%ad|%s" --date=format:"%m-%d %H:%M",输出的就是上图的数据源。


八、遇到的代码模块异常或结对困难及解决方法(4 分)

按「问题描述 → 做过哪些尝试 → 是否解决 → 有何收获」记录四个真实问题。

问题 1:模块名撞上浏览器内置属性,模块会「凭空消失」

  • 问题描述:往届学长提醒过「页面渲染不出来,但控制台没有报错」——排查后发现根因是模块挂在了 window 的保留属性上,赋值被浏览器悄悄改掉了。

  • 做过哪些尝试:我们先在无头 Chrome 里做了一组实测,把候选名字一个个挂在 window 上再读回来:

    尝试的名字 结果
    window.status ✗ 内置 DOMString 属性,window.status = {…} 被强制转成 "[object Object]",模块直接消失
    window.name ✗ 同上(内置字符串)
    window.Storage ✗ 赋值前就已存在(构造函数)——这正是往届「模块是否就绪」判断永远为真、列表整个渲染不出来的根因
    window.Constant / Seed / ItemStore / Validate / Search / Status ✓ 全部可用
  • 是否解决:已解决,并从设计层面定成纪律:模块全局名一律大写开头;存取模块刻意避开 Storage,取名 ItemStore;页面层判断「模块是否就绪」时检测具体方法(typeof ItemStore.getAll === 'function')而不是判断对象真假。

  • 有何收获:「不报错」不等于「正常」。这类冲突是静默失败,如果等到页面白屏了再去猜,排查成本极高。花 20 分钟先做一次环境探测,比事后 debug 两小时划算得多——这也是我们后来坚持「凡是不确定的行为,先写个最小页面实测一遍」的起点。

问题 2:中文日期不能直接用来排序

  • 问题描述:做「最近丢失」排序时,排序结果明显不对——'9月6日' 被排在了 '10月8日' 后面。原因是排序直接用了展示用的中文时间文本,而字符串比较是按字典序的(首字符 '9' > '1')。
  • 做过哪些尝试:① 写一个中文日期解析函数,把「9月6日 下午」解析成时间戳——但用户输入是自由文本(「上周三」「国庆期间」都是合理输入),解析逻辑永远覆盖不全;② 强制用户从下拉框里选日期——输入体验变差,而且「9月6日」这种粒度反而更符合失物招领的语境;③ 把「展示」和「排序」拆成两个字段。
  • 是否解决:已解决。数据里同时存 lostTime(中文文本,只展示)和 lostAt(时间戳,只排序),排序时 lostAt 缺失自动回落到 createdAt。示例数据的 lostAt 按「丢失时间比发布时间早多久」真实反推,所以「最近丢失」和「最新发布」排出来的顺序确实不同(可验证)。
  • 有何收获:展示与计算要分开存储。同一个信息在「给人看」和「给机器算」两种用途下,正确的表示形式往往不同;强行用一个字段兼顾,一定会在某一边出错。另外「新发布的记录 lostAt 只能用当前时刻近似」这个问题,当时我们选择如实写进 4.5 而不是掩盖;后来也真的把它修掉了——时间字段换成 <input type="datetime-local">,lostAt 直接取用户选的时刻(见 4.5 问题 3)。

问题 3:第一次跑 npm test,挂了 1 条——错的是测试自己

  • 问题描述:当时 119 个用例第一次跑,在 search.test.js 上挂了 1 条。断言写的是「命中的 id 应该等于 ['a','c']」,实际拿到的是 ['c','a']。(后来用例扩展到 181 个,这条断言也一直保留着。)
  • 做过哪些尝试:先怀疑排序函数写错了,把 sortItems 单独拉出来跑——发现默认排序是「最新发布」(按 createdAt 倒序),所以 c 在前本来就是正确行为;错的是我把「集合相等」写成了「顺序相等」。
  • 是否解决:已解决。改用 expect(ids).to.have.members(['a','c'])(只校验集合成员、不校验顺序);真正需要校验顺序的用例(如「createdAt 严格递减」)则在名字里写明「按时间倒序」,并单独构造时间戳明确的数据。
  • 有何收获:测试失败时,先确认验收判据本身是对的。这次的判据错在「默认排序是什么」这个隐含前提上。后来我们在写用例时多了一步:把「这条用例到底在验证什么」写成 it 的完整句子(例如「不传 sort 时按 createdAt 倒序」),判据模糊时一眼就能看出来。

问题 4:结对协作中的 Git 流程——从「直接 push」到「必须走 PR」

  • 问题描述:作业 2.1 要求「一位同学建仓库,另一位 fork 该项目,有进展时 pull request」。一开始两个人都在主仓库的 main 上直接开发,形式上不满足要求;而且双方同时改 index.html 时会产生冲突。
  • 做过哪些尝试:① 甲先用 GitHub 公开 API 确认 main 的分支保护状态(GET /repos/.../branches/main 返回 "protected": true),确认直推会被拒;② 建立「甲在 main 上推逻辑层 → 乙 fork 后在自己的分支上开发 → 发 Pull Request → 甲 review 后 Merge」的流程;③ 把唯一的公共文件 constant.js 冻结:接口约定好之后双方都不再改它,其他文件各自独立,从根上消除冲突。
  • 是否解决:已解决。乙的页面层通过 PR #1 合并进主仓库,此后双方各自推各自的文件,全程零冲突。
  • 有何收获:把「共享状态」压到最小,是两人协作最有效的解药。两个人共用一个文件就必然要频繁沟通,而我们只保留了 constant.js 这一个耦合点,其余各自独立——这比「冲突了再想办法解」要省事得多。另外,分支保护虽然一开始让人「推不上去有点烦」,但它实际上强制了 review 环节,是好事。

九、评价队友(2 分)

(颜俊宇)评价(林世颖):

  • 值得学习的地方:页面还原度高,样式体系搭得很完整——主题色、卡片阴影、标签配色、空态插画感都是他一个人定的,最后「美观性」这一项基本不需要改。他会从使用者视角提问:我在写逻辑层接口时,他提出「搜索页和首页筛选能不能用同一套规则」「发布页的时间输入框不好填」这类问题,直接影响了后来的接口设计。另外走查很细,第四章 4.5 里那个复制降级的问题就是他先注意到「toast 弹的是失败」,我才去追了代码。
  • 需要改进的地方:开发经验不足(虽然本人也是一样hh),有时候commit不太符合规范,但总体态度良好,需一起加油哈_

十、小结

做成了什么

我们完成了作业正文要求的完整闭环——发布信息 → 浏览 / 搜索 → 查看详情 → 联系发布者 → 更新状态,做得比最低要求多一些:8 项附加特点、181 个单元测试用例、24 条可直接演示的示例数据,以及一套「下载后双击 index.html 就能用」的零依赖交付物。

技术上最想留下的一句话

逻辑层与页面层分离 + 模块双导出,这两件事加起来让「单元测试测的就是线上跑的那份代码」成为可能。这不是测试技巧,而是架构决定的:如果逻辑层和界面揉在一起,测试就只能靠 DOM 模拟,覆盖率和可信度都会打折。

过程中的真实教训

  1. 凡是不确定的行为,先写个最小页面实测——file:// 下 localStorage / clipboard / crypto.randomUUID 到底能不能用,实测 20 分钟得到的结论,直接决定了技术选型能不能成立(见第八节问题 1)。
  2. 特性检测只能说明 API 存在,不能说明调用会成功——降级路径要挂 catch,不要挂 if(见 4.5 问题 1)。
  3. 测试失败时先确认判据——那次挂掉的用例错在测试自己(见第八节问题 3)。
  4. 共享文件越少,两人协作越顺——我们全程只保留 constant.js 一个耦合点,零冲突(见第八节问题 4)。

遗留的不足(如实列出)

# 不足 影响 目前状态
1 copyContact 的降级路径挂在 else 而不是 catch(4.5 问题 1) 剪贴板 API 存在但调用失败时不会降级 ✅ 已修复:降级改挂 .catch(legacyCopy)
2 相似物品提示是无出口的 toast,#similar-tip 是死元素(4.5 问题 2) 提示不可点击,用户想去看看得自己找 ✅ 已修复:发布成功页给出可点击入口
3 新发布信息的 lostAt 用当前时刻近似(4.5 问题 3) 「最近丢失」对新数据退化成「最新发布」 ✅ 已修复:时间字段改 <input type="datetime-local">
4 E7「一键重置」只有 ItemStore.reset(),界面上没有入口 演示时要开控制台 ✅ 已修复:「我的」页加「恢复示例数据」(二次确认)
5 数据存在浏览器本地,不同浏览器 / 设备不互通 换设备看不到自己的发布 作业已豁免后端,属刻意简化;若要扩展可接云端存储
6 乙早期的提交以注释 / 网页上传为主,粒度偏粗(见第七节第 5 条) 签入记录规范性有瑕疵(后期已补 4 个实质提交,但早期那几条留在历史里) 下次从一开始就按功能拆分提交,提交数宁少勿虚
7 页面层没有单元测试,靠端到端脚本 + 手动走查覆盖 交互层的回归依赖脚本执行 可考虑引入 Playwright 做真实浏览器回归

写完博客初稿、又补完悬赏打赏那批功能之后,我们把上面第 1~4 条实际修掉了(改法与验证见 4.5,端到端断言也从 78 项加到 88 项)。所以现在这一表里剩下的只有第 5~7 条。

最后

这份作业最大的收获不是「写出了一个能跑的网页」,而是第一次完整体验了「先约定接口 → 两人并行 → 各自测试 → 合并交付」这套流程。尤其是「把不确定的事先验证掉」和「把共享状态压到最小」这两条经验,比具体写了多少行代码更有价值。


附:本地辅助工具目录(不上传仓库)

目录 / 文件 用途
_localtest/check_constant.js constant.js 契约自检(42 个导出键 + 55 条断言)
_localtest/smoke_logic.js 逻辑层四条验收流程冒烟(136 条断言)
_verify/verify_app.py 端到端自检:headless Chrome 真实点击(88 项断言)
_verify/shoot_app.py 各视图截图(第一章 / 第五章配图)
_verify/shoot_extras.py 附加特点截图(第四章配图,含悬赏与打赏)
_verify/shoot_npm_test.py npm test 输出截图(第六章配图)
_verify/shoot_reward.py 悬赏 / 打赏专项截图(第四章配图备选)
_verify/draw_diagrams.py 架构图 / 流程图 / 签入记录图(第三章、第七章配图)

仓库里不含以上任何文件——作业要求「下载所有文件后双击 index.html 就能展现预期结果」,这些是开发期的工具,不影响运行。

posted @ 2026-10-10 20:21  JunYu_101  阅读(6)  评论(0)    收藏  举报