软件工程第二次结对作业——校园失物招领
结对作业二——校园失物招领
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601 软件工程与软件工程实践 |
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次之程序实现) |
| 这个作业的目标 | 将"福大拾光"校园失物招领原型实现为可运行的 Web 应用,完成"发布—浏览/搜索—查看详情—联系发布者—更新状态"核心业务流程,并通过 GitHub 协作、PSP 和单元测试实践结对开发、版本管理与软件测试 |
| GitHub 仓库 | 102402112-102402110 |
| 学号与姓名 | 102402112 松格拉;102402110 姜思源 |
| 队友博客 | 松格拉的博客 |
| 队友博文 | 松格拉的博文 |
1. 具体分工
| 模块 / 任务 | 松格拉 | 姜思源 |
|---|---|---|
| 需求分析与原型对照 | ✔ 主导 | ✔ 参与 |
数据层 js/store.js(搜索 / 筛选 / 校验 / 统计 / 持久化) |
✔ 主导 | 复审 |
种子数据 js/data.js |
✔ 主导 | — |
页面结构 index.html |
复审 | ✔ 主导 |
样式 css/style.css(视觉设计) |
— | ✔ 主导 |
视图渲染 js/views.js |
参与 | ✔ 主导 |
交互控制 js/app.js |
主导 | 参与 |
| 单元测试用例设计(61 个) | ✔ 主导 | 参与 |
测试报告页 test/test.html |
✔ 主导 | — |
| README 与目录说明撰写 | 参与 | ✔ 主导 |
| 博客撰写 | 参与 | ✔ 主导 |
| GitHub 仓库创建 / 审核合并 PR | ✔ 主导 | Fork 并提交 PR |
协作方式:松格拉创建仓库并负责数据层与测试,姜思源fork 仓库后负责界面与文档,
通过 Pull Request 合入,全程共 20+ 次有意义的 commit(按功能点提交,见第 11 节)。
2. PSP 表格
2.1 预估与实际对照表
| PSP 2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 40 | 55 |
| Estimate | 估计这个任务需要多少时间 | 40 | 55 |
| Development | 开发 | 760 | 985 |
| Analysis | 需求分析(包括学习新技术) | 90 | 80 |
| Design Spec | 生成设计文档 | 60 | 70 |
| Design Review | 设计复审 | 30 | 40 |
| Coding Standard | 代码规范 | 30 | 25 |
| Design | 具体设计 | 80 | 110 |
| Coding | 具体编码 | 350 | 480 |
| Code Review | 代码复审 | 60 | 80 |
| Test | 测试(自我测试,修改代码,提交修改) | 60 | 100 |
| Reporting | 报告 | 400 | 565 |
| Test Report | 测试报告 | 90 | 120 |
| Size Measurement | 计算工作量 | 30 | 35 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 60 | 90 |
| — | 博客撰写 | 220 | 320 |
| 合计 | 1200 | 1605 |
2.2 时间分布可视化
| 阶段 | 预估 | 实际 | 偏差 |
|---|---|---|---|
| 计划 | 40 | 55 | +15(+37.5%) |
| 开发 | 760 | 985 | +225(+29.6%) |
| 报告 | 400 | 565 | +165(+41.3%) |
| 合计 | 1200 | 1605 | +405(+33.8%) |
2.3 偏差分析与总结
- 低估了「具体编码」的时间(预估 350 → 实际 480)。主要原因是:一开始想把
「失物地图」和「时空匹配」也做进去,实现到一半发现工作量远超预期,于是调整为
简化方案,中间浪费了一部分时间。教训是:原型设计可以天马行空,但要先做技术可行性评估。 - 测试环节实际耗时是预估的 1.67 倍。写完 61 个用例后,有 1 个用例首次运行是失败的
(用例 2 的关键词设计有误,详见第 12 节),定位和修正花了额外时间。这说明
测试数据本身也需要设计,不能随手写。 - 「事后总结」超时,是因为本次踩的坑(浏览器安全策略、剪贴板 API 降级)都值得记录,
我们多花时间沉淀了经验。 - 下次改进:先做 15 分钟的技术预研(尤其是浏览器 API 兼容性),再开始估时;
把「跑通一个最小闭环」作为第一个里程碑,而不是先追求功能全。
3. 解题思路描述与设计实现说明
3.1 总体思路
作业要求打通这条主流程:
发布信息 → 浏览或搜索 → 查看详情 → 联系发布者 → 更新状态
我们二人围绕这条主线做设计,核心决策有三个:
决策一:为什么选择纯静态 Web(原生 HTML/CSS/JS),而不是框架?
作业明确要求「确保其他同学在下载你的所有文件后,用谷歌浏览器运行 HTML 文件就能展现预期结果」。
如果用 React/Vue,助教需要装 Node、跑 npm install、启动 dev server,任何一步出问题都会
导致「跑不起来」而丢便捷性分数。因此我们选择:
- 原生 HTML + CSS + JavaScript,零框架、零构建、零依赖
- 数据存储用
localStorage,无需后端服务 - 单元测试框架 Mocha/Chai 也下载为本地文件,保证断网也能运行测试
这个决策的收益在测试环节非常明显:助教双击 index.html 就能看到完整的带数据界面。
决策二:三层架构分离
为了让 store.js 可被单元测试直接测试,我们把业务逻辑与 DOM 操作彻底解耦:
| 层级 | 文件 | 职责 | 是否碰 DOM |
|---|---|---|---|
| 数据层 | store.js |
搜索、筛选、校验、统计、状态流转 | ❌ 完全不碰 |
| 数据层 | data.js |
常量与示例数据 | ❌ |
| 视图层 | views.js |
数据 → HTML 字符串 | ❌(只拼字符串) |
| 入口层 | app.js |
路由、事件绑定、编排 | ✔ 唯一操作 DOM 的地方 |
这样做的直接好处:61 个单元测试可以直接 require('../js/store.js') 断言,
不需要 jsdom、不需要启动浏览器。这正是分层带来的可测试性红利。
决策三:状态流转采用「不可变更新」
updateStatus(items, id, status) 不修改传入数组,而是返回新数组(内部用 map 生成新对象)。
这样做的原因:视图层可能在多处持有同一份数据引用,如果原地修改,容易产生「数据改了但界面没刷新」
或者「刷新了但引用了脏数据」的诡异问题。不可变更新的代价是内存开销略高,但对于校园级数据量完全可以接受。
3.2 关键实现的流程图
流程图 1:发布信息流程
┌─────────────┐
│ 点击发布信息 │
└──────┬──────┘
▼
┌────────────────────────┐
│ 选择类型(寻物 / 招领) │
└────────────┬───────────┘
▼
┌────────────────────────┐
│ 填写表单字段 │
│ 名称/类别/地点/日期/联系方式 │
└────────────┬───────────┘
▼
┌────────────────────────┐
│ 点击「确认发布」 │
└────────────┬───────────┘
▼
┌────────────────────────┐
│ validateItem() 校验 │◄──── 数据层
└────────────┬───────────┘
┌─────┴─────┐
不通过 通过
│ │
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ 逐字段标红 │ │ addItem() 写入 │
│ 显示具体错误 │ │ localStorage │
└──────────────┘ └────────┬─────────┘
▼
┌────────────────────┐
│ Toast 提示发布成功 │
│ 自动跳转「全部信息」 │
└────────────────────┘
流程图 2:浏览 / 搜索 / 详情 / 状态更新流程
┌───────────┐
│ 首页 │──输入关键词──►┌─────────────────┐
└───────────┘ │ 跳转「全部信息」 │
│ 并预填关键词 │
┌───────────┐ └────────┬────────┘
│ 全部信息页 │◄───────────────────────┘
└─────┬─────┘
│ 类型标签 / 类别 / 状态 / 关键词
▼
┌──────────────────────────┐
│ searchItems() 组合筛选 │◄──── 数据层(纯函数)
└────────────┬─────────────┘
▼
┌──────────────────────────┐
│ 渲染卡片列表(views.js) │
└────────────┬─────────────┘
│ 点击卡片
▼
┌──────────────────────────┐
│ 打开详情抽屉(右滑入) │
│ · 物品信息 │
│ · 联系方式(手机号脱敏) │
│ · 一键复制按钮 │
└────────────┬─────────────┘
│
┌──────────┴──────────┐
▼ ▼
┌──────────┐ ┌────────────────┐
│ 复制联系 │ │ 标记已找到/已归还 │
│ 方式 │ └───────┬────────┘
└──────────┘ ▼
┌──────────────────────┐
│ updateStatus() 更新 │◄──── 数据层
│ 返回新数组,持久化 │
└──────────┬───────────┘
▼
┌──────────────────────┐
│ 列表与详情同步刷新 │
│ 状态徽章变为「已找到」 │
└──────────────────────┘
3.3 数据流图(分层视角)
┌──────────────────────────────────────────────────────────────┐
│ 用户交互 │
│ 发布 · 搜索 · 筛选 · 查看详情 · 复制联系方式 · 标记状态 │
└───────────────────────────┬──────────────────────────────────┘
│ DOM 事件(事件委托)
▼
┌──────────────────────────────────────────────────────────────┐
│ 入口层 app.js │
│ go() 路由切换 · handleSubmit() · markClosed() │
│ 只做编排:读表单 → 调 store → 调 views → 写 DOM │
└──────────┬────────────────────────────────┬──────────────────┘
│ │
▼ ▼
┌────────────────────────┐ ┌──────────────────────────────┐
│ 数据层 store.js │ │ 视图层 views.js │
│ ┌──────────────────┐ │ │ ┌────────────────────────┐ │
│ │ searchItems() │ │ │ │ renderCard() │ │
│ │ validateItem() │ │ │ │ renderDetail() │ │
│ │ updateStatus() │ │ │ │ renderStatCards() │ │
│ │ calcStats() │ │ │ │ renderDayChart() │ │
│ │ load() / save() │ │ │ │ renderMineList() │ │
│ └──────────────────┘ │ │ └────────────────────────┘ │
│ 纯函数 · 可单测 │ │ 只输出 HTML 字符串 │
└───────────┬────────────┘ └──────────────────────────────┘
│
▼
┌────────────────────────┐ ┌──────────────────────────────┐
│ localStorage │ │ data.js 类别常量 + 示例数据 │
│ (浏览器本地持久化) │ │ (首次访问自动注入) │
└────────────────────────┘ └──────────────────────────────┘
3.4 重要的代码片段与解释
片段 1:关键词搜索 + 多条件组合筛选(store.js)
function searchItems(items, query) {
var list = Array.isArray(items) ? items.slice() : [];
if (!query) { return list; }
var keyword = String(query.keyword || '').trim().toLowerCase();
return list.filter(function (item) {
if (keyword) {
var haystack = [
item.title || '',
item.description || '',
item.place || ''
].join(' ').toLowerCase();
if (haystack.indexOf(keyword) === -1) { return false; }
}
if (query.type && item.type !== query.type) { return false; }
if (query.category && item.category !== query.category) { return false; }
if (query.status && item.status !== query.status) { return false; }
return true;
});
}
为什么这么写?
items.slice()先拷贝再操作,保证函数是纯函数,不污染调用方数据。
这是本项目的统一约定,单元测试「用例 15」专门验证了这一点。- 三层字段拼成 haystack 再统一匹配:用户搜「图书馆」可能想找「地点在图书馆的物品」,
搜「校园卡」可能是物品名,搜「恐龙」可能是描述里的特征词。把标题、描述、地点合成一个
搜索域,一次indexOf就能覆盖全部场景,比写三个||判断更简洁,也更容易扩展
(将来要加「搜索联系人姓名」只需往数组里加一项)。 toLowerCase()统一转小写实现大小写不敏感搜索。「用例 4」用airpods/AIRPODS/
AiRpOdS三种写法验证了这个特性。- 筛选条件用「有值才判断」的短路写法:
if (query.status && item.status !== query.status)。
这样调用方只需传关心的条件,其他留空即可,无需构造完整的 query 对象。
片段 2:不可变的状态更新(store.js)
function updateStatus(items, id, status) {
var list = Array.isArray(items) ? items : [];
return list.map(function (item) {
if (item.id !== id) { return item; }
var next = Object.assign({}, item); // 浅拷贝,生成新对象
next.status = status;
next.statusUpdatedAt = status === STATUS.CLOSED ? new Date().toISOString() : null;
return next;
});
}
为什么这么写?
- 用
map而非forEach+ 原地赋值:map天然返回新数组,从函数签名就能看出
「这是一个不可变更新」,调用方不会担心原数据被改。 Object.assign({}, item)生成新对象而非改原对象。项目里同一个 id 的记录可能同时被
列表渲染函数和详情抽屉引用,原地修改会导致「改了列表,详情看着新了,但点开别的又串了」
这类难排查的 bug。- 回滚时把
statusUpdatedAt置为null:因为「重新打开」意味着这条记录不再是
已结束状态,统计「平均找回时长」时不应把它算进去。「用例 21」验证了这个回滚行为。
片段 3:表单校验(store.js)—— 一次返回全部错误
function validateItem(data) {
var errors = {};
if (!data || typeof data !== 'object') {
return { valid: false, errors: { _global: '数据格式不正确' } };
}
if (!data.title || String(data.title).trim().length < 2) {
errors.title = '物品名称至少需要 2 个字符';
} else if (String(data.title).trim().length > 30) {
errors.title = '物品名称不能超过 30 个字符';
}
// ... 其余字段同理 ...
// 联系方式采用「二选一」策略:手机号或 IM 至少填一个
var phoneOk = /^1[3-9]\d{9}$/.test(String(contact.phone || '').trim());
var imOk = String(contact.im || '').trim().length >= 4;
if (!phoneOk && !imOk) {
errors.contact = '请至少填写一个有效的联系方式(11 位手机号,或 QQ / 微信号)';
}
var keys = Object.keys(errors);
return { valid: keys.length === 0, errors: errors };
}
为什么这么写?
- 不提前 return,把所有错误一次性收集完再返回。如果写成「发现第一个错误就返回」,
用户要反复提交 5 次才能把 5 个字段的错误都填对,体验很差。现在一次提交就能把
title、category、date、contact等全部标红。「用例 31」专门验证了这个行为。 - 用
errors对象的键对应表单字段名,渲染层只需$('[data-error="' + key + '"]')
就能定位到错误提示容器,无需写一堆 if-else 映射。 - 手机号正则
/^1[3-9]\d{9}$/:这是中国大陆手机号的常用校验规则(1 开头,
第二位 3-9,共 11 位)。比只判断长度更严格,能拦住12345678901这类无效输入。 - 联系方式「二选一」:考虑到学生可能只愿意留微信不留手机号,所以设计成
「手机号有效 或 IM 长度 ≥ 4」满足其一即可。「用例 29」「用例 30」分别验证了
两种路径。
片段 4:事件委托(app.js)—— 一个监听器管全站
document.addEventListener('click', function (e) {
var navEl = e.target.closest('[data-nav]');
var actEl = e.target.closest('[data-action]');
var card = e.target.closest('.item-card');
var copyEl = e.target.closest('[data-copy]');
// 优先级很重要:先判断更具体的目标
if (navEl) { e.preventDefault(); go(navEl.getAttribute('data-nav')); return; }
if (copyEl) { e.stopPropagation(); /* 复制逻辑 */ return; }
if (actEl) { e.stopPropagation(); /* 操作逻辑 */ return; }
if (card) { openDetail(card.getAttribute('data-id')); }
});
为什么这么写?
- 列表会频繁重渲染(搜索、筛选、标记状态后都要重新生成卡片 HTML)。如果给每张卡片
单独addEventListener,每次重渲染都要重新绑定,既冗余又容易漏绑、重复绑。
事件委托把监听器挂在整个document上,不管列表怎么重渲染,事件永远有效。 - 判断顺序体现优先级:一张卡片里面可能同时包含「卡片本身」(data-id)和
「卡片内的复制按钮」(data-copy)。必须先判断更具体的目标,否则点复制按钮会同时
触发「打开详情」。return语句保证了这一点。 closest()会向上查找祖先节点,所以用户点到卡片里的文字、图标、标签都能正确识别
为「点了这张卡片」,不需要给每层子元素绑定事件。
片段 5:剪贴板复制的降级方案(app.js)
function copyText(text) {
function fallback() {
var 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');
toast('已复制:' + text, 'success');
} catch (e) {
toast('复制失败,请手动选择复制', 'error');
}
ta.remove();
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text)
.then(function () { toast('已复制:' + text, 'success'); })
.catch(fallback); // 现代 API 失败时回退
} else {
fallback(); // 不支持现代 API 时直接回退
}
}
为什么这么写?
navigator.clipboard是较新的 API,在file://协议下很多浏览器会拒绝授权
(因为不是安全上下文)。而本作业要求「双击 HTML 直接运行」,恰好就是file://场景。
如果只用新 API,助教本地测试时会遇到「点了复制没反应」的问题。document.execCommand('copy')虽然已标记为废弃,但在file://下仍广泛可用,
作为兜底方案效果很好。- 双层保险:先试新 API,
.catch()到降级方案;如果浏览器连navigator.clipboard
都没有(老版本),直接走降级。这保证了在任何环境下复制功能都可用。
4. 附加特点设计与展示
4.1 附加特点一:一键复制联系方式 + 手机号脱敏保护
设计的创意独到之处与意义
作业的评分规则里明确举例提到「一键复制联系方式」这个方向。我们在此基础上做了
一个额外思考:直接明文展示手机号,等于把发布者的隐私暴露给所有访问页面的爬虫。
因此我们的设计是:
| 状态 | 显示 | 用户操作 |
|---|---|---|
| 初始 | 138****5678(脱敏) |
点击手机号 |
| 第一次点击 | 13812345678(明文展开) |
再次点击 |
| 第二次点击 | 不变 | 自动复制到剪贴板 + Toast 提示 |
这个设计的双重价值:
- 对发布者:号码不会被自动化脚本批量抓取(很多失物招领页面会被人肉爬虫扫号码用于推销);
- 对使用者:真正的联系动作只需两次点击,且复制后有明确的成功反馈——「已复制:13812345678」,
避免用户不确定有没有复制成功而反复点击。
同时详情面板底部还有两个显式的大按钮「📋 一键复制手机号」「📋 一键复制微信号」,
给追求效率的用户一条更短的路径。
实现思路
- 脱敏:
store.js的maskPhone()把 11 位手机号的第 4-7 位替换为****,
非手机号格式的输入原样返回(不会误截断短的 IM 号)。 - 展开状态用一个模块级变量
phoneRevealed管理,点击手机号时先判断是否已展开,
未展开则展开(并刷新抽屉),已展开则执行复制。 - 复制走
copyText(),内部有navigator.clipboard→execCommand的降级链(见 3.4 片段 5)。
关键代码片段
// views.js —— 渲染联系方式时,根据是否已展开决定显示脱敏还是明文
var showPhone = opts.maskedPhone && phone ? global.LFStore.maskPhone(phone) : phone;
html.push(
'<div class="contact-row">',
' <span class="contact-row__label">手机号</span>',
' <span class="contact-row__value" data-copy="' + escapeHtml(phone) + '">',
escapeHtml(showPhone),
(opts.maskedPhone ? ' <span>点击展开</span>' : ''),
' </span>',
'</div>'
);
// store.js —— 脱敏实现,非手机号输入原样返回
function maskPhone(phone) {
var p = String(phone || '');
if (!/^1\d{10}$/.test(p)) { return p; } // 不符合手机号格式则不动
return p.slice(0, 3) + '****' + p.slice(7);
}
// app.js —— 第一次点击展开,第二次点击才复制
if (isPhone && !phoneRevealed) {
phoneRevealed = true;
refreshDetail(); // 重渲染为明文
toast('已显示完整手机号');
} else {
copyText(raw); // 复制 + Toast 反馈
}
4.2 附加特点二:数据统计看板
设计的创意独到之处与意义
作业不要求后台管理,但我们认为:一个失物招领平台光有「列表」是没有生命力的,
它应该能回答一个更有价值的问题——「这个校园的失物找回情况到底怎么样?」
因此我们增加了一个数据看板页面,它承担三个作用:
- 对用户:展示「找回率 40%」这类数字,传递「发了真的有用」的信任感,
激励更多人来发布和归还; - 对校园管理者:想优化失物招领工作(比如在哪个楼多放招领点),
需要知道「哪类物品最容易丢」「哪个时间段发布量高」,看板直接给出答案; - 对本项目:让项目从「信息展示工具」升级为「有数据洞察的平台」,
这是纯粹加分的设计,也和我们原型里「福大拾光」这个品牌调性一致。
看板包含四张指标卡和两张图表:
| 组件 | 内容 | 为什么有用 |
|---|---|---|
| 指标卡 ×4 | 累计发布 / 成功找回归还 / 正在寻找 / 找回率 | 一眼看清全局 |
| 近 7 天发布趋势(柱状图) | 每日新增数量 | 看活跃度变化,发现「周末丢东西多」之类的规律 |
| 物品类别分布(条形图) | 各类别数量排序 | 提示高发类别(本例中电子产品居多) |
实现思路
关键决策:不引入图表库(如 ECharts / Chart.js),全部用纯 CSS 绘制。
原因有两点:
- 体积与依赖:ECharts 压缩后还有几百 KB,而我们整个项目才几十 KB。
为了两张小图表引入一个大库,性价比极低,也违背了「零依赖」的架构决策; - 学习成本与可控性:CSS 画的柱状图高度可控、样式和全站统一、还能直接加 CSS 过渡动画,
视觉效果并不比图表库差。
技术实现是:用数据算出每个柱子的高度百分比,直接写入内联 style 的 height,
配合 CSS 的 transition: height .6s 就有生长动画。
所有统计计算都在 store.js 的 calcStats() 里完成(纯函数,可单测),
views.js 只负责把算好的数字画成 HTML。
关键代码片段
// store.js —— 统计计算(纯函数,被单元测试覆盖)
function calcStats(items) {
var list = Array.isArray(items) ? items : [];
var stats = { total: list.length, lost: 0, found: 0, closed: 0, open: 0,
closedRate: 0, byCategory: {}, last7Days: [] };
list.forEach(function (item) {
if (item.type === TYPE.LOST) { stats.lost++; }
if (item.type === TYPE.FOUND) { stats.found++; }
if (item.status === STATUS.CLOSED) { stats.closed++; } else { stats.open++; }
var cat = item.category || '其他';
stats.byCategory[cat] = (stats.byCategory[cat] || 0) + 1; // 按类别累加
});
// 找回率:四舍五入为整数百分比;总数为 0 时直接给 0,避免除零得到 NaN
stats.closedRate = stats.total === 0
? 0
: Math.round((stats.closed / stats.total) * 100);
// 近 7 天每日发布量:从 6 天前循环到今天,保证返回的永远是 7 个点
var today = new Date();
today.setHours(0, 0, 0, 0);
for (var i = 6; i >= 0; i--) {
var day = new Date(today.getTime() - i * 86400000);
var key = day.getFullYear() + '-' +
String(day.getMonth() + 1).padStart(2, '0') + '-' +
String(day.getDate()).padStart(2, '0');
var count = list.filter(function (item) {
return String(item.createdAt).slice(0, 10) === key;
}).length;
stats.last7Days.push({ date: key, count: count });
}
return stats;
}
💡 两个细节值得说明:
stats.total === 0 ? 0 : ...这个三元判断是必要的。如果没有它,
空数据时0/0会得到NaN,界面上就会显示「找回率 NaN%」。
单元测试「用例 39」专门断言了Number.isNaN(s.closedRate) === false。- 日期循环写成
for (var i = 6; i >= 0; i--)而不是正序,是为了让数组里
的日期从早到晚递增,这样画出来的柱状图天然就是时间从左到右的顺序。
// views.js —— 纯 CSS 柱状图:用数据算出高度
function renderDayChart(days) {
var max = Math.max.apply(null, days.map(function (d) { return d.count; }).concat([1]));
return days.map(function (d) {
// 数量为 0 时给 4px 的最小高度,让用户能看到「这天确实有柱子,只是值为 0」
var height = d.count === 0 ? 4 : Math.round((d.count / max) * 140) + 8;
var label = d.date.slice(5).replace('-', '/');
return [
'<div class="chart__col">',
' <div class="chart__num">' + d.count + '</div>',
' <div class="chart__bar" style="height:' + height + 'px"></div>',
' <div class="chart__label">' + label + '</div>',
'</div>'
].join('');
}).join('');
}
💡
.concat([1])的作用:当所有日期的 count 都是 0 时,max会是 0,
后面的除法就会变成0/0 = NaN。加一个[1]保证分母至少是 1。
这是写图表代码时非常容易忽略的边界,我们把它记录下来。
4.3 其他值得说明的人性化设计
- 首次访问自动注入 10 条示例数据:助教老师打开即可看到完整界面效果,
不用先手动录入 10 条才能评估「美观性」。同时提供控制台命令localStorage.clear()
方便查看空状态。 - 表单错误实时清除:用户修改某个字段时,该字段的红色提示立即消失,
而不是等到下次提交才更新,减少「我已经改了但还红着」的困惑。 - 状态可回滚:误把「进行中」点成「已找到」后,可以在详情页点「重新标记为进行中」,
不用删了重发。 - 全局 Toast 反馈:每一次「发布成功」「已复制」「已删除」「已标记」都有明确反馈,
用户永远知道自己的操作有没有生效。 - ESC 键关闭详情抽屉:符合桌面端用户的直觉习惯。
- 详情页显示「N 天前」:
2026-10-05(3 天前)比单纯的日期更直观,
因为失物招领的时效性很强。
5. 目录说明与使用说明
5.1 目录是如何组织的
fzu-lost-and-found/
├── index.html # 单页应用入口,包含 5 个视图的完整结构
├── package.json # 仅用于命令行跑单元测试,网页运行不需要它
├── README.md # 目录说明 + 使用说明
│
├── css/
│ └── style.css # 全站样式(主题变量 / 布局 / 组件 / 响应式)
│
├── js/
│ ├── store.js # ★ 数据层:业务逻辑核心(可单测)
│ ├── data.js # 常量与示例数据
│ ├── views.js # 视图层:数据 → HTML
│ └── app.js # 入口层:路由 + 事件 + 编排
│
├── test/
│ ├── test.html # 浏览器端测试报告页
│ ├── store.test.js # 61 个单元测试用例
│ └── lib/ # Mocha + Chai 本地文件(保证断网可跑)
│
└── docs/
└── 博客园博文.md # 本博客原文
组织思路:按「职责」而不是「文件类型」划分文件夹。
- 所有业务逻辑集中在
js/store.js,它是项目的核心资产,也是单元测试的主要对象; - 视图层与入口层分离,保证「改样式」和「改逻辑」互不干扰;
- 测试相关文件全部放在
test/,第三方测试框架放在test/lib/下,
与业务代码物理隔离,一眼就能看出「哪些是别人的代码,哪些是我们写的」; - 文档放在
docs/,与代码分离。
5.2 老师和助教如何运行我们的网页
运行主程序
- 从 GitHub 下载(
Code→Download ZIP)并解压,注意保持目录结构完整; - 用 谷歌浏览器(Chrome) 双击打开根目录的
index.html; - 页面会自动加载 10 条示例数据,可直接体验全部功能。
运行单元测试
方式一:浏览器
用谷歌浏览器打开 test/test.html,立即看到测试报告,全通过显示绿色的 61 passing。
方式二:命令行
npm install # 首次需要,安装 mocha 与 chai
npm test # 输出:61 passing
6. 单元测试
6.1 测试工具的选择与学习过程
选用的工具
- Mocha 10.7.3:测试运行器,负责组织用例(
describe/it)和输出报告; - Chai 4.5.0:断言库,提供
expect(xxx).to.equal(yyy)这类自然语言风格的断言。
选择它们的原因是:作业附录里明确推荐了「测试框架 Mocha 实例教程」和
「10 个可靠的 JavaScript 测试工具」,而 Mocha + Chai 是 JavaScript 社区最经典的组合,
资料最丰富,遇到问题容易搜到答案。
我们是怎么学单元测试的
第一步:先搞懂「什么是单元测试」
我们读了邹欣老师的《关于单元测试和回归测试》,得到的核心认知是:
单元测试是对代码中最小可测试单元(通常是一个函数)的验证,
它的价值不在于「证明代码是对的」,而在于当你改动代码时,能立刻知道有没有破坏原有功能。
这个认知直接影响了我们的架构决策:为了让 store.js 可被单测,我们刻意把它写成了
不依赖 DOM 的纯函数集合。如果一开始就把逻辑写在事件处理函数里,就根本没法测。
第二步:跟着教程跑通第一个用例
我们参考了廖雪峰的 JavaScript 教程和 Mocha 官方文档,先写了一个最简单的用例跑通:
describe('冒烟测试', function () {
it('1 应该等于 1', function () {
expect(1).to.equal(1);
});
});
跑通后再逐步替换成真实的业务断言。
第三步:给项目写一份自己的简易教程
我们的教程如下(也希望能帮到其他同学):
① 安装
npm install --save-dev mocha chai
② 在 package.json 里配置测试命令
{
"scripts": {
"test": "mocha test/store.test.js --reporter spec"
}
}
③ 写测试文件(test/store.test.js)
// 1) 引入被测代码和断言库
var Store = require('../js/store.js');
var expect = require('chai').expect;
// 2) describe 描述一个「测试套件」,通常对应一个功能模块
describe('【搜索】searchItems', function () {
// 3) it 描述一个「测试用例」,写明「应该发生什么」
it('用例1:应能按物品名称精确命中', function () {
var data = [{ id: 'T1', title: '校园卡', description: '', place: '' }];
var result = Store.searchItems(data, { keyword: '校园卡' });
// 4) 用 expect 断言
expect(result).to.have.lengthOf(1);
expect(result[0].id).to.equal('T1');
});
});
④ 运行
npm test
⑤ 看懂报告
【搜索】searchItems - 关键词匹配
√ 用例1:应能按物品名称精确命中(关键词「校园卡」)
√ 用例2:应能匹配详细描述中的文字
61 passing (58ms)
√绿色对勾 = 通过;×红色叉号 = 失败,下方会打印期望值和实际值;- 我们在断言里都写了中文说明,失败时能直接看出是哪条业务规则被破坏了。
⑥ 让测试能在浏览器里跑(本项目采用的方式)
因为作业要求「双击 HTML 就能运行」,我们还额外做了一个 test/test.html,
把 Mocha/Chai 换成浏览器版并下载到本地:
<div id="mocha"></div>
<script src="lib/mocha.js"></script>
<script src="lib/chai.js"></script>
<script>mocha.setup({ ui: 'bdd', reporter: 'spec' });</script>
<!-- 被测代码 -->
<script src="../js/store.js"></script>
<script src="../js/data.js"></script>
<!-- 测试用例 -->
<script src="store.test.js"></script>
<script>mocha.run();</script>
打开这个 HTML 就能看到漂亮的测试报告,助教无需任何环境配置。
一点心得
我们最大的体会是:单元测试的价值在「重构」时才能真正体现出来。
开发过程中我们改过 calcStats 的实现(一开始用 for 循环,后来改成 forEach),
改完之后直接跑 npm test,61 个用例 58 毫秒出结果,立刻确认没有破坏任何原有功能。
如果没有这套测试,就得手工翻遍每个页面去点,效率差了几十倍。
6.2 单元测试代码展示
完整用例文件:test/store.test.js,共 61 个用例,全部通过。
可以看到,测试代码被分成了 8 个套件,每个套件对应一个功能模块。
测试的函数清单
| 套件 | 测试的函数 | 用例数 | 重点覆盖 |
|---|---|---|---|
| 【搜索】 | searchItems(关键词部分) |
9 | 标题/描述/地点匹配、大小写、trim、空值 |
| 【筛选】 | searchItems(条件部分) |
7 | 类型、类别、状态、组合、纯函数性 |
| 【状态更新】 | updateStatus |
7 | 状态转换、不可变性、回滚、非法 id |
| 【表单校验】 | validateItem |
10 | 各字段边界、多错并返、非法入参 |
| 【统计】 | calcStats |
9 | 计数、找回率、7 天统计、除零、空数据 |
| 【工具函数】 | sortByTimeDesc findById maskPhone daysBetween generateId todayString |
9 | 排序正确性、格式校验、唯一性 |
| 【持久化】 | save load addItem removeItem |
5 | 读写一致、跨会话、数据损坏降级 |
| 【示例数据】 | data.js 的数据质量 |
5 | 类别合法性、内置数据自身可过校验 |
部分关键测试代码
(1)边界值测试:物品名称长度
it('用例24:物品名称为空或过短(1 个字)应校验失败', function () {
var d = validData(); d.title = '';
expect(Store.validateItem(d).valid).to.equal(false);
expect(Store.validateItem(d).errors).to.have.property('title');
d.title = '卡'; // 边界:恰好 1 个字符,应该失败
expect(Store.validateItem(d).valid).to.equal(false);
});
it('用例25:物品名称超过 30 字应校验失败', function () {
var d = validData();
d.title = new Array(32).join('测'); // 31 个字符,越过上限
var r = Store.validateItem(d);
expect(r.valid).to.equal(false);
expect(r.errors.title).to.contain('30');
});
设计说明:
new Array(32).join('测')是一个常用技巧,生成 31 个重复字符。
验证的是「上限 +1」这个经典的边界值。
(2)状态更新的不可变性测试
it('用例18:不应修改原数组(避免视图层数据脏读)', function () {
var originalStatus = Store.findById(testItems, 'T1').status;
Store.updateStatus(testItems, 'T1', 'closed');
expect(Store.findById(testItems, 'T1').status).to.equal(originalStatus);
});
设计说明:这条用例保护的是架构约定。如果将来有人把
updateStatus改成原地修改,
这条测试会立刻失败,从而拦住可能引发界面 bug 的改动。这是「用测试固化架构决策」。
(3)除零边界测试
it('用例39:边界 —— 空数据时所有统计应为 0,且不出现除零 NaN', function () {
var s = Store.calcStats([]);
expect(s.total).to.equal(0);
expect(s.closedRate).to.equal(0);
expect(Number.isNaN(s.closedRate)).to.equal(false); // 关键断言
expect(s.last7Days).to.have.lengthOf(7);
});
设计说明:
Number.isNaN在 Chai 的expect里不能直接调用(它会尝试把 NaN 当
断言目标),所以用原生方法包裹。这条用例的价值在于:如果不写,
空数据时界面会显示「找回率 NaN%」,而这个 bug 在数据充足时永远不会暴露。
(4)异常入参的健壮性测试
it('用例8:数据源为空数组或非法入参时,应安全返回空数组', function () {
expect(Store.searchItems([], { keyword: '任意' })).to.have.lengthOf(0);
expect(Store.searchItems(null, { keyword: '任意' })).to.have.lengthOf(0);
expect(Store.searchItems(undefined, {})).to.have.lengthOf(0);
});
it('用例32:边界 —— 传入 null / undefined / 非对象时应安全返回失败', function () {
expect(Store.validateItem(null).valid).to.equal(false);
expect(Store.validateItem(undefined).valid).to.equal(false);
expect(Store.validateItem('字符串').valid).to.equal(false);
});
设计说明:传入
null和传入'字符串'是两种不同性质的错误(空值 vs 类型错误),
我们特意都测了。因为typeof '字符串' === 'string',如果代码里只写
if (!data) return ...,字符串会绕过检查然后.title得到undefined,
后续逻辑可能抛出TypeError。
(5)unique 性压力测试
it('用例49:generateId 应返回非空且唯一的字符串 ID', function () {
var ids = {};
for (var i = 0; i < 500; i++) {
var id = Store.generateId();
expect(id).to.be.a('string');
expect(id.length).to.be.greaterThan(5);
expect(ids[id]).to.equal(undefined); // 无重复
ids[id] = true;
}
});
设计说明:
generateId用「时间戳 + 随机串」实现,理论上存在碰撞可能。
循环 500 次检测重复,是验证随机部分足够分散的实用做法。
500 次在毫秒级完成,不会拖慢测试。
6.3 构造测试数据的思路
数据构造的整体策略
我们没有随手拿几条数据测,而是刻意设计了一份能覆盖各种组合的测试数据集 buildTestItems():
function buildTestItems() {
var today = new Date();
function iso(daysAgo) {
var d = new Date(today.getTime() - daysAgo * 86400000);
return d.toISOString();
}
function dateStr(daysAgo) { return iso(daysAgo).slice(0, 10); }
return [
{ id: 'T1', type: 'lost', title: '校园卡', category: '证件卡类',
place: '图书馆三楼', date: dateStr(1), description: '蓝色卡套,学号 2023 开头',
contact: { phone: '13800000001', im: 'user_t1' }, status: 'open', createdAt: iso(1) },
{ id: 'T2', type: 'found', title: 'AirPods 耳机', category: '耳机音响',
place: '紫金楼 201', date: dateStr(2), description: '白色,盒盖写有字母 Z',
contact: { phone: '13800000002', im: 'user_t2' }, status: 'closed', createdAt: iso(2) },
{ id: 'T3', type: 'lost', title: '黑色雨伞', category: '服饰穿戴',
place: '第三食堂门口', date: dateStr(3), description: '伞骨变形,挂云朵挂件',
contact: { phone: '13800000003', im: '' }, status: 'open', createdAt: iso(3) },
{ id: 'T4', type: 'found', title: '宿舍钥匙', category: '钥匙门禁',
place: '12 号楼门口', date: dateStr(5), description: '挂蓝色恐龙挂饰,两把钥匙',
contact: { phone: '', im: 'wechat_t4' }, status: 'closed', createdAt: iso(5) },
{ id: 'T5', type: 'lost', title: '考研英语真题', category: '书籍资料',
place: '图书馆二楼', date: dateStr(8), description: '黄皮书,有大量铅笔笔记',
contact: { phone: '13800000005', im: 'user_t5' }, status: 'open', createdAt: iso(8) }
];
}
每一维度的设计意图
| 维度 | 设计 | 意图 |
|---|---|---|
| 类型 | 3 条 lost + 2 条 found | 验证类型筛选能正确分类,且数量不等可发现「硬编码数量」的错误 |
| 状态 | 3 条 open + 2 条 closed | 验证状态筛选与找回率计算(2/5 = 40%) |
| 地点 | T1、T5 都在「图书馆」,T2 在「紫金楼」 | 验证关键词搜「图书馆」能命中 2 条(多条命中场景,而非只有 1 条) |
| 描述词 | T3 有「挂件」,T4 有「挂饰」,都用「挂」字能命中 | 验证搜索在描述字段生效,且子串匹配正确 |
| 大小写 | T2 标题是 AirPods(大写 P) |
用小写 airpods 搜索验证大小写不敏感 |
| 联系方式 | T3 只有手机号、T4 只有 IM、T1/T2/T5 两者都有 | 覆盖「二选一」校验的两条分支,以及渲染层「只显示一种」的情况 |
| 时间 | 1/2/3/5/8 天前 | 精心设计:前 4 条在 7 天内、T5 在 8 天外,用于验证「近 7 天统计只含 4 条」 |
| 分类 | 5 条各属不同类别 | 验证类别聚合正确,且各类别数量之和等于总数 |
我们是如何考虑各种情况的
- 正常路径(Happy Path):用例 1、16、23、33 —— 确认基础功能可用;
- 边界值(Boundary):用例 24/25(标题长度 1 和 31)、用例 28(未来日期)、
用例 39(空数据除零)—— 专门测「恰好越界」的位置,这是 bug 最集中的地方; - 异常路径(Error Path):用例 8、20、32、48、54 —— 传
null、不存在的 id、
非法日期、损坏的 JSON,验证程序不崩溃且行为可预期; - 组合与交互:用例 13、14 —— 多条件组合,包括「组合后必然为空」的互斥场景;
- 不变量(Invariant):用例 34(closed + open === total)、用例 38
(各分类之和 === total)—— 这类断言不依赖具体数值,而是验证业务逻辑的内在一致性,
即使将来测试数据变了也不会失效; - 纯函数性:用例 15、18、43 —— 验证函数不修改入参,保护架构约定。
如何考虑将来测试人员的刁难
我们自己扮演了一次「刁难的测试人员」,问了自己几个问题:
| 可能的刁难 | 我们的防御 |
|---|---|
| 「搜索空字符串会怎样?」 | 用例 6 断言返回全部数据 |
| 「搜索带前后空格的关键词呢?」 | 用例 5 验证自动 trim |
| 「搜一个绝对搜不到的词,返回 null 还是空数组?」 | 用例 7 明确要求返回空数组(不是 null),因为渲染层用 .length 判断 |
| 「传个不存在的 id 去更新状态?」 | 用例 20 验证不报错、其他记录不受影响 |
| 「会不会把原数组改了?」 | 用例 15、18、43 三处验证 |
| 「空数据时统计会不会崩?」 | 用例 39、40 验证 |
| 「localStorage 里是坏数据怎么办?」 | 用例 54 验证降级为空数组 |
| 「手机号传个 5 位数会怎样?」 | 用例 46 验证原样返回而非错误截断 |
| 「示例数据本身合法吗?」 | 用例 57、58、59 反过来验证内置数据的质量 |
| 「ID 会不会重复?」 | 用例 49 循环 500 次检测 |
对测试设计本身的评价(作业要求「结合测试评估的要求,对自己的测试设计进行评价」):
我们认为这 61 个用例基本能满足本项目的测试要求,理由如下:
✅ 覆盖完整:
store.js的 15 个导出函数全部被覆盖,没有测试盲区;
✅ 深度足够:每个函数都覆盖了正常路径、边界值、异常路径三类;
✅ 可自动化:npm test一条命令跑完,58 毫秒出结果,可以放进每日构建;
✅ 可回归:断言带中文说明,失败的用例能直接定位到被破坏的业务规则。⚠️ 但我们也清楚它的局限:
- 未覆盖视图层(
views.js的 HTML 拼接)和入口层(app.js的事件绑定)。
这两层依赖 DOM,需要 jsdom 或 Puppeteer 这类工具才能测,超出了本次作业范围。
我们通过人工走查 + 手工点击测试来覆盖这部分;- 未做覆盖率统计。可以引入
nyc(Istanbul)生成覆盖率报告,
但会增加依赖,与「零依赖」的架构决策冲突;- 未测浏览器兼容性。作业统一要求用 Chrome,所以没有做跨浏览器测试。
如果后续要继续完善,我们会优先补 Puppeteer 端到端测试,
用脚本自动完成「打开页面 → 发布一条 → 搜索它 → 打开详情 → 标记已找到」
这条主流程,把目前只能手工验证的部分也自动化起来。
7. GitHub 代码签入记录
7.1 Commit 记录
📌 ** 下图是主仓库的提交记录(截图自 GitHub 的 Commits 页面):
**

