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

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

项目 内容
这个作业属于哪个课程 H202601 软件工程与软件工程实践(福州大学·计算机与大数据学院)
这个作业要求在哪里 2026 秋软件工程结对作业(第二次之程序实现))
结对同学博客 颜俊宇
本作业博客 林世颖
结对成员 学号 102401114(颜俊宇)、学号 102401126(林世颖)
项目仓库 https://github.com/1cecream1/102401114-102401126
呈现形式 WEB 网页(纯前端,用谷歌浏览器直接打开 index.html 即可运行)

一、具体分工

成员 主要负责 具体产出
颜俊宇(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 计划
Estimate 估计这个任务需要多少时间 20 15
Development 开发
Analysis 需求分析(包括学习新技术) 100 80
Design Spec 生成设计文档 60 35
Design Review 设计复审 40 25
Coding Standard 代码规范(为目前的开发制定合适的规范) 20 15
Design 具体设计 60 40
Coding 具体编码 300 260
Code Review 代码复审 50 35
Test 测试(自我测试,修改代码,提交修改) 140 90
Reporting 报告
Test Report 测试报告 50 25
Size Measurement 计算工作量 15 10
Postmortem & Process Improvement Plan 事后总结,并提出过程改进计划 30 20
合计 785 650

实际与预估的差异说明:

  1. Analysis 比预估少(100→80):接口约定好之后,页面层的需求边界其实很清楚,不需要像甲那样花时间验证 file:// 环境下 localStorage 和 clipboard 能不能用,那部分主要是甲在做。
  2. Coding 比预估少(300→260):页面层虽然要处理六个视图,但甲给的接口文档写得很细,字段名和返回结构都固定了,照着写就行;真正花时间的反而是样式微调(底部加号居中、搜索框留白、卡片间距这些),这些零碎活儿加起来大概 40 分钟。
  3. Test 比预估少(140→90):页面层不写单元测试,主要靠浏览器里手动点一遍和甲写的端到端脚本,走查两个下午就差不多了。
  4. 合计 650 分钟,比预估 785 少了约 17%,主要省在不需要自己写测试和验证底层环境上。但前期接口对齐那一步如果省掉,后面返工的时间会更多——这是这份 PSP 里最值的一笔投入。

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

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;联系方式默认显示 ●●●●●●●●,点「查看联系方式」才明文显示,再点「复制联系方式」直接拿走。
  6. 发布者本人在「我的发布」里点「标记为已找到」,update(id, { status: 'done' });closedAt 由 update 自动补上,卡片和详情页的状态标签同步变成「已找到」。

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

① 模块「双导出」——决定单元测试能不能与线上同源。

七个模块统一写成:

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

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

② 命名铁律:window 上不能挂小写的通用名。

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

③ 时间要存两份:给人看的 lostTime 和给排序用的 lostAt。

一开始想直接用表单里那个中文时间(如「9月25日 下午」)排序,很快发现是错的——按字典序 '9月6日' 大于 '10月8日',但实际前者更早。所以数据里拆成两个字段:lostTime(中文文本,只负责展示)与 lostAt(时间戳,只负责排序),排序时若 lostAt 缺失则回落到 createdAt。

顺带说明:发布表单最初用的是自由文本时间,新数据只能拿「此刻」近似 lostAt,「最近丢失」因此退化。后来把该字段换成了 ,展示文本与排序时间戳由同一个输入派生,两个字段天然一致——见 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)

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. 降级是「行为一致、只是不持久化」,不是「功能消失」。内存替身的接口与 localStorage 完全一致,所以上层的 ItemStore 一行都不用改。这也正是单元测试能在 Node 里直接跑的原因——Node 里没有 localStorage,于是自动走了这条降级路径。

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

function validateItem(data) {
  const errors = {};
  const d = (data && typeof data === 'object') ? data : {};
  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 字';
  return { valid: Object.keys(errors).length === 0, errors: errors };
}

