2026 秋软件工程 · 第二次结对作业:校园失物招领程序实现

结对同学博客:孟维涛 | 王宇航
本作业博客:https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering/homework/16744
GitHub 项目地址:https://github.com/Meng-tao123/102401404-102401418
班级群结对表已填写:是


一、具体分工

同学 负责内容
孟维涛(102401404) 业务逻辑层 core.js(校验、搜索、状态变更、统计)、单元测试用例设计、博客「解题思路」与「单元测试」两节
王宇航(102401418) 表现层 6 个页面与 ui.js、样式 base.css / pages.css、仓库层 repo.js、README 与目录说明
共同完成 需求梳理与功能划分、三层结构的接口约定、代码复审、浏览器端走查与回归

具体到每一次提交的分工,见文末的签入计划;仓库按功能点提交,每完成一个功能 commit 一次。


二、PSP 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟) 说明
Planning 计划 — —
Estimate 估计这个任务需要多少时间 15 20 先把功能拆成 6 个页面 + 3 个模块再估
Development 开发 — —
Analysis 需求分析(包括学习新技术) 60 90 重读了第一次作业的原型与老师点评,重新圈定范围
Design Spec 生成设计文档 40 35 三层结构与数据结构的约定
Design Review 设计复审 25 30 两人讨论「业务逻辑要不要碰 DOM」
Coding Standard 代码规范 20 20 统一 2 空格缩进、模块名与函数命名、文件头注释
Design 具体设计 60 75 页面结构、字段与状态机
Coding 具体编码 300 420 6 个页面 + 3 个 JS 模块
Code Review 代码复审 40 60 复审时发现首页卡片没绑跳转的 bug
Test 测试(自我测试,修改代码,提交修改) 90 150 51 个单元测试 + 浏览器端全流程走查
Reporting 报告 — —
Test Report 测试报告 40 45 见第六节
Size Measurement 计算工作量 15 10
Postmortem & Process Improvement Plan 事后总结,并提出过程改进计划 40 60 见第七节
合计 745 1015 实际是预估的 1.36 倍

关于估偏的部分:编码超了 120 分钟,主要集中在发布页的照片压缩和详情页的操作栏状态联动;测试超了 60 分钟,原因是第一次写测试框架时断言失败的提示信息太笼统,定位问题很慢,后来给 assert 加上了「期望值 / 实际值」的输出才快起来;分析环节超了 30 分钟,是因为我们把第一次作业里画的「消息 / 留言」功能又拿出来讨论了一遍才决定砍掉。这些偏差让我们意识到:看上去最熟的编码环节反而最容易低估,因为接口联调的问题都是写的时候才冒出来的。


三、解题思路与设计实现

3.1 先定范围:从原型到可运行的程序

第一次作业的原型里有 9 个页面,其中「消息」承担的是留言沟通。老师点评里已经指出:本次不要求复杂后台、实名认证、即时聊天和地图定位。所以我们在动手前做了一次减法:

原型里的设计 本次如何处理 原因
首页 / 搜索 / 搜索结果 / 搜索无结果 / 详情 / 发布 / 发布成功 保留 对应作业要求的主流程
我的(我的发布) 保留但简化 「更新状态」必须有入口,统计只保留三个数字
消息 / 留言 删除 属于即时聊天范畴,不在本次范围内
「已实名」标记 删除 实名认证明确不要求

最终实现的页面是 6 个:index(首页)、search(搜索与结果)、detail(详情)、publish(发布)、success(发布成功)、mine(我的发布)。底部导航也相应从原型里的三个 Tab 变成了 首页 / 发布 / 我的——既然不做消息,就不该留一个点进去是空的入口。

3.2 三层结构:让业务逻辑能被测试

代码实现的核心思路是把「业务规则」和「浏览器」分开:

表现层(6 个 HTML)        只负责渲染和事件绑定
        ↓ 调用
业务逻辑层(core.js)      校验 / 搜索 / 状态变更 / 统计 —— 全是纯函数
        ↓ 调用
数据仓库层(repo.js)      持有信息列表,读写 localStorage
        ↓
存储层(localStorage)

