结对作业二:校园失物招领小程序实现
结对作业二:校园失物招领小程序实现
| 这个作业属于哪个课程 | H202601软件工程与软件工程实践 |
|---|---|
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次之程序实现) |
| 这个作业的目标 | 实现前一次的原型模型:把「福大拾光」校园失物招领原型做成可运行的 Web 应用,完成"发布—浏览/搜索—查看详情—联系发布者—更新状态"的核心流程 |
| 结对同学博客 | 翁齐文 |
| 本作业博客链接 | 结对作业二:校园失物招领小程序实现 |
| GitHub 仓库地址 | 102402137-102402139 |
| 成员1 | 102402137 翁齐文 |
| 成员2 | 102402139 徐张睿 |
结对表已在班级群统计表中填写,GitHub 项目地址已同步填写(fork + Pull Request 流程见第七节)。
一、具体分工
| 成员 | 学号 | 主要负责 |
|---|---|---|
| 翁齐文 | 102402137 | 仓库搭建与分支/PR 流程、数据层 store.js(校验、增删改查、搜索筛选排序、分页、状态维护、智能配对、统计)、首页与搜索页、整体 UI 与交互迭代、README |
| 徐张睿 | 102402139 | 发布页 / 详情页 / "我的"页、单元测试用例设计与编写(test/ 129 条)、功能走查与边界情况排查、博客整理 |
两人都参与了需求讨论、界面走查与代码复审;徐张睿 fork 仓库后在分支上完成功能,通过 Pull Request 合并(见第七节),翁齐文负责 review 与合并。
二、PSP 表格
| PSP2.1 阶段 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 40 |
| · Estimate | · 估计这个任务需要多少时间 | 30 | 40 |
| Development | 开发 | 480 | 620 |
| · Analysis | · 需求分析(包括学习新技术) | 40 | 60 |
| · Design Spec | · 生成设计文档 | 30 | 30 |
| · Design Review | · 设计复审 | 20 | 20 |
| · Coding Standard | · 代码规范 | 10 | 10 |
| · Design | · 具体设计 | 60 | 80 |
| · Coding | · 具体编码 | 280 | 380 |
| · Code Review | · 代码复审 | 40 | 50 |
| · Test | · 测试(自测、修 bug、提交) | 40 | 60 |
| Reporting | 报告 | 90 | 110 |
| · Test Report | · 测试报告 | 30 | 40 |
| · Size Measurement | · 计算工作量 | 10 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结并提出过程改进计划 | 50 | 60 |
| 合计 | 600 | 770 |
偏差分析与过程改进:
- 超出预估最多的是 Coding(280 → 380):一是"看起来很简单"的交互其实很费时间,比如卡片点击区域、发布成功页的回跳、编辑模式与新增模式共用一套表单;二是中文关键词高亮和悬浮预览的边界处理(右边放不下要翻到左边)反复调了几轮。
- Analysis(40 → 60) 超时,是因为中途砍了一轮范围:最初想把"浏览记录、收藏"也做进去,讨论后发现这些功能解决不了"物归原主"这个核心问题,两个人也没时间做完,于是按老师的提醒收敛到"发布、搜索、详情、状态维护 + 少量附加特点"。
- Test(40 → 60) 超时主要是补边界用例:写完测试才发现"标题 30 字 / 31 字""QQ 12 位 / 13 位"这类临界值有一处判断写反了,如果没有边界用例就会漏掉。
- 下次改进:把"交互走查"单独列成一个任务并预留时间,而不是算在 Coding 里;先写一版可运行的最小骨架,再往上加特点,避免返工。
三、解题思路与设计实现说明
3.1 代码实现思路
整个项目按三层组织,层与层之间单向依赖:

数据层 store.js:唯一碰数据的地方。对外暴露 add / updateItem / getById / updateStatus / remove / query / paginate / summarize / findMatches / getStats / getSearchHistory / getDraft 等函数,内部负责 localStorage 读写与数据格式。它不引用任何 DOM 对象,判断浏览器还是 Node 靠的是结尾这段环境判断:
if (typeof module !== 'undefined' && module.exports) module.exports = Store; // Node:可以被单元测试 require
else global.Store = Store; // 浏览器:挂到 window
所以同一份业务代码既能在 Chrome 里跑,又能被 node --test 直接引入做单元测试,一行都不用改。
公共层 common.js:放跨页面复用的纯函数。比如把一条物品数据渲染成卡片的 cardHtml(),首页、搜索页、我的页三处共用;再比如关键词高亮 highlight()、悬浮预览 initCardPreview()、头像渲染 avatarHtml()、一键复制 copyText()。
页面层:每个页面只做三件事——调 Store 拿数据、用 App 渲染成 HTML、给按钮绑事件。页面之间通过 URL 参数通信(detail.html?id=xxx&from=my,from 决定详情页的"返回"跳回哪里;search.html?kw=雨伞 支持直接带关键词打开)。
校验属于数据层的一部分:Store.validate(raw) 返回 { ok, errors, item },发布与编辑复用同一套规则,因此不存在"发布能过、编辑反而能绕过校验"的漏洞。
3.2 关键实现的流程图 / 数据流图
(1)发布一条信息的时序(含表单校验与智能配对)