为什么这段值得贴:

  1. 校验函数不知道 DOM 的存在。它只返回一个大对象,页面层拿着 errors.name / errors.contact 各找各的提示位去渲染。所以「一次提交把所有错都标出来」是天然的。
  2. 提示文案是动态的,不是静态的。「请选择丢失地点 / 请选择拾获地点」直接从 Constant.getPostTab(type).placeLabel 取,所以寻物态与招领态共用同一份校验代码。
  3. 错误提示里带真实字数,这个细节后来被直接写成了单元测试的断言——测试会检查提示里是否包含 31 这个数字,防止有人把 > 顺手写成 >=。

片段三:详情页联系方式的「三态渲染」(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 不参与关键词匹配的原因。
  3. 所有插值的文本都过 escapeHtml()。用户输入的名称、描述、联系方式会原样插进 innerHTML,不转义就等于把 XSS 的门敞开。

四、附加特点设计与展示

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

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

4.1 设计的创意独到之处

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

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

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

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

失物招领的联系方式一旦明文挂在列表上,就等于把同学的微信号交给爬虫。所以详情页默认显示 ●●●●●●●●,点「查看联系方式」才明文显示(多一次明确的用户意图),再点「复制联系方式」直接拿走——省掉手动选字复制的每一步。

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

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

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

点「先去看看 ›」直接跳进那条招领帖的详情页。这条提示的价值在于把两条本该相遇的信息提前撞上,减少的是「重复发布 → 无人应答 → 反复刷新」的无效流程。

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

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

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

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

  • 悬赏挂在寻物帖上——失主在发布时说「找回后自愿兑现 ¥N」。它是一句承诺,纯展示、不落任何流水。
  • 打赏挂在招领帖上——失主看到别人拾金不昧,在详情页点「打赏拾金不昧者」,落一条本机记录,形成一面打赏墙。

这样切分的好处:语义不重叠,承诺归承诺、兑现归兑现;顺手挡掉「自己给自己打赏」的怪逻辑——所以 Reward.canTip(item, isOwner) 明确要求「招领帖 + 非本人」才给出入口。

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

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

4.2 实现思路

总原则:附加特点不允许破坏核心闭环。所以这 8 项里没有一项需要新增数据表或新增页面——它们要么是在已有结构上多挂一个筛选维度,要么是复用逻辑层已有的纯函数,要么是一个纯展示函数。这样做的直接好处是:附加特点的改动只落在 app.js、reward.js 与 constant.js 里,逻辑层的数据结构与原有接口一行都没动。

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

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

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

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

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

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

这里踩过一个坑:数据里有两个时间——lostTime(给用户看的中文)和 lostAt(时间戳)。一开始想直接拿 lostTime 排序,但中文日期按字典序比大小是错的('9月6日' 大于 '10月8日',而实际上前者更早)。所以展示与排序必须分成两个字段。

(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
});
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');
}

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

store.js 的 ensure() 在读不到数据时不只是返回空数组,而是灌入 24 条示例数据并落盘,并且把 JSON 解析失败也归到这条路径上。ItemStore.reset() 则给答辩演示用:一条命令恢复 24 条示例数据。

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

时间文案由 fmtTime(ts) 一个纯函数处理(<1 分钟 / <60 分钟 / <24 小时 / <30 天 / ≥30 天 五档);看板则是对 ItemStore.getAll() 的一次遍历,算出 4 个数字。

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

打赏的记录读写与统计被收敛进新模块 js/reward.js,页面层只做两件事:把用户选的金额交给纯函数算出一条记录,再交给 ItemStore.addTip() 落盘。金额判据只有一处(Constant.isValidAmount),累计值派生而非存储,弹层复用同一套模式。

4.3 重要代码片段

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

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 (keyword && !matchKeyword(item, keyword)) return false;
    return true;
  });
  return sortItems(hit, f.sort);
}