这么分有两个直接好处:

  1. 单元测试不需要浏览器。 core.js 不碰 DOM、不碰 localStorage,所以 node test/run.js 能直接把它加载进来跑。
  2. 仓库层的存储介质是注入的。 createRepo({ storage: null }) 就退化成纯内存,这样 Node 里也能测仓库的增删改查;浏览器里传 window.localStorage 就是持久化的。

分层数据流图

3.4 关键流程

作业要求的闭环是「发布信息—浏览或搜索—查看详情—联系发布者—更新状态」。其中有两个地方最容易出错,我们单独画了流程:

发布信息:重点是校验失败的分支——不是弹一句「请填写完整」,而是把错误逐项定位到具体输入框。

image

更新状态:重点是「谁来改、从哪改、改完别人看到什么」。

image

关于「更新状态」这条链路的闭环:发布者在详情页或「我的发布」里点「标记为已找到 / 已归还」;入口有两个,详情页底部操作栏和我的发布列表的操作行;改完之后,这条信息在详情页的标签会从「未完成」变成「已归还」,首页和搜索结果里其他同学按「进行中」筛选时就不再看到它,我的发布的统计数字同时 +1。

3.5 关键代码片段

(1)表单校验:返回结构化的错误,而不是一个布尔值

function validateDraft(draft) {
  var d = draft || {};
  var errors = [];

  var title = trim(d.title);
  if (!title) errors.push({ field: 'title', message: '请填写物品名称' });
  else if (title.length < 2) errors.push({ field: 'title', message: '物品名称至少 2 个字' });
  else if (title.length > CONFIG.TITLE_MAX) {
    errors.push({ field: 'title', message: '物品名称不能超过 ' + CONFIG.TITLE_MAX + ' 个字' });
  }
  // …(类别、地点、日期、联系方式、照片数量同理)

  return { ok: errors.length === 0, errors: errors };
}

这里刻意不抛异常、不返回 true/false,而是返回 { ok, errors },每个错误带上 field。页面拿到之后就能把 data-err="title" 的提示文字填上、给对应的输入框加 .invalid 红框、并把焦点移到第一个出错的输入框。校验规则本身也因此变成了可以单独测试的纯数据。

(2)搜索筛选:一次遍历完成多维过滤

function searchItems(items, query) {
  var q = query || {};
  var kw = normalize(q.keyword);          // 小写 + 去掉所有空白
  var list = (items || []).filter(function (it) {
    if (q.type && q.type !== 'all' && it.type !== q.type) return false;
    if (q.category && q.category !== 'all' && it.category !== q.category) return false;
    if (q.place && q.place !== 'all' && it.place !== q.place) return false;
    if (q.status && q.status !== 'all' && it.status !== q.status) return false;
    if (kw) {
      var hay = normalize([it.title, it.desc, it.place,
                           categoryLabel(it.category), typeLabel(it.type)].join(' '));
      if (hay.indexOf(kw) === -1) return false;
    }
    return true;
  });
  return list.sort(function (a, b) {
    return q.sort === 'old' ? a.createdAt - b.createdAt : b.createdAt - a.createdAt;
  });
}

关键词会同时匹配物品名称、详细说明、地点、类别名、类型名五个字段,所以搜「图书馆」能找到地点在图书馆的信息,搜「耳机」也能命中描述里提到耳机的信息。normalize 去掉了所有空白,因此「校园 卡」也能搜到「校园卡」。

(3)状态变更:纯函数,不改动入参

function setItemStatus(items, id, status, now) {
  if (status !== 'active' && status !== 'done') throw new Error('非法状态:' + status);
  var found = false;
  var next = (items || []).map(function (it) {
    if (it.id !== id) return it;
    found = true;
    return Object.assign({}, it, { status: status, updatedAt: now });
  });
  return { ok: found, items: next };
}

这个函数没有副作用:找不到 id 时返回 ok:false 而不是静默失败,页面据此提示;正常时返回新数组,原数组不动。这样做的好处是调用方可以放心地在渲染前后对比,也让「改完之后其他人看到什么」这件事变得可测——我们专门写了一个用例验证「标记为已完成后再按进行中筛选,这条信息不再出现」。