校验失败走 alt [校验失败] 分支(顶部红框列错误,不写入数据),校验通过走 alt [校验通过] 分支(unshift + save() 落盘,再查一次智能配对,最后进发布成功页)。
(2)搜索 / 筛选的数据流(一个 query() 搞定所有组合条件)

地点用的是子串模糊匹配:点"图书馆"能命中"图书馆三楼自习区""图书馆门口"等所有包含该词的记录,不需要和原文一字不差。
(3)信息状态流转(可逆,发布者本人操作)

状态字段只有
active / resolved两个取值,updateStatus()对其它取值直接返回false,避免出现第三种状态导致界面判断漏分支。
3.3 重要代码片段与解释
片段一:智能配对
function titleOverlap(a, b) {
a = (a || '').toLowerCase(); b = (b || '').toLowerCase();
for (var i = 0; i < a.length - 1; i++) {
var g = a.substr(i, 2); // 取相邻两字的二元组
if (b.indexOf(g) >= 0) return true;
}
return false;
}
function findMatches(newItem) {
var opposite = newItem.type === 'lost' ? 'found' : 'lost';
return query({ type: opposite }).filter(function (it) {
if (it.id === newItem.id) return false;
if (it.status !== 'active') return false;
if (it.category !== newItem.category) return false;
return titleOverlap(newItem.title, it.title);
}).slice(0, 3);
}
解释:不做站内聊天,也能把"丢的人"和"捡的人"接上。逻辑很克制——必须是相反类型(寻物 ↔ 招领)、同一分类、标题有两字重合、且仍在进行中,才推荐。titleOverlap 用中文二元组粗匹配,"蓝色折叠雨伞"和"藏蓝色折叠雨伞"会命中"折叠/叠雨/雨伞"这些公共片段;不取整串相等(太严格),也不算编辑距离(实现复杂、收益小)。
片段二:关键词高亮——先转义再包 <mark>,防 XSS
function highlight(text, keyword) {
var s = (text == null) ? '' : String(text);
var kw = (keyword == null) ? '' : String(keyword).trim();
if (!kw) return escapeHtml(s);
var lower = s.toLowerCase(), target = kw.toLowerCase();
var out = '', from = 0, idx;
while ((idx = lower.indexOf(target, from)) !== -1) {
out += escapeHtml(s.slice(from, idx)) +
'<mark class="hl">' + escapeHtml(s.slice(idx, idx + kw.length)) + '</mark>';
from = idx + kw.length;
}
return out + escapeHtml(s.slice(from));
}
解释:搜索结果要把命中的字高亮,但物品标题是用户输入的内容,直接拼 HTML 会被 XSS(比如标题写成 <img src=x onerror=alert(1)>)。这里的关键是每一段原文都先过 escapeHtml 再插入 <mark>,连命中片段本身也不例外,保证"高亮"这个功能本身不会变成漏洞入口。同时支持一段文字多处命中、大小写不敏感。
片段三:搜索 + 筛选 + 排序一个函数搞定
function query(opt) {
opt = opt || {};
var kw = (opt.keyword || '').trim().toLowerCase();
var items = getAll(), out = [];
for (var i = 0; i < items.length; i++) {
var it = items[i];
if (opt.type && opt.type !== 'all' && it.type !== opt.type) continue;
if (opt.category && opt.category !== 'all' && it.category !== opt.category) continue;
// 地点用模糊包含:选"图书馆"能命中"图书馆三楼自习区"
if (opt.location && opt.location !== 'all' && it.location.indexOf(opt.location) === -1) continue;
if (opt.status && opt.status !== 'all' && it.status !== opt.status) continue;
if (kw) {
var hay = (it.title + ' ' + it.description + ' ' + it.location + ' ' + it.category).toLowerCase();
if (hay.indexOf(kw) === -1) continue;
}
out.push(it);
}
var sort = SORTS.indexOf(opt.sort) === -1 ? 'newest' : opt.sort;
out.sort(function (a, b) {
var ra = a.status === 'resolved' ? 1 : 0, rb = b.status === 'resolved' ? 1 : 0;
if (ra !== rb) return ra - rb; // 已解决的一律沉底
if (sort === 'views') return (b.views || 0) - (a.views || 0);
if (sort === 'oldest') return a.createdAt - b.createdAt;
return b.createdAt - a.createdAt; // 默认最新发布在前
});
return out;
}
解释:五个筛选维度、三种排序,全部收敛到一个 query() 里,页面只负责传一个 opt 对象。这样加筛选条件(比如后来加的"地点")不用改页面逻辑;非法 sort 参数统一回退到 newest,不会因为前端传了怪值就崩。
片段四:卡片悬浮预览 + 视口边界处理
function move(x, y) {
var w = box.offsetWidth, h = box.offsetHeight;
var left = x - w / 2; // 水平方向以鼠标为中心
var top = y - h - 16; // 默认浮在鼠标上方
if (left < 8) left = 8; // 左边越界 → 贴左
if (left + w > window.innerWidth - 8) left = window.innerWidth - w - 8; // 右边越界 → 贴右
if (top < 8) top = y + 16; // 上方空间不够 → 翻到鼠标下方
box.style.left = left + 'px';
box.style.top = top + 'px';
}
解释:浮层最容易出的问题不是"弹不出来",而是贴着屏幕边缘时被截断、跑到视口外。这里做了三步收敛:水平方向先按鼠标居中,再分别用左右边界钳制;垂直方向优先放在鼠标上方(不挡住卡片内容),上方空间不足时翻到下方。另外浮层设了 pointer-events: none,鼠标不会被它"接住"导致闪烁。
四、附加特点设计与展示
4.1 智能配对提醒
① 意义
传统失物招领页面只是被动地"把信息列出来",丢的人和捡的人还是要自己一条条翻。我们加了一个主动搭桥的小功能:当用户发布完一条信息时,系统自动找一找有没有人刚好捡到了 / 丢了相似的东西,然后在成功页提示一句"你可能要找的是这条,去联系看看"。
它的意义在于:不做聊天、不做地图,就把"失主"和"拾主"这两个本来互不相识的人第一次主动关联起来。对用户来说,从"我发完就只能干等"变成了"刚发完就有人推荐了一条可疑信息";这也是我们对"怎么减少无效联系"的回答——与其让大家互相乱问,不如系统先帮你筛一遍。
② 实现思路
配对条件刻意收得很窄,避免乱推荐:
| 条件 | 说明 |
|---|---|
| 类型相反 | 寻物(lost)只匹配招领(found),反之亦然——同一方向的两条信息不可能是同一件物品 |
| 分类相同 | 都选了"雨具"才可能是同一把伞 |
| 仍在进行中 | 已标记"已找到 / 已归还"的不再推荐 |
| 标题有两字重合 | 中文二元组粗匹配,"蓝色折叠雨伞" ↔ "藏蓝色折叠雨伞"命中"折叠/叠雨" |
满足以上条件的信息最多取 3 条,显示在发布成功页。
③ 代码片段
function titleOverlap(a, b) {
a = (a || '').toLowerCase(); b = (b || '').toLowerCase();
for (var i = 0; i < a.length - 1; i++) {
var g = a.substr(i, 2); // 相邻两字组成的二元组
if (b.indexOf(g) >= 0) return true;
}
return false;
}
function findMatches(newItem) {
var opposite = newItem.type === 'lost' ? 'found' : 'lost';
return query({ type: opposite }).filter(function (it) {
if (it.id === newItem.id) return false;
if (it.status !== 'active') return false;
if (it.category !== newItem.category) return false;
return titleOverlap(newItem.title, it.title);
}).slice(0, 3);
}
解释:titleOverlap 不取整个标题做相等比较(那样太严格,"蓝色雨伞"和"蓝色折叠雨伞"就匹配不上),也不算编辑距离(实现复杂),而是把标题切成所有相邻两字的片段,任一公共片段命中即认为相似。中文两字是最自然的语义单元,对"伞 / 卡 / 耳机"这类短标题特别合适。
④ 成果展示