为什么这段值得贴:六个维度是同一条式子;isAll() 把「没选」的四种写法统一了;先 slice() 再排序,不改调用方的数组;非法输入一律兜底返回约定结果。

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

window.copyContact = function (text) {
  const ok = function () { toast(Constant.TEXT.copySuccess); };
  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);
  } else {
    legacyCopy();
  }
};

为什么这段值得贴:同一个能力有两条路径,主路径靠特性检测选;降级用的 textarea 必须脱离布局流、不可见但可选中;降级挂在 .catch 上而不是只挂在 else 上。

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

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;
}

为什么这段值得贴:不存冗余字段,派生值永远不会对不上;入口判定是一个显式函数而不是散在页面里的 if;脏数据兜底,amount 被改成字符串时记录仍计入条数但不计入金额。

4.4 实现成果展示

下面 9 张图由自检脚本 _verify/shoot_extras.py 一次性跑出来(headless Chrome 真实渲染 + 真实点击):

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

08_附加_组合筛选

② 排序切换:「最多浏览」——排在最前的是浏览量最高的蓝牙耳机(88 次)

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

③ 一键复制联系方式——点完弹出「联系方式已复制,去联系 TA 吧」

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

④ 相似物品匹配提示——发布成功页给出可点击的「先去看看 ›」

11_附加_相似物品提示

⑤ 搜索空态 + 热门词——搜「航空母舰」0 条结果,给出空态引导

12_附加_搜索空态

⑥ 信息新鲜度与过时提示——33 天前的信息直接提示可能已过时

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

⑦ 悬赏:只挂在寻物帖上——保温水杯详情页显示橙色悬赏行,没有打赏按钮

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

⑧ 打赏:只挂在别人的招领帖上——点按钮后弹层从底部升起

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

⑨ 「我的」页底部的「恢复示例数据」按钮——演示前不用再开控制台

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

4.5 自查发现的问题与修复

写这一节时我逐项点了自己实现的功能,抓到三个问题。它们在截图里看不出来,是"点下去"才暴露的。后来我们把这三条实际修掉了,每一条都补了对应的端到端断言。下面保留「当时是怎么发现的」,再写「最后怎么修的」。

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

  • 现象:用脚本自动点击「复制联系方式」时,toast 弹的是「复制失败」;而 window.isSecureContext 是 true、navigator.clipboard 也存在——特性检测通过,调用却失败了。
  • 原因:.catch(fail) 只弹一句"复制失败",真正能用的 execCommand 降级写在 else 分支里,只有 navigator.clipboard 不存在时才轮到它。
  • 修法:把降级抽成函数,if 的两个分支和 catch 都指向它(代码见 4.3 片段二)。
  • 状态:已修复。
  • 收获:特性检测只能说明"这个 API 存在",不能说明"这次调用会成功"。这个 bug 是靠"用脚本真的去点一下"发现的——人在浏览器里手点复现不出来。

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

  • 现象:文案写着「先去看看?」,但它是一条 1.8 秒后自动消失的 toast,点不了;index.html 里那个
  • 修法:把 #similar-tip 真正用起来,渲染在发布成功页,并用闭包绑 onclick(因为 openDetail 是 app.js 内部函数,没挂到 window,写 onclick="openDetail(...)" 会静默失效)。
  • 状态:已修复。
  • 收获:给出了建议,就要同时给出执行建议的入口。另外「内部函数不要通过 HTML 属性调用」这类错误不会报错,只会让点击悄无声息地失效。

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

  • 现象:示例数据的 lostAt 是反推的时间戳,排序有区分度;但新发布时时间字段是自由文本,app.js 只能拿当前时刻近似 data.lostAt = Date.now()。
  • 修法:把时间字段换成 ,lostAt = new Date(value).getTime(),lostTime 由该时间戳格式化成中文(凌晨/早上/上午/中午/下午/傍晚/晚上七档,和示例数据同一种措辞)。
  • 状态:已修复。
  • 收获:展示与计算要分开存储。新发布的记录如果不把时间戳和展示文本同步写,排序就会出现一个"只有对示例数据才成立"的假象。