7.2 Commit 信息规范
我们参照《Commit message 和 Change log 编写指南》采用了 type: 描述 的格式:
| 类型 | 含义 | 示例 |
|---|---|---|
feat |
新功能 | feat: 实现关键词搜索与多条件筛选 |
fix |
修 bug | fix: 修复空数据时找回率显示 NaN 的问题 |
docs |
文档 | docs: 补充 README 目录说明与运行指南 |
test |
测试 | test: 新增 61 个单元测试用例 |
style |
样式 | style: 优化卡片悬停动效与移动端适配 |
refactor |
重构 | refactor: 将状态更新改为不可变实现 |
7.3 按功能点拆分的提交顺序
按照「每做完一个功能,编译成功后至少 commit 一次」的要求,我们的提交大致是:
feat: 初始化项目结构与基础样式
feat: 实现数据层 store.js(本地存储读写)
feat: 实现物品卡片列表渲染
feat: 实现发布表单与表单校验
feat: 实现关键词搜索功能
feat: 实现类型/类别/状态多条件筛选
feat: 实现详情抽屉与联系方式展示
feat: 实现一键复制联系方式(含降级方案)
feat: 实现手机号脱敏保护
feat: 实现状态标记为已找到/已归还
feat: 实现状态回滚功能
feat: 实现数据统计看板与 CSS 图表
feat: 实现「我的发布」管理页
feat: 添加 10 条校园示例数据
style: 优化响应式布局,适配移动端
fix: 修复复制在 file:// 协议下失效的问题
fix: 修复近 7 天统计在零数据时出现 NaN
test: 搭建 Mocha + Chai 测试环境
test: 新增 61 个单元测试用例
docs: 撰写 README 目录说明与使用说明
docs: 撰写结对编程博客
7.4 Pull Request 协作记录
📌 **下图是仓库的 Pull Request 记录(Pull requests → Closed),可见已合并的 PR #1:

姜思源 fork 仓库后在自己的分支上开发,每完成一个模块就发起一次 Pull Request,
松格拉 在合并前做 code review。这样可以保证主仓库的每一次提交都是可运行的。

8. 遇到的代码模块异常或结对困难及解决方法
问题一:一键复制功能在 file:// 协议下失效
问题描述
「一键复制联系方式」在本地开发生成的页面上点击没有任何反应,控制台报错:
NotAllowedError: Failed to execute 'writeText' on 'Clipboard':
Document is not focused. / Write permission denied.
做过哪些尝试
- 一开始怀疑是按钮事件没绑上,在事件处理里加
console.log确认——事件是触发了的; - 检查发现
navigator.clipboard.writeText()返回的 Promise 被 reject,但我们的代码
当时只写了.then()没写.catch(),导致错误被静默吞掉,表现成「点击无反应」; - 搜索资料后确认根因:
navigator.clipboard属于安全上下文 API,在file://协议下
浏览器会拒绝授权。而本作业恰恰要求「双击 HTML 直接运行」,正好踩中这个坑; - 尝试用
document.execCommand('copy')替代——在file://下可用; - 但
execCommand已经标记废弃,未来可能被移除,不能只用它。
是否解决:✅ 已解决
解决方案:采用双层降级策略——优先用现代 navigator.clipboard,
.catch() 中回退到 document.execCommand;如果浏览器连 clipboard 都没有,直接走降级。
两种路径都给用户 Toast 反馈。
有何收获
- 学到了「API 可用性 ≠ API 能用」。
navigator.clipboard在现代浏览器里都存在,
但在file://下不可用。这提醒我们:部署形态会影响 API 可用性,写代码时要考虑
最终运行环境,而不是只在开发环境测试; - 学到了 Promise 一定要
.catch()。这次的「点击无反应」就是因为 unhandled rejection
被静默忽略,排查时多花了不少时间; - 加深了对「渐进增强 / 优雅降级」的理解——不是所有用户都在最新浏览器和 HTTPS 环境下。
问题二:单元测试首次运行有 1 个用例失败——「测试数据设计错误」
问题描述
写完 61 个用例后第一次运行,显示:
59 passing
1 failing
1) 【搜索】searchItems - 关键词匹配
用例2:应能匹配详细描述中的文字(关键词「挂饰」):
AssertionError: expected [ { id: 'T4', ... } ] to have a length of 2 but got 1
做过哪些尝试
- 第一反应是「搜索函数有 bug」,但仔细看数据后发现是我自己写错了测试数据:
T3 的描述是「挂云朵挂件」,T4 的描述是「挂蓝色恐龙挂饰」——
「挂件」和「挂饰」虽然都有「挂」字,但没有「挂饰」这个共同的连续子串,
所以搜「挂饰」只能命中 T4; - 确认
searchItems本身逻辑正确(子串匹配、多字段合并都对); - 思考如何修改:是改测试数据,还是改断言?
是否解决:✅ 已解决
解决方案:选择同时修改数据断言并补充用例,而不是简单地把 lengthOf(2) 改成 1:
- 把用例 2 的关键词改为「挂」(能同时命中两条),保留「多条命中」的验证意图;
- 新增用例 2b,用「恐龙」这个只出现在 T4 描述中的词,专门验证
「关键词只出现在描述字段、不出现在标题和地点时也能命中」。
这样既修正了错误,又让测试覆盖更全面。
有何收获
- 测试代码也是代码,同样需要设计。这次失败不是产品 bug,而是测试设计缺陷。
如果当初随手编数据,很可能写成「T3 描述含挂饰、T4 描述含挂件」这种想当然的数据,
然后为了让测试通过而把断言改成lengthOf(1),那就把测试的意义彻底丢掉了; - 学会了区分两种情况:「产品代码错了」vs「测试写错了」。前者要改源码,
后者要改测试。绝不能为了让测试通过而弱化断言,那等于自欺欺人; - 体会到「失败信息足够详细」的重要性。因为断言里写了中文描述、
.with.lengthOf(2)
也给出了期望值,一眼就能定位问题,省去了大量排查时间。
结对困难:对「界面美观」标准的理解不一致
问题描述
我个人认为功能性最重要,界面「能用就行」;但是姜思源希望多做视觉效果(渐变、阴影、动效)。
两人在样式代码上产生了分歧:我 觉得 姜花了太多时间在「不重要的地方」,
但她 觉得 我 的界面「太朴素,作业评分里美观性占 10 分」。
做过哪些尝试
- 一起重新读了一遍评分规则,发现美观性确实占 10 分,而且规则里明确写了
「朴素不加修饰取最低分 5 分」——说明作业是明确鼓励做视觉打磨的; - 也发现 我 的担忧有道理:如果时间都花在美化上,测试和文档就会赶不上 deadline;
- 于是我们做了时间分配的约定。
是否解决:✅ 已解决
解决方案
- 明确优先级:先把「发布—浏览/搜索—详情—联系—更新状态」这条主流程跑通(对应功能性 15 分),
确认功能完备后,再投入时间做视觉; - 把美观工作集中在
style.css:因为 CSS 与逻辑完全解耦(这正是我们分层架构的收益),
所以美化过程不会引入功能 bug,风险可控; - 我 负责跑一遍
npm test确认 61 个用例仍然全绿,作为「美化没有破坏功能」的守门人。
有何收获
- 分歧的解法是回到评分规则,用「共同目标」而非「个人偏好」来做决策。
与其争论「要不要美化」,不如问「评分规则怎么说的,剩下的时间够不够」; - 体会到了好的架构能让协作更顺畅。如果 CSS 和 JS 混在一起,
姜思源 改样式的过程很可能改坏 我 的逻辑,分歧会升级成技术冲突。
正因为我们把样式隔离在style.css、逻辑集中在store.js,
两人的工作才能并行推进、互不干扰; - 学会了给非功能性需求设定时间和范围的边界,而不是简单地争论「要不要做」。
9. 评价队友
9.1 对松格拉的评价
值得学习的地方
- 架构意识强。是松格拉坚持要把业务逻辑抽到独立的
store.js里、不碰 DOM。
当时我觉得「多写一个文件有点麻烦」,但后来写单元测试时才发现,
正因为这个决定,61 个用例才能直接require断言,不用 jsdom、不用启浏览器。
这个技术判断力值得学习; - 严谨。松格拉写代码会主动考虑边界情况(空数组、null、除零),
写测试时也是先想「哪些地方可能出错」再动手。
看板里「空数据找回率显示 NaN」这个 bug 就是松格拉在写用例 39 时提前发现的; - 守时守约。每次约定好的模块交付节点都能按时完成,且交付前会自测一遍。
需要改进的地方
- 过度追求「完备」。做数据层时想一次性把所有能想到的函数都写出来
(比如还想加一个「按距离排序」),导致初期进度偏慢。
建议采用「够用就好,先跑通主流程」的迭代思路; - 不擅长表达。技术方案讲得比较简略,有时松格拉听不懂也不太好意思追问,
导致理解偏差后返工。建议多用流程图或简单例子沟通。
9.3 我们两个人的共同收获
最大的收获是理解了「结对编程」不是「分工干活」。
作业初期我们其实是「各做各的」——松格拉写逻辑,我写界面,最后合并时才发现接口对不上:
松格拉的 searchItems 返回的是数组,但我的渲染函数期望的是一个带 total 字段的对象。
这次返工让我们意识到,结对的核心价值在于「实时的设计对齐」,
而不是把工作量对半分。
后来我们调整了方式:每个模块开工前先用 10 分钟口头对齐接口
(函数名、参数、返回值结构),对接成本大幅降低。
这 10 分钟花得非常值——它避免了后面几个小时的返工。
10. 程序实现成果展示
📌 **下面是程序的实际运行截图+GIF(全部在 Chrome 中双击 index.html 打开后截取)。
**