4.2 物品分类 + 校园地点双重筛选(附排序与加载更多)
① 意义
光靠关键词搜索,用户脑子里得先想出"该搜哪个词"。但很多时候人是按场景找东西的——"我今天在图书馆丢的""在食堂附近捡的"。我们在搜索框下方加了两组一键筛选胶囊:
- 分类:证件卡 / 数码电子 / 生活用品 / 雨具 / 包袋 / 随身物品 / 图书资料 / 其他
- 地点:图书馆 / 食堂 / 教学楼 / 体育馆 / 宿舍 / 校车站 / 实验楼 / 操场
点一下就能筛,不用打字。地点筛选用模糊包含匹配:点"图书馆"能命中"图书馆三楼自习区""图书馆门口"等所有包含该词的记录,不用和原文一字不差。它和关键词、寻物/招领 tab、最新/最热排序可以叠加使用;条数多时列表分页显示,底部给"加载更多(还有 N 条)"。
② 实现思路
筛选逻辑集中在 Store.query(),每加一个条件就是一条 continue(代码见 3.3 片段三)。胶囊按钮的选项不是写死在 HTML 里的,而是从 Store.CATEGORIES 和 Store.HOT_LOCATIONS 两个常量渲染出来的,改一处常量,首页和搜索页同步更新。
③ 代码片段
// 地点筛选:点胶囊 = 选地点关键词(选项来自常量,不写死在 HTML)
function renderLocChips() {
var box = document.getElementById('locChips');
var html = '<span class="chips-label">地点</span>' +
'<span class="chip' + (state.location === 'all' ? ' active' : '') +
'" data-loc="all">全部</span>';
Store.HOT_LOCATIONS.forEach(function (l) {
html += '<span class="chip' + (state.location === l ? ' active' : '') +
'" data-loc="' + App.escapeHtml(l) + '">' + App.escapeHtml(l) + '</span>';
});
box.innerHTML = html;
}
④ 成果展示