五、目录说明与使用说明

5.1 目录是如何组织的

102401114-102401126/
├── index.html              # 唯一页面:六个视图 + 底部导航
├── css/
│   └── style.css           # 全局样式:主题色 #07C160、卡片体系、移动端自适应
├── js/
│   ├── constant.js         # 【契约文件】枚举与文案字典
│   ├── seed.js             # 24 条示例数据
│   ├── reward.js           # Reward:悬赏与打赏的纯函数
│   ├── store.js            # ItemStore:存取 + 发布者标识
│   ├── validate.js         # Validate:表单校验
│   ├── search.js           # Search:搜索 / 筛选 / 排序
│   ├── status.js           # Status:状态文案
│   └── app.js              # 页面层:视图路由、渲染、事件绑定
├── test/                   # 单元测试(7 个文件 181 个用例)
├── package.json
├── README.md
└── .gitignore

组织思路:按「层」分目录而不是按「页面」分文件;js/ 内部按依赖顺序排列(文件名本身就是加载顺序);契约文件单独一个 constant.js;测试放 test/,与 js/ 平级,require 的就是页面运行时同一份代码。

5.2 测试人员如何运行

第一步:运行网页

  1. 从 GitHub 仓库下载 ZIP;
  2. 用 Google Chrome 打开根目录下的 index.html(双击即可);
  3. 页面自带 24 条示例数据,打开就有内容。

不需要安装依赖、不需要启动服务器。页面按移动端宽度设计,建议把浏览器窗口调窄或用 F12 移动端模拟查看。

第二步:按这张表把每个功能点一遍

想看什么 怎么操作
浏览 / 筛选 / 排序 打开即首页;切类型 tab,用三个下拉按分类、地点、状态筛选,点排序栏切换
查看详情与联系方式 点任意卡片进详情页;点「查看联系方式」显示明文,再点「复制联系方式」
发布一条信息 点底部「+」→ 选类型 → 填名称、分类、地点、时间、联系方式 → 立即发布
悬赏与打赏 发布寻物时可填悬赏金额;点开别人的招领帖详情页可看到打赏按钮
搜索物品 点首页搜索框进搜索页,输入关键词回车;或点热门词直接搜
管理自己的发布 点底部「我的」,卡片上可标记已找到/已归还、撤销、删除;底部有「恢复示例数据」
数据持久化 发布/完结/删除后按 F5 刷新,数据仍在

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

npm install
npm test

预期输出 181 passing。


六、单元测试

6.1 测试工具与学习过程

工具选型:Mocha + Chai。理由:对原生 JS 友好(七个模块本来就有双导出写法)、学习成本低(describe/it 两层结构直观)、npm test 一条命令跑完。

简易教程:

npm init -y
npm i -D mocha chai
{ "scripts": { "test": "mocha test/*.test.js" } }
const { expect } = require('chai');
const C = require('../js/constant.js');
describe('Constant 常量契约', function () {
  it('8 个分类、8 个地点,均无重复', function () {
    expect(C.CATEGORY).to.have.lengthOf(8);
  });
});

6.2 测试代码与运行结果

测试在 test/ 目录下,7 个文件、181 个用例,全部通过:

测试文件 用例数 被测对象
constant.test.js 15 常量契约:枚举齐全、长度上限与 maxlength 一致
seed.test.js 22 示例数据:字段结构、分类覆盖、本人/他人条数
store.test.js 44 ItemStore:九个方法、localStorage 落盘/损坏自愈/降级
validate.test.js 21 表单校验:必填、长度边界、文案随类型切换
search.test.js 47 搜索筛选排序:四字段匹配、不搜隐私字段、组合交集
status.test.js 5 状态文案
reward.test.js 27 悬赏打赏:金额边界、脏数据兜底、累计值派生