(4)仓库层:把存储介质做成可替换的

function createRepo(options) {
  var storage = options.storage || null;   // 传 null 就是用内存
  var key = options.key || CONFIG.ITEM_KEY;

  function writeStore() {
    if (!storage) return false;
    try { storage.setItem(key, JSON.stringify(items)); return true; }
    catch (e) { return false; }            // 配额写满、隐私模式等,不崩
  }

  return {
    add: function (draft, now) {
      var result = core.createItem(draft, now);
      if (result.ok) { items = [result.item].concat(items); writeStore(); }
      return result;
    },
    // …find / setStatus / remove / search / stats
  };
}

这一层只有十几行,但它是整个项目「能测」的关键:测试里传 storage: null 就是内存仓库,浏览器里传 window.localStorage 就是持久化仓库,同一份业务代码在两种环境下都能跑。


四、附加特点设计与展示

4.1 创意点与意义

附加特点 它解决了什么 意义
搜不到时给「换个说法」 失物描述天然不统一:丢的人写「保温杯」,捡的人写「水杯」,双方永远搜不到对方 把「搜索失败」从一个死胡同变成一次引导:既给同义词,也给「自己发一条」的出口
搜索关键词高亮 结果一多,用户不知道这条为什么被搜出来 命中位置直接标黄,判断成本从「读完整条」降到「看一眼」
一键复制联系方式 手抄微信号、手机号容易抄错 详情页弹层里直接复制,减少一次失败的联系
照片自动压缩后再存 手机原图动辄 3~5MB,三张就能把 localStorage 写满,写满之后整个页面都存不进数据 上传前先压到最长边 720px,从根上避免「用着用着就存不进去了」

4.2 实现思路与代码

搜不到时的同义词推荐:先维护一张同义词表,关键词命中任意一组就返回该组的所有说法,再用热词补齐,最后去重并剔除与关键词完全相同的项;如果这个关键词其实能搜到结果,就返回空数组,页面不会显示这个模块。

function suggestKeywords(keyword, items, limit) {
  var kw = normalize(keyword);
  if (!kw) return [];
  if (items && searchItems(items, { keyword: kw }).length > 0) return [];

  var out = [];
  SUGGEST_MAP.forEach(function (group) {
    var hit = group.keys.some(function (k) {
      return kw.indexOf(normalize(k)) !== -1 || normalize(k).indexOf(kw) !== -1;
    });
    if (hit) out = out.concat(group.words);
  });
  out = out.concat(HOT_WORDS);
  // …去重、剔除与关键词相同的项、截取前 limit 个
}

关键词高亮:先转义 HTML 再包 <mark>,顺序不能反,否则用户输入的 <script> 会被当成标签渲染出来。

function highlight(text, keyword) {
  var safe = escapeHtml(text);              // 先转义
  var kw = trim(keyword);
  if (!kw) return safe;
  var escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  return safe.replace(new RegExp(escaped, 'gi'), function (m) { return '<mark>' + m + '</mark>'; });
}

照片压缩:FileReader 读成 dataURL → 画到 canvas 上按最长边 720px 缩放 → toDataURL('image/jpeg', 0.75)。一个 3MB 的相机原图压完大约 80KB,三张也还占不到 0.3MB。

4.3 成果展示

image

左起依次为:首页(分类快捷入口 + 信息流)、搜索结果、搜索无结果(同义词引导)、信息详情、联系发布者弹层、发布成功、发布信息、我的发布。


五、目录说明与使用说明

以下内容同样写进了仓库根目录的 README.md,助教可以直接照着复现。

5.1 目录结构