4.3 卡片悬浮预览
① 意义
列表里一屏只有几条信息,想知道"这条到底是不是我的东西",只能点进详情页再返回,来回几次很累。我们让鼠标停在卡片上就直接浮出一张简介卡(照片 / 标题 / 描述 / 地点 / 时间),看完移开就消失,不用离开列表就能快速筛掉明显不相关的信息,找到目标再点进去。
② 实现思路
- 预览容器
#cardPreview在common.js初始化时创建一次,后面只改内容与位置,不反复创建 DOM; - 事件绑在
document上(而不是每个列表各自绑一遍),这样首页、搜索页、我的发布三处的卡片共用同一套预览逻辑,页面脚本一行都不用改; - 卡片渲染时带上了
data-id属性,预览时用Store.getById(card.dataset.id)取完整数据(列表里只有摘要,详情字段要现取); - 位置计算:水平居中后左右钳制、垂直优先上方、上方不够翻到下方(见 3.3 片段四);
- 浮层
pointer-events: none,不会被鼠标接住导致抖动;鼠标移出卡片(用relatedTarget判断是否还在卡片内)立即隐藏。
③ 代码片段
// 卡片渲染时带上 data-id,供悬浮预览取数据
'<a class="card" href="' + href + '" data-id="' + escapeHtml(item.id) + '">'
// 悬浮预览:鼠标停在卡片上时浮出简介/照片
function initCardPreview() {
var box = document.getElementById('cardPreview');
if (!box) { box = document.createElement('div'); box.id = 'cardPreview'; document.body.appendChild(box); }
function show(id, x, y) {
var it = (typeof Store !== 'undefined' && Store.getById) ? Store.getById(id) : null;
if (!it) { box.style.display = 'none'; return; }
var photo = it.photo ? '<img class="pv-photo" src="' + it.photo + '" alt="物品照片">' : '';
box.innerHTML =
photo +
'<div class="pv-title">' + typeBadge(it) + ' ' + escapeHtml(it.title) + '</div>' +
'<div class="pv-desc">' + escapeHtml(it.description || '(暂无描述)') + '</div>' +
'<div class="pv-meta">📍 ' + escapeHtml(it.location || '—') + ' · ' + escapeHtml(it.time || '') + '</div>';
box.style.display = 'block';
move(x, y);
}
document.addEventListener('mouseover', function (e) {
var card = e.target.closest('.card');
if (!card) return;
show(card.getAttribute('data-id'), e.clientX, e.clientY);
});
document.addEventListener('mousemove', function (e) {
if (box.style.display === 'block') move(e.clientX, e.clientY);
});
document.addEventListener('mouseout', function (e) {
var card = e.target.closest('.card');
if (card && !card.contains(e.relatedTarget)) box.style.display = 'none';
});
}
initCardPreview();
解释:预览内容里的标题、描述、地点全部经过 escapeHtml —— 悬浮预览是"第二个渲染入口",如果这里忘了转义,XSS 一样会从这个入口进来。
④ 成果展示

4.4 联系方式一键复制
① 意义
详情页里电话、QQ、微信本来就是给人联系失主用的,但手机/电脑上要"长按选中 → 复制 → 切到微信粘贴"很绕。我们在每条联系方式后面放一个"复制"按钮,点一下就进剪贴板并提示"已复制到剪贴板",对方拿到号码直接加好友,少一步操作摩擦。
② 实现思路
现代浏览器有 navigator.clipboard,但本项目是双击 HTML 在 file:// 下打开的,这种协议下浏览器往往不暴露 Clipboard API。所以做了降级:优先用 navigator.clipboard,不可用就创建一个隐藏的 <textarea> 用 document.execCommand('copy') 兜底。
③ 代码片段
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'); return true; }
finally { document.body.removeChild(ta); }
}
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text).then(function () { return true; }, fallback);
}
return Promise.resolve(fallback());
}
④ 成果展示