关键前提:测的就是页面跑的那份代码。因为七个模块都写成双导出,测试里 require('../js/store.js') 拿到的是同一份实现,不需要 mock DOM。

运行结果:npm test 输出 181 passing (134ms),无失败、无跳过。

14_单元测试_npm_test

上图是 npm test 的真实输出(全量 236 行),为控制图高中间省略了 188 行,开头命令与结尾 181 passing 均为原样。

6.3 测试数据构造思路

测试用例按白盒思路设计:先把每个函数的条件分支列出来,再给每个分支配数据。分五类:正常路径、边界值(上限刚好通过 + 超一个字报错成对写)、空值与缺省、异常输入、组合与顺序。

为什么要专门刁难异常输入:页面层会把这些函数用在数据已经不可靠的时刻——详情页可能拿一条已经被删掉的记录去取文案,localStorage 里的内容可能被别的东西写坏。这些路径一旦抛异常,表现就是整页白屏。所以每个模块入口都写了兜底,每条兜底都补了一条用例。

页面层没有写单元测试:它依赖真实 DOM、焦点、剪贴板权限,用 jsdom 模拟投入产出比不高。这一层的做法是手动走查 + 一个自研的浏览器端到端脚本(headless Chrome 驱动真实点击,覆盖 88 项断言)。


七、GitHub 代码签入记录

下图由脚本直接调用 git log 取真实数据渲染:

图5_GitHub签入记录

签入记录说明:

  1. 共 46 次提交(含合并提交),甲 26 次、乙 20 次;提交信息统一用「类型 + 描述」格式。
  2. 每个 commit 只装一个功能点,message 能对上代码。
  3. 单元测试是独立提交的,不跟功能代码混在一起。
  4. 合并走 Pull Request:乙 fork 主仓库后在分支上开发,通过 PR #1、#3、#4 合并进 main,符合作业要求。
  5. 如实说明:乙最早有两次提交是通过 GitHub 网页上传完成的,之后又有一批以补充注释为主的提交。这批记录不够规范——提交次数够了,但 message 与实质改动是虚的。发现这个问题后,我们把 4.5 里那四个真实待改进项逐个做成了独立提交,这批记录才立得住。

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

问题 1:模块名撞上浏览器内置属性

  • 问题描述:页面渲染不出来,但控制台没有报错——模块挂在了 window 的保留属性上,赋值被浏览器悄悄改掉了。
  • 尝试:在无头 Chrome 里实测,window.status 被强制转成字符串、window.Storage 赋值前就已存在。
  • 解决:模块全局名一律大写开头,存取模块取名 ItemStore 避开 Storage;判断模块是否就绪时检测具体方法而不是判断对象真假。
  • 收获:不报错不等于正常。花 20 分钟先做环境探测,比事后 debug 两小时划算。

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

  • 问题描述:做「最近丢失」排序时,'9月6日' 被排在 '10月8日' 后面,因为字符串按字典序比较。
  • 尝试:写中文日期解析函数(覆盖不全)、强制下拉选择(体验差)、把展示和排序拆成两个字段。
  • 解决:数据里同时存 lostTime(中文文本,只展示)和 lostAt(时间戳,只排序)。
  • 收获:展示与计算要分开存储。新发布记录的时间戳和展示文本必须同步写。

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

  • 问题描述:119 个用例第一次跑,search.test.js 挂了 1 条,断言写的是「id 应该等于 ['a','c']」,实际拿到的是 ['c','a']。
  • 尝试:怀疑排序函数写错了,单独拉出来跑——发现默认排序是按 createdAt 倒序,c 在前本来就是正确行为,错的是把「集合相等」写成了「顺序相等」。
  • 解决:改用 expect(ids).to.have.members(['a','c'])。
  • 收获:测试失败时,先确认验收判据本身是对的。