- 首页:Hero 搜索区 + 统计数字 + 最新发布卡片列表

- 全部信息页:类型标签 + 类别/状态筛选器 + 筛选结果

- 详情抽屉:物品信息 + 脱敏手机号 + 一键复制按钮

- 数据看板:四张指标卡 + 近 7 天柱状图 + 类别分布条形图

- 单元测试报告页:绿色的
61 passing

功能演示路径
① 打开 index.html
↓ 首页显示统计数字与最新 8 条信息
② 在搜索框输入「图书馆」→ 回车
↓ 跳转全部信息页,筛选出 2 条结果
③ 点击任意卡片
↓ 右侧滑出详情,手机号显示为 138****5678
④ 点击手机号
↓ 展开为完整号码,Toast 提示「已显示完整手机号」
⑤ 再点一次手机号
↓ Toast 提示「已复制:13812345678」
⑥ 点击「✅ 我已找到,标记为已找到」
↓ 确认弹窗 → 状态徽章变为灰色「已找到」,列表同步刷新
⑦ 点击导航栏「数据看板」
↓ 找回率数字上升,柱状图与类别分布正常渲染
⑧ 打开 test/test.html
↓ 显示 61 passing
11. 最后的话
这次作业我们最大的感受是:「做一个能跑的程序」和「做一个别人能跑起来的程序」
之间隔着很多细节。
比如「一键复制」这个看起来 10 行代码就能搞定的功能,因为要适配 file:// 协议
而不得不写降级方案;再比如「零依赖」这个决定,意味着所有第三方库都要下载到本地、
所有图表都要手写 CSS。但正是这些选择,让助教双击 index.html 就能看到完整效果,
让测试环节没有任何环境障碍。
如果重来一次,我们会在项目开始时就用 10 分钟做技术预研(尤其是
「浏览器在要求的运行环境下,哪些 API 不可用」),而不是等到踩坑再补救。

浙公网安备 33010602011771号