4.5 其他贴心小设计
| 小设计 | 解决什么问题 |
|---|---|
| 搜索历史(最近 8 条,可单删/清空) | 找东西往往是反复搜的,不用每次重新打字 |
| 关键词高亮 | 一屏好几条信息,直接看到"命中的是哪几个字",确认快 |
| 发布草稿自动保存(400ms 防抖) | 填了一半误关页面/刷新,回来会问"要恢复吗",照片因为体积大不入草稿 |
| 头像上传 | 支持上传一张图片作为头像(canvas 居中裁剪成 120px 方图存为 dataURL,用 avatarHtml() 圆形显示),不想上传也可以用内置 emoji 头像 |
| 空状态与异常兜底 | 搜不到、筛不出、点进已删除的详情页、必填项没填、localStorage 被写坏,都有明确提示,不白屏 |
| "我的"页统计与个人资料 | 我的发布数 / 已完成数 / 总浏览量一眼可见,发布时自动带出联系方式 |
4.6 失物招领最终成果简要展示
| 首页(筛选 + 排序 + 加载更多) | 搜索页(历史 + 高亮) |
|---|---|
![]() |
![]() |
| 详情页(联系 + 状态维护) | 发布页(表单 + 校验) |
|---|---|
![]() |
![]() |
| 我的(统计 + 头像上传) | 悬浮预览 |
|---|---|
![]() |
![]() |
五、目录说明与运行方法
5.1 目录结构
102402137-102402139/
├── index.html # 首页:浏览 + 搜索框 + 类型/分类/地点筛选 + 排序 + 加载更多
├── search.html # 搜索页:关键词搜索 + 热门搜索 + 搜索历史 + ?kw= 深链接
├── publish.html # 发布/编辑页:寻物招领表单 + 校验 + 照片/图标 + 草稿 + 发布成功页
├── detail.html # 详情页:物品详情 + 联系方式一键复制 + 发布者状态维护/编辑/删除
├── my.html # 我的:个人资料卡 + 编辑弹窗(昵称/头像上传/学院/校区/联系方式)+ 我的发布
├── css/
│ └── style.css # 全部样式(居中手机式应用列,含悬浮预览等)
├── js/
│ ├── store.js # 【数据层】localStorage 增删改查、校验、搜索/筛选/排序/分页、
│ │ # 状态维护、统计、智能配对、搜索历史、草稿;兼容浏览器与 Node
│ ├── common.js # 【公共层】HTML 转义、关键词高亮、时间格式化、卡片渲染、
│ │ # 悬浮预览、一键复制、toast
│ ├── index.js # 首页逻辑
│ ├── search.js # 搜索页逻辑
│ ├── publish.js # 发布/编辑表单逻辑
│ ├── detail.js # 详情页逻辑
│ └── my.js # 「我的」页逻辑
├── test/ # 单元测试(不影响网页运行,助教可选择性执行)
│ ├── helpers.js # 测试辅助:内存 localStorage 桩、构造合法数据的工厂函数
│ ├── store.test.js # 数据层 Store 测试(71 条)
│ ├── common.test.js # 公共函数测试(27 条)
│ ├── flow.test.js # 业务主流程集成测试(8 条)
│ ├── pages.test.js # 页面静态一致性检查(10 条,无需浏览器)
│ └── dom.flow.test.js # DOM 级集成测试(10 条,可选,需 jsdom,未装自动跳过)
├── package.json # 只有 test 脚本,没有任何运行依赖
├── .gitignore # 忽略 node_modules 等
└── README.md # 目录说明与使用说明(与本节目的一致)
组织思路:
js/store.js是唯一数据入口,所有页面通过它读写;数据层不碰 DOM,将来换后端只需改这一层。js/common.js放跨页面复用的纯函数(转义、高亮、时间、卡片渲染、悬浮预览、复制),避免复制粘贴。- 每个页面对应一个同名
.js,职责单一:取数据 → 渲染 → 绑事件。 - 脚本都是普通
<script>,没有模块化import,所以file://直接双击也能跑。 test/与网页运行完全解耦:不装 Node、不跑测试也不影响 Chrome 打开index.html。
5.2 测试人员如何运行
第 1 步:拿到代码
git clone https://github.com/wqwabc/102402137-102402139.git
或在 GitHub 页面 Code → Download ZIP 后解压。
第 2 步:打开网页(无需任何环境)
- 保持上面的目录结构不变;
- 用谷歌浏览器 Chrome 双击打开根目录下的
index.html;- 项目按
file://协议即可正常工作(没有用fetch/ ES module); - 若浏览器对本地文件限制较严,也可用 VS Code 的 Live Server 以
http://打开,效果一致;
- 项目按
- 首次打开会自动写入 6 条演示数据(寻物/招领、进行中/已归还等状态都有,其中 3 条带内置示例图片),首页立刻有内容;
- 统一用 Chrome,避免不同浏览器在日期控件、
localStorage上的行为差异。
第 3 步:按这个顺序走一遍(约 5 分钟)
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| ① 发布 | 首页点「我丢了东西 / 我捡到东西」→ 填必填项 → 立即发布 | 发布成功页,显示编号与摘要;若有相关信息会给出"智能配对"提示 |
| ② 校验 | 不填必填项直接发布 | 表单顶部出现红色错误清单,不白屏 |
| ③ 搜索 | 输入「雨伞」「校园卡」 | 列表实时过滤,命中字黄色高亮;搜不到有空状态提示 + "清空全部筛选" |
| ④ 筛选 | 点分类 / 地点胶囊、寻物招领 tab、最新 / 最热 | 列表内容随之变化,可与关键词叠加 |
| ⑤ 悬浮预览 | 鼠标停在任意卡片上 | 卡片旁浮出简介浮层(照片 / 标题 / 描述 / 地点 / 时间),靠近屏幕边缘会自动翻转贴边,移开即消失 |
| ⑥ 详情 | 点任意卡片 | 进入详情:分类 / 地点 / 时间 / 编号 / 描述 / 照片 |
| ⑦ 联系 | 详情页点「联系失主 / 拾取人」 | 显示联系方式,点「复制」提示"已复制到剪贴板" |
| ⑧ 状态 | 打开自己发的那条,底部「发布者管理」 | 可标记已找到 / 已归还、撤回、编辑、删除 |
| ⑨ 我的 | 底部导航进「我的」 | 个人资料卡 + 我的发布 + 总浏览量;点铅笔可改昵称 / 头像(内置 emoji 或上传照片)/ 学院 / 校区 / 联系方式 |
提示:数据保存在浏览器
localStorage中,清除浏览器数据会清空;换电脑演示时重新录几条数据即可(本作业不做后台,数据本来就不跨设备)。
六、单元测试
6.1 选用的测试工具、学习过程与简易教程
选用工具:Node.js 自带的 node:test + node:assert/strict。
为什么不用 Mocha / Jest:本项目是零依赖的纯静态页面,引入测试框架就要让助教 npm install 一整套依赖;而作业里也提到"单元测试不必上传"。用 node:test 的好处是 不装任何东西、一条命令就能跑,也不会因为网络或版本问题跑不起来。它的 describe / it / beforeEach 写法与 Mocha 基本一致,将来要迁到 Mocha 只需改一行 require。
我们是怎么学的:先看 Node 官方文档里 node:test 的最小示例(describe + it + assert),再回到项目里,对着 store.js 的每个导出函数列"它应该满足哪些规则",一条规则写一个 it。写完一个文件的用例就跑一次,看红绿。
自己写一个单元测试的最小教程(5 步):
# 第 1 步:确认 Node >= 18(自带 node:test)
node -v
# 第 2 步:让业务模块在 Node 里能被 require
# store.js 结尾已经做了环境判断:
# if (typeof module !== 'undefined' && module.exports) module.exports = Store;
# else global.Store = Store; // 浏览器
# 同一份代码,浏览器和 Node 都能用,业务逻辑一行不用改。
# 第 3 步:补一个 localStorage 桩(Node 里没有 localStorage)
# 见 test/helpers.js,用一个 Map 实现 getItem/setItem/removeItem/clear
# 第 4 步:写用例 test/mytest.test.js
# const { describe, it } = require('node:test');
# const assert = require('node:assert/strict');
# const Store = require('../js/store.js');
# describe('发布校验', () => {
# it('标题 31 字应当被拒绝', () => {
# assert.equal(Store.validate({ title: '伞'.repeat(31), ... }).ok, false);
# });
# });
# 第 5 步:运行
node --test test/mytest.test.js
运行全部测试:
npm test
# 等价于(不需要安装任何依赖):
node --test test/store.test.js test/common.test.js test/flow.test.js test/pages.test.js
测试隔离:每条用例开始前都换一个全新的内存版 localStorage 并 Store._reset(),所以用例之间互不污染,可以任意顺序、反复执行——这也是回归测试能每天跑的前提。
function createLocalStorage() {
var map = new Map();
return {
getItem: function (k) { return map.has(String(k)) ? map.get(String(k)) : null; },
setItem: function (k, v) { map.set(String(k), String(v)); },
removeItem: function (k) { map.delete(String(k)); },
clear: function () { map.clear(); }
};
}
6.2 部分单元测试代码与说明
const { describe, it, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const h = require('./helpers');
const Store = h.freshStore();
beforeEach(() => { h.installLocalStorage(); Store._reset(); });
describe('Store.validate —— 发布信息校验', () => {
it('合法数据:返回 ok,并自动补全 id / 编号 / 发布者 / 状态', () => {
const res = Store.validate(h.validRaw());
assert.equal(res.ok, true);
assert.match(res.item.code, /^LF\d{11}$/); // 编号格式 LF + 日期 + 序号
assert.equal(res.item.publisher, Store.CURRENT_USER_ID);
assert.equal(res.item.status, 'active');
});
it('标题长度边界:30 字合法、31 字非法', () => {
assert.equal(Store.validate(h.validRaw({ title: '伞'.repeat(30) })).ok, true);
assert.equal(Store.validate(h.validRaw({ title: '伞'.repeat(31) })).ok, false);
});
it('三种联系方式至少填一种', () => {
assert.equal(Store.validate(h.validRaw({
contactPhone: '', contactQq: '', contactWechat: ''
})).ok, false);
assert.ok(Store.validate(h.validRaw({ contactPhone: '', contactWechat: 'wx_abc' })).ok);
});
});
describe('query 排序 —— 进行中在前,已解决沉底', () => {
it('已标记归还的信息排到后面', () => {
h.useItems(Store, [
h.item({ id: 'a', status: 'resolved', createdAt: 100 }),
h.item({ id: 'b', status: 'active', createdAt: 200 })
]);
assert.deepEqual(Store.query().map(function (x) { return x.id; }), ['b', 'a']);
});
});
测了哪些函数:
| 测试文件 | 用例数 | 覆盖的函数 / 规则 |
|---|---|---|
store.test.js |
71 | validate 表单校验、add / updateItem / updateStatus / remove、query 搜索筛选排序、paginate 分页、summarize 统计、findMatches 配对、getStats、搜索历史、发布草稿、exportData / importData / resetAll、个人资料 |
common.test.js |
27 | escapeHtml、highlight 高亮、fmtTime 时间格式化、状态/类型徽标、cardHtml 卡片渲染、moreHtml 加载更多 |
flow.test.js |
8 | 业务主流程贯通:发布 → 浏览/搜索 → 详情 → 联系 → 标记状态 → 编辑/删除 → 备份迁移 |
pages.test.js |
13 | 静态一致性 + 回归保护:JS 用到的 id 在 HTML 里是否存在、脚本/样式表路径、页面互链是否 404、编码是否 UTF-8;以及"头像上传必须用 readAsDataURL""common.js 不能在模块顶层碰 document""存储 Key 变更时测试要一起改"这三条曾经踩过的坑 |
dom.flow.test.js |
10 | 可选(需 jsdom):真跑页面,模拟点击 / 输入,验证筛选、排序、加载更多、搜索历史、发布表单、草稿恢复、详情页状态维护 |
| 合计 | 129 | 远超作业要求的"至少 5 / 10 个测试用例" |
6.3 构造测试数据的思路 & 怎么防"刁难"
1)等价类划分 + 边界值分析
每个字段先分"合法 / 非法 / 缺失"三类,再对长度类字段取 n 与 n+1 两个边界点:
| 字段 | 合法 | 非法 / 边界 |
|---|---|---|
| 手机号 | 13800138000 |
12345678901(第二位非法)、1380013800(10 位)、13800138000a(含字母) |
| 物品名称 | 30 字 | 31 字 |
| 地点 | 50 字 | 51 字 |
| 5 位、12 位 | 4 位、13 位、含字母 | |
| 搜索历史 | 8 条 | 第 9 条要挤掉最旧的一条 |
2)白盒覆盖分支
store.js 里每个 if 都至少有一条用例走到"真"和"假"两个方向。例如 updateStatus 只接受 active / resolved,就分别测:合法值、非法值 'done'、以及一个根本不存在的 id。
3)坏数据兜底
localStorage 被写成 {坏JSON、记录缺字段、null、全是空格、不是数组、导入文件缺 items —— 断言"不抛异常 + 有兜底值 + 不破坏已有数据"。
4)面向"将来测试人员会怎么挑刺"
- 可能拿特殊字符试 XSS →
escapeHtml与highlight都有专门用例,断言输出里不出现<img; - 可能说"搜索只匹配了标题吧" → 用例明确覆盖描述、地点、分类字段,并写死预期条数;
- 可能换台电脑 / 清掉浏览器数据 → 测了"首次访问自动生成演示数据"和"导出再导入";
- 可能点一个已被删除的详情页 →
dom.flow.test.js里有"id 不存在时给友好提示"的用例; - 不写
assert.ok(true)这种假测试,每条断言都对着一条具体的业务规则(例如"发布成功后草稿必须被清除")。
6.4 可选的 DOM 集成测试(jsdom)
test/dom.flow.test.js 用 jsdom 把页面真的跑起来(执行页面脚本、模拟点击 / 输入)。它是可选依赖:没装 jsdom 时整个文件自动跳过(输出里显示 ﹣ 未安装 jsdom…),其余 119 条用例照常运行。
npm install --no-save jsdom # 只装到本地 node_modules,不会写进依赖
npm run test:dom # 或 npm test(装了 jsdom 会自动一起跑)
它覆盖:首页 tab / 分类 / 地点筛选与关键词高亮、超过一页时的「加载更多」、搜索历史读写与删除、?kw= 深链接直接出结果、发布表单校验失败与成功页、草稿自动保存与恢复、详情页联系方式展示与「标记为已找到」、id 不存在时的兜底、我的页统计与恢复演示数据。
测试评估:这套用例满足了本程序的主要测试要求——数据层(最核心、最容易出错的一层)做到了分支级覆盖;页面层用静态一致性检查 + 可选的 DOM 集成测试兜住了"画了界面但点了没反应""链接写错 404""提交 UTF-16 导致乱码"这类问题。不足是没有做真实浏览器(Chrome)的端到端自动化,样式的视觉问题仍然依赖人工走查。
七、GitHub 签入记录
提交历史(主仓库 main):