问题 4:结对协作中的 Git 流程

  • 问题描述:一开始两个人都在主仓库 main 上直接开发,形式上不满足要求;双方同时改 index.html 时会产生冲突。
  • 尝试:建立「甲推逻辑层 → 乙 fork 后在自己分支开发 → 发 PR → 甲 review 后 merge」的流程;把唯一公共文件 constant.js 冻结。
  • 解决:全程零冲突。
  • 收获:把共享状态压到最小,是两人协作最有效的解药。

九、评价队友

林世颖评价颜俊宇:

  • 值得学习的地方:他写逻辑层之前先把接口文档列得很细,每个函数传什么参数、返回什么结构、非法输入给什么提示都提前定好了,我照着写页面层基本没猜过,遇到问题翻文档就能找到答案。有个地方我一开始不太理解——他坚持页面层不许直接碰 localStorage,所有读写都要走他写的 ItemStore。当时觉得多绕了一层,后来才明白正是这条纪律,他那 181 个单元测试才能在 Node 里直接跑,不用模拟浏览器环境。另外他中途主动加了悬赏打赏功能,我走查的时候基本没挑出什么毛病,弹层交互和错误处理都写得很完整。
  • 需要改进的地方:接口变更的同步。他中途把关键词搜索从只匹配名称扩到了名称、分类、地点、描述四个字段,又把发布时间改成了必填项,这些都写进了文档,但没有第一时间跟我说。我是后来自己重新看文档才发现的,那天对着搜出来的结果对了半天,还以为是自己代码写错了。后来约好每次改完接口在群里说一声,联调才顺起来。

十、小结

做成了什么:

我们完成了作业要求的完整闭环——发布信息、浏览和搜索、查看详情、联系发布者、更新状态。在这个基础上多做了 8 项附加功能:组合筛选、一键复制联系方式、三种排序、相似物品提示、首页数据看板、新鲜度提示、示例数据一键重置,还有悬赏打赏。测试方面写了 181 个单元测试用例,覆盖逻辑层所有模块。交付物就是一个下载下来双击 index.html 就能用的网页,不需要装任何东西。

过程里印象最深的几件事:

一是"先约定再写代码"比我想象中重要得多。我们一开始花了一个晚上对接口,把每个函数叫什么、参数叫什么都定下来,之后各写各的,几乎没返工。如果当时直接开始写,两个人各写各的,后面联调肯定要重写一大半。

二是有些问题手动点是发现不了的。比如复制联系方式那个 bug,我在浏览器里点十次都是成功的,但他写的自动化脚本一点就复现了——因为无头浏览器里文档没有焦点,剪贴板 API 会调用失败,降级路径又没挂对地方。这种问题靠人工走查真的很难碰到。

三是结对的时候,自己写的代码自己看不出问题。我一开始觉得发布成功后停在那个页面挺正常的,他走查的时候直接问"我发完想看看这条信息长什么样怎么办",我才意识到缺了个"查看详情"的出口。这种互相检查一个人是做不到的。

遗留不足:

# 不足 状态
1 copyContact 降级路径挂错位置 已修复
2 相似提示是无出口的 toast 已修复
3 新发布记录 lostAt 用当前时刻近似 已修复
4 一键重置没有界面入口 已修复
5 数据存在浏览器本地,不同设备不互通 作业豁免后端,刻意简化
6 早期提交粒度偏粗,有几次是网页上传和补注释 后期已补实质提交
7 页面层没有单元测试,靠端到端脚本加手动走查 时间有限,后续可补

这份作业最大的收获不是写出了一个能跑的网页,而是第一次完整走了一遍"先约定接口、两个人并行开发、各自测试、最后合并交付"的流程。以前写代码都是自己一个人从头写到尾,这次才体会到把共享部分压到最小、把接口提前冻结,真的能省很多沟通成本。下次做项目应该从一开始就按这个思路来。

posted @ 2026-10-10 20:47  diahull  阅读(3)  评论(0)    收藏  举报