campus-lost-found/
├── index.html              首页:搜索入口、分类快捷入口、信息流、悬浮发布按钮
├── search.html             搜索页:搜索历史、大家都在找、结果筛选、无结果引导
├── detail.html             详情页:物品信息、发布者联系方式、状态标记入口
├── publish.html            发布页:选择寻物 / 招领,填写表单并校验
├── success.html            发布成功页
├── mine.html               我的发布:统计、筛选、标记完成、删除
├── css/
│   ├── base.css            设计变量、重置样式、通用组件(按钮 / 卡片 / 标签 / 弹层)
│   └── pages.css           各页面专属样式
├── js/
│   ├── core.js             纯逻辑层:配置、工具函数、校验、搜索、状态变更、统计
│   ├── repo.js             数据仓库层:增删改查 + localStorage 读写(可降级为内存)
│   └── ui.js               浏览器 UI 层:图标、页面外壳、物品卡片、弹层、图片压缩
├── test/
│   ├── framework.js        极简测试框架(describe / it / assert),浏览器与 Node 通用
│   ├── tests.js            51 个单元测试用例
│   ├── test.html           在浏览器里跑测试
│   └── run.js              在命令行里跑测试
└── README.md

5.2 如何运行

  1. 把整个文件夹下载到本地,保持目录结构不变。
  2. 用 Chrome 直接打开 index.html(双击即可,不需要本地服务器、不需要安装任何依赖)。
  3. 首次打开会自动写入 5 条示例数据,可以直接试用发布、搜索、详情、标记归还的完整流程。

数据保存在浏览器的 localStorage 里,关掉浏览器再打开依然在。想恢复到初始的示例数据,在 Chrome 的控制台执行 localStorage.clear() 后刷新页面即可。


六、单元测试

6.1 选用的工具,以及一份简易教程

我们没有引入 Mocha、Jest 这类第三方框架,而是自己写了一个约 90 行的极简测试框架(test/framework.js)。原因有两点:一是作业要求「下载所有文件后用 Chrome 打开就能跑」,引入 npm 依赖会破坏这一点;二是自己实现一遍 describe / it / assert,比直接调用 API 更能理解单元测试到底在做什么。

框架的用法和 Mocha 完全一致,日后想换成 Mocha,测试文件一行都不用改:

describe('分组名', function () {
  it('用例名:能描述清楚期望的行为', function () {
    assert.equal(实际值, 期望值, '失败时显示的说明');
  });
});

怎么自学单元测试:我们看的材料是廖雪峰的《测试框架 Mocha 实例教程》和邹欣老师《关于单元测试和回归测试》那篇博客。看下来最有用的一句话是「一个测试只验证一件事」——所以我们的用例名都写成一句可判断真假的陈述句,例如「物品名称只有 1 个字也要拦下来」。

自己动手写一个最小可用的断言,其实就是三步:把期望值和实际值比一下,不相等就抛异常,再写个循环把抛异常记为失败、正常返回记为通过。

// 断言:相等
equal: function (actual, expected, message) {
  if (actual !== expected) {
    fail((message || '两个值不相等') + ':期望 ' + JSON.stringify(expected) + ',实际 ' + JSON.stringify(actual));
  }
},
// 断言:期望抛出异常
throws: function (fn, contains, message) {
  var thrown = null;
  try { fn(); } catch (e) { thrown = e; }
  if (!thrown) fail(message || '期望抛出异常,但函数正常返回了');
  if (contains && String(thrown.message).indexOf(contains) === -1) fail('异常信息不符合预期:' + thrown.message);
}

失败信息里一定要带上「期望值 vs 实际值」。我们一开始只写「断言失败」,结果一个用例挂了要逐个打日志才知道差在哪,这也是 PSP 里测试环节超时 60 分钟的直接原因。

运行方式(两种任选):

node test/run.js          # 命令行运行

或者直接用浏览器打开 test/test.html,页面会自动跑完并把每个用例的结果列出来。

6.2 单元测试代码与它测试的函数

测试文件按被测对象分成 7 组,共 51 个用例:

分组 被测函数 用例数 覆盖内容
1. 表单校验 validateDraft 12 必填项缺失、长度上下限、非法枚举、选填项留空、多字段同时出错
2. 信息构造 createItem 3 默认状态、错误时不产生对象、首尾空格清理
3. 搜索与筛选 searchItems 12 命中名称/描述/地点、忽略空格、无结果、空关键词、按类型/类别/状态/组合筛选、两种排序
4. 状态变更 setItemStatus 5 正常标记、id 不存在、非法状态抛错、纯函数不改入参、标记后的可见性
5. 统计 calcStats 2 各项计数、空列表
6. 工具函数 timeAgo / isExpired / daysLeft / highlight / escapeHtml / suggestKeywords 9 相对时间、30 天有效期、XSS 转义、同义词推荐
7. 数据仓库 createRepo 8 新增、ownerId、校验失败不写入、改状态、删除、空仓库、reset、存储不可用时降级