3567f08 fix: 修复单测跑不起来、头像上传无反应等 4 处问题 ← 徐张睿(PR #2)
9c9d24d docs: 优化README.md ← 翁齐文
2d36f49 docs: 更新README ← 翁齐文
fe76040 feat: 增加悬浮预览的功能(鼠标悬停预览物品详情) ← 翁齐文
7ec593d feat: 优化UI ← 翁齐文
29e54eb Merge pull request #1 from VioletQAQ666/main ← 合并 PR
aa71afa docs: 重写 README(功能清单、目录说明、运行与单测教程) ← 徐张睿
8e0c03a test: 新增 126 条零依赖单元测试(node:test) ← 徐张睿
6f328e5 feat(ui): 页面接入地点筛选、排序、加载更多、关键词高亮与数据管理
fa5a053 feat(data): 数据层新增地点筛选/排序/分页、搜索历史、发布草稿与数据备份
d38e1be feat: 优化界面UI ← 更早的历史提交
协作用的是标准 fork + Pull Request 流程:

- 102402137(翁齐文)创建主仓库
wqwabc/102402137-102402139; - 102402139(徐张睿)fork 出
VioletQAQ666/102402137-102402139,在本地按功能拆分提交,每完成一个功能(数据层 → 页面接入 → 单元测试 → 文档)就 push 一次; - 阶段性完成后向主仓库发起 Pull Request,由翁齐文 review 差异后合并到主仓库
main。
commit 信息的写法:采用 类型(范围): 做了什么 的格式(feat / fix / docs / test),并在正文里分条列出"改了哪些文件、为什么这么改",方便 review 时对照差异。
八、遇到的代码模块异常或结对困难及解决方法
| # | 问题 | 做过的尝试 | 是否解决 | 收获 |
|---|---|---|---|---|
| 1 | 照片以 base64 存 localStorage 导致超限 |
先直接存原图,发大图后抛 QuotaExceededError,之后换 FileReader + canvas 压缩 |
✅ 压到最长边 480px、JPEG 质量 0.72 后再存;草稿不入照片 | 上传类功能必须先想体积上限,别等报错才处理 |
| 2 | 悬浮预览在屏幕边缘被截断(浮层跑出视口) | 一开始固定放在卡片一侧 → 改为跟随鼠标 → 再加"水平左右钳制、上方空间不够就翻到鼠标下方"的收敛逻辑 | ✅ 见 3.3 片段四 | 浮层一定要算视口边界,不能假设屏幕够宽 |
| 3 | file:// 下 navigator.clipboard 不可用,点"复制"没反应 |
查资料确认是协议限制(非安全上下文拿不到 Clipboard API),加 execCommand 降级 |
✅ 两种环境都能复制 | 本地双击打开和线上部署的运行环境不一样,要各测一遍 |
| 4 | 原 README 是 UTF-16 编码,部分编辑器/页面打开是乱码 | 先用 Set-Content/记事本另存都不彻底,最后统一把所有文本文件转成 UTF-8 无 BOM,并在 pages.test.js 里加了一条"禁止 UTF-16、禁止替换字符"的断言 |
✅ 现在 GitHub、Chrome、VS Code 都正常 | 编码问题要用测试守住,靠人眼检查容易漏 |
| 5 | 首页信息一多,一屏滚不到底 | 先全量渲染 → 改成 Store.paginate() 每页 6 条 + "加载更多(还有 N 条)" |
✅ 分页逻辑单独写了 5 条边界用例 | 纯展示逻辑也值得抽成函数并单测,边界(页数越界、非法每页条数)比正常路径更容易出错 |
| 6 | 两人对"要不要做收藏/浏览记录"意见不一致,返工了一轮 | 拉了一次需求对齐:把功能分成"必须/可选/明确不做"三列,按"是否服务于物归原主"排序 | ✅ 砍掉收藏与浏览记录,把时间投到状态维护与测试上 | 开工前先把范围写成一张表双方确认,比事后争论便宜得多 |
| 7 | 合并队友的"悬浮预览"后,单元测试整个文件跑不起来 | npm test 报 ReferenceError: document is not defined;排查发现 common.js 在模块顶层直接调用了 initCardPreview(),浏览器里没问题,Node 里 require 就炸,common.test.js 的 27 条用例全部加载不了 |
✅ 改成 if (typeof document !== 'undefined') initCardPreview();,并在 pages.test.js 加了一条回归断言 |
浏览器与 Node 共用一份代码时,"顶层副作用"是最隐蔽的地雷:写完要问自己一句"这段代码在 Node 里跑会怎样" |
| 8 | 新做的"上传头像"选了照片完全没反应 | 走查时点开选图,选完界面毫无变化;排查发现 FileReader 用的是 readAsText(file),图片被当成文本读,img.onload 永远不触发(写法本身没错,只是读取方式不匹配) |
✅ 改成 readAsDataURL(file) 后正常,并加了回归用例钉住 |
这类"静默失败"没有报错、没有提示,只能靠真机走查发现;凡是"点了没反应",多半是异步回调根本没进 |
| 9 | 队友把存储 Key 从 v1 升到 v2(为了让老数据不干扰新结构),DOM 测试却还在往 v1 里写数据,用例全红 |
一开始以为是页面坏了,逐个 ID 排查后才发现数据根本没被读到 | ✅ DOM 测试同步改成 v2,并加了一条"Key 变了测试要一起改"的断言 |
存储 Key、接口字段这类"契约"变更后,要全局搜一遍调用方;测试用例也是调用方 |
九、评价队友
值得学习的地方
这次结对中,搭档在测试走查和边界情况上帮了大忙。一开始我只顾着把"发布成功"这条主流程跑通,是他对着界面一条一条点,找出了好几个我漏掉的异常路径:必填项留空直接点发布会不会白屏、搜索一个根本不存在的词会不会空白一片、点进一条已经被删掉的详情页会不会报错。后来他把这些路径全部固化成了单元测试与 DOM 测试用例——"把偶然发现的问题变成以后每次都会自动检查的用例",这一点是我以前写作业时从来没做过的,也是这次收获最大的地方。
需要改进的地方
需求对齐可以更早一点。中间有一次他直接按自己的想法加了一个功能,结果和我们之前砍范围的决定冲突,返工了一轮;如果下次开工前先把"这次做什么、明确不做什么"列成一张表双方确认,能少走弯路。另外我们在 commit 粒度上也有过分歧,后来统一成"每完成一个可独立验证的功能提交一次"才好起来。
整体配合很顺畅,下次继续参考这个分工方式。



浙公网安备 33010602011771号