举两个例子:

it('分类校验:类别不在可选范围时报错', function () {
  assert.includes(errorsOf(draft({ category: '飞船' })), 'category');
});

it('是纯函数:不会修改传进来的原数组', function () {
  core.setItemStatus(items, 'a', 'done', NOW);
  assert.equal(items[0].status, 'active');   // 原数组里应该还是 active
});

6.3 构造测试数据的思路

第一层:正常的输入。 先准备一份「一定合法」的草稿作为基准:

function draft(overrides) {
  var base = { type: 'lost', title: '白色蓝牙耳机', category: 'earphone',
               place: '教学楼', date: '2026-09-28',
               desc: '充电盒背面贴了蓝色贴纸', contact: '微信 earphone_lost', photos: [] };
  return Object.assign(base, overrides || {});
}

每个用例只覆盖自己关心的字段(draft({ title: '' })),其余字段保持合法。这样用例之间互不干扰,也没有一份到处复制的测试数据。

第二层:边界值。 有长度限制的字段,分别取「刚好合法」和「刚好越界」两个值。例如名称上限 30 字,我们测的是 1 个字(下限越界)和 31 个字(上限越界),而不是随手写一个 100 字。

第三层:异常与防御性输入。 类别传 '飞船'、类型传 'unknown'、日期传 '2026/09/28'、状态传 '完成啦',验证程序是明确报错而不是默默接受。

第四层:设想测试人员的刁难。 我们按「如果你是助教,会怎么把它弄坏」来补用例:

  • 「我把 localStorage 禁掉 / 塞满,你是不是整个页面就废了?」→ 所以有了「存储不可用时降级为内存」这条用例,storage 对象被我们故意写成每次都抛异常的桩。
  • 「我在物品名称里输入 <script>alert(1)</script> 会怎样?」→ 所以有了 escapeHtml 和高亮的 XSS 用例。
  • 「我搜索的时候多打了一个空格呢?」→ 所以 normalize 会去掉所有空白,并且有对应用例。
  • 「已经标记成归还了,还能不能搜出来?搜索结果会不会自相矛盾?」→ 所以有「标记完成后再按进行中筛选不会出现」这条。
  • 「时间算得对不对?信息会不会永远不下架?」→ 所以把 now 作为参数注入,timeAgo 和 isExpired 都能在固定时间点上断言,不受运行时刻影响。

这些用例够不够? 我们认为对本次范围是够的:作业要求的 5 个功能(发布、浏览、搜索、详情、更新状态)对应的核心函数全部被覆盖,且每个函数的正常路径、边界、异常路径都有用例。不足之处在于表现层的渲染逻辑(DOM 操作)没有自动化测试,这部分我们靠浏览器端的手动走查覆盖,见第七节。

6.4 GitHub 签入记录

我们按「一个功能一次提交」来划分,提交信息统一用 feat / style / test / docs / chore 前缀,共 14 次提交:

顺序 commit 信息 涉及文件
1 chore: 初始化项目结构与说明文档 README.md
2 feat(core): 业务逻辑层——配置、校验、搜索、状态变更与统计 js/core.js
3 feat(repo): 数据仓库层与本地存储读写 js/repo.js
4 feat(ui): 通用 UI 工具层——图标、外壳、卡片与弹层 js/ui.js
5 style: 设计变量与基础组件样式 css/base.css
6 feat(page): 首页信息流与分类快捷入口 index.html、css/pages.css
7 feat(page): 搜索页与搜索结果筛选 search.html
8 feat(page): 信息详情与联系发布者 detail.html
9 feat(page): 发布信息表单与必填校验 publish.html
10 feat(page): 发布成功页 success.html
11 feat(page): 我的发布与状态管理 mine.html
12 test: 自研极简单元测试框架 test/framework.js
13 test: 51 个单元测试用例与两种运行方式 test/tests.js、test/test.html、test/run.js
14 docs: 补充目录说明与使用说明 README.md

image


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

问题一:首页点卡片不跳转(代码模块异常)

问题描述:首页的信息卡片看起来是能点的(鼠标悬停有位移和阴影),但点下去毫无反应,控制台也不报错。

做过哪些尝试:一开始怀疑是 CSS 的 pointer-events 或者被悬浮按钮盖住了,调了半天样式没有用。后来在 console 里手动执行 document.querySelector('.item').click(),发现点击事件确实触发了,但没有任何跳转逻辑,才意识到问题不在样式,而是首页压根没调用 UI.bindItemList()——搜索页调了,首页漏了。

是否解决:已解决。首页补上绑定后,卡片点击、回车键跳转都正常了。

有何收获:这个 bug 说明「页面看起来对」和「功能能用」是两回事。我们后来在浏览器走查清单里专门加了一条:每个能点的元素都要真的点一遍。它也是我们在代码复审环节唯一发现的严重问题——如果只跑单元测试,这个 bug 永远不会暴露,因为单元测试只覆盖了 core.js,而漏掉的绑定发生在表现层。这也反过来印证了分层的好处:core.js 没有 bug,问题范围被迅速缩小到了页面脚本。

问题二:照片存不进去(代码模块异常)

问题描述:最初直接存手机原图的 dataURL,试到第三张时新增信息就失败了,但页面上没有任何提示。

做过哪些尝试:先以为是 FileReader 的问题,后来在 try/catch 里打印异常,才看到是 QuotaExceededError——localStorage 通常只有 5MB,三张原图就写满了。尝试过用 IndexedDB,但对本次作业来说太重;最后选择在存之前先压缩。

是否解决:已解决。用 canvas 把图片压到最长边 720px、JPEG 质量 0.75 再存,三张照片加起来不到 0.3MB。同时给 repo.js 的写入加了兜底:即使以后还是写满了,也只返回 false 而不会让页面崩溃,数据仍保留在内存中。

有何收获:同一个异常,报错和正确处理是两件事。 第一版的问题不是「存不下」,而是「存不下却什么都不说」。现在我们不仅修了压缩,也让失败路径有了明确的行为。

困难三:结对节奏(结对困难)

问题描述:两个人写的代码要拼在一起,第一天就出现过「我按 repo.search() 已经返回数组来写,他以为要自己再过滤一遍」的重复劳动。

做过哪些尝试:我们约定先定接口再写实现——动手前把 core.js 和 repo.js 要暴露的函数名、参数、返回值写在一张纸上,返回值格式(比如统一用 { ok, items } 还是直接返回数组)先吵清楚再各写各的。另外,因为一个人写逻辑层、一个人写表现层,我们规定改接口必须先在群里说一声。

是否解决:基本解决。后半天没有再出现返工。剩下的问题是两边的进度不完全同步,写表现层的同学偶尔要等接口,我们就先用假数据把页面搭起来,接口好了再接上。

有何收获:结对最花时间的不是写代码,而是对齐对接口的理解。这次在《构建之法》第 3 章里看到的「驾驶员和领航员要不断沟通」这句话,是我们真正体会到的一次。


八、评价你的队友

王宇航 值得学习的地方:写代码前习惯先把函数的输入输出写清楚,{ ok, errors } 这种「不抛异常、把失败也当成返回值」的写法就是他坚持的,后来证明这个决定让校验和状态变更都变得非常好测。另外他很较真,发现测试的失败信息看不明白,会停下来先把框架改好再继续写用例。

王宇航 需要改进的地方:有时候会一口气写很多再一起提交,导致 commit 粒度偏大,助教如果按 commit 追功能会不太好对应。我们已经约定后面按功能点拆小提交。另外写页面时容易先追求视觉效果,这次是同伴提醒之后才回头补的空状态和错误提示。

同时也说一下自己的问题:我在估算工时上偏乐观,编码环节低估了 120 分钟;下次宁愿多留一点缓冲,也不要把时间排满。

 posted on 2026-10-10 23:04  明晚桃  阅读(5)  评论(0)    收藏  举报