结对作业二:校园失物招领小程序实现
结对作业二:校园失物招领小程序实现
| 这个作业属于哪个课程 | H202601软件工程与软件工程实践 |
|---|---|
| 这个作业要求在哪里 | 作业要求 |
| 这个作业的目标 | 实现前一次的原型模型 |
| github链接 | 102402137-102402139 |
| 成员1 | 102402137 翁齐文 |
| 成员2 | 102402139 徐张睿 |
一、具体分工
| 成员 | 学号 | 主要负责 |
|---|---|---|
| 翁齐文 | 102402137 | 仓库搭建、数据层 store.js、首页/搜索页、UI 与交互迭代、README |
| 徐张睿 | 102402139 | 发布/详情/我的页面、单元测试用例编写、博客整理、功能走查 |
二、PSP 表格
| PSP2.1 阶段 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|
| 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 自测与修改 | 40 | 60 |
| Reporting 报告 | 90 | 110 |
| — Test Report 测试报告 | 30 | 40 |
| — Size Measurement 工作量统计 | 10 | 10 |
| — Postmortem 事后总结 | 50 | 60 |
| 合计 | 600 | 770 |
三、解题思路与设计实现
3.1代码实现思路
整个项目按三层组织,层与层之间单向依赖:
页面层(index.js / publish.js / detail.js / my.js / search.js)
↓ 只调用
数据层 store.js ←→ localStorage
↑ 共用
公共层 common.js(转义、高亮、卡片渲染、复制、toast)
- 数据层
store.js:是唯一碰数据的地方。对外暴露add / updateItem / getById / updateStatus / remove / query / paginate / findMatches / getStats等函数。它不引用任何 DOM 对象,判断浏览器还是 Node 全靠typeof localStorage,所以同一份代码既能在浏览器跑、又能被 Node 直接require来单测。 - 公共层
common.js:放跨页面复用的纯函数。比如把一条物品数据渲染成卡片的cardHtml(),首页、搜索页、我的页三处共用,避免复制粘贴。 - 页面层:每个页面只做三件事——调 Store 拿数据、用 common 渲染成 HTML、给按钮绑事件。页面之间通过 URL 参数通信(
detail.html?id=xxx\&from=my)。
校验是数据层的一部分:Store.validate(raw) 返回 {ok, errors, item},发布和编辑复用同一套校验,不会出现“发布能过、编辑反而能绕过”的漏洞。
3.2关键实现的流程图 / 数据流图
程序运行的整体流程

整体数据流
发布一条信息的时序
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 = String(text == null ? '' : 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>,连命中片段本身也不例外,保证高亮功能本身不会变成漏洞入口。
四、附加特点设计与展示
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、最新 / 最热排序可以叠加使用。
② 实现思路
筛选逻辑集中在 Store.query(),每加一个条件就是一条 continue:
function query(opt) {
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.keyword) {
var hay = (it.title + ' ' + it.description + ' ' + it.location + ' ' + it.category).toLowerCase();
if (hay.indexOf(opt.keyword.toLowerCase()) === -1) continue;
}
out.push(it);
}
// 排序:处理中在前,已解决沉底
...
}
胶囊按钮的选项是从 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联系方式一键复制
① 意义
详情页里电话、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(() => true, fallback);
}
return Promise.resolve(fallback());
}
④ 成果展示

4.4失物招领最终成果简要展示
|
|
|
|
|
|
|
||
五、目录说明与运行方法
目录结构
102402137-102402139/
├── index.html # 首页:浏览 + 搜索框 + 类型/分类/地点筛选 + 排序 + 加载更多
├── search.html # 搜索页:关键词搜索 + 热门搜索 + 搜索历史
├── 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 测试
│ ├── common.test.js # 公共函数测试
│ ├── flow.test.js # 业务主流程集成测试
│ ├── pages.test.js # 页面静态一致性检查
│ └── dom.flow.test.js# DOM 级集成测试(可选,需 jsdom,未装自动跳过)
├── package.json # 只有 test 脚本,无运行依赖
└── README.md # 本文件
组织思路:
js/store.js是唯一数据入口,所有页面通过它读写;数据层不碰 DOM,将来换后端只需改这一层。js/common.js放跨页面复用的纯函数(转义、高亮、时间、卡片渲染、复制、悬浮预览)。- 每个页面对应一个同名
.js,职责单一:取数据 → 渲染 → 绑事件。 - 脚本都是普通
<script>,没有模块化 import,所以file://直接打开也能跑。
运行方法
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. 使用说明
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| ① 发布 | 首页点「我丢了东西 / 我捡到东西」→ 填必填项 → 立即发布 | 发布成功页,显示编号与摘要;若有相关信息会提示“智能配对” |
| ② 校验 | 不填必填项直接发布 | 顶部出现红色错误清单,不白屏 |
| ③ 搜索 | 输入「雨伞」「校园卡」 | 列表实时过滤,命中字黄色高亮;搜不到有空状态提示 |
| ④ 筛选 | 点分类/地点胶囊、寻物招领 tab、最新/最热 | 列表内容随之变化 |
| ⑤ 悬浮预览 | 鼠标停在任意卡片上 | 旁边浮出简介、照片、地点时间 |
| ⑥ 详情 | 点任意卡片 | 进入详情:分类/地点/时间/编号/描述/照片 |
| ⑦ 联系 | 详情页点「联系失主/拾取人」 | 显示联系方式,点「复制」提示已复制 |
| ⑧ 状态 | 打开自己发的那条,底部「发布者管理」 | 可标记已找到/已归还、编辑、删除 |
| ⑨ 我的 | 底部导航进「我的」 | 个人资料卡 + 我的发布;点铅笔可改昵称/头像/学院/校区/联系方式 |
六、单元测试(10 分)
6.1 选用的测试工具、学习过程与简易教程
选用工具:Node.js 自带的 node:test + node:assert/strict。
我们是边查文档边写的:先看 Node 官方文档里 node:test 的例子,再对照项目里 store.js 的函数签名一个个补用例。
自己写一个测试的最小教程:
# 1. 确认 Node >= 18
node -v
# 2. 跑全部测试
npm test
# 等价于:
node --test test/store.test.js test/common.test.js test/flow.test.js test/pages.test.js
# 3. 只跑某个文件
node --test test/store.test.js
为什么 store.js 能在 Node 里直接测?因为它结尾做了环境判断:
if (typeof module !== 'undefined' && module.exports) module.exports = Store;
else window.Store = Store;
浏览器里挂到 window,Node 里走 module.exports,同一份业务代码不用改一行。Node 里没有 localStorage,我们在 test/helpers.js 里用一个 Map 自己实现了同样的接口:
function createLocalStorage() {
var map = new Map();
return {
getItem(k) { return map.has(String(k)) ? map.get(String(k)) : null; },
setItem(k,v){ map.set(String(k), String(v)); },
removeItem(k){ map.delete(String(k)); },
clear() { map.clear(); }
};
}
每条用例开始前都换一个全新的内存版 localStorage 并 Store._reset(),所以用例之间互不污染、可以随便重复跑。
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(x => x.id), ['b', 'a']);
});
});
测的是哪些函数:
| 测试文件 | 测的函数 |
|---|---|
store.test.js |
validate 表单校验、add/updateItem/updateStatus/remove、query 搜索筛选排序、paginate 分页、findMatches 配对、getStats 统计 |
common.test.js |
escapeHtml、highlight 高亮、fmtTime 时间格式化、cardHtml 卡片渲染 |
flow.test.js |
发布→搜索→详情→标记状态→编辑 删除 的主流程贯通 |
pages.test.js |
静态检查:HTML 里的 id、脚本路径、页面互链、UTF-8 编码 |
6.3 测试数据构造思路 & 怎么防 “刁难”
按等价类划分 + 边界值分析来设计:
- 每个字段分合法 / 非法 / 缺失三类:手机号取合法
13800138000、第二位2开头非法、长度 10 位不足、含字母13800138000a各一组; - 边界取两点:长度限制的字段测
n和n+1—— 标题 30/31 字、地点 50/51 字、QQ 5/12/13 位; - 白盒覆盖分支:
store.js里每个if都至少有一条用例走到 “真” 和 “假”。比如updateStatus只接受active/resolved,就分别测合法值、非法值'done'、以及一个根本不存在的 id; - 坏数据兜底:
localStorage被写成{坏JSON、记录缺字段、null、全是空格、不是数组 —— 断言 “不抛异常 + 有兜底值 + 不破坏已有数据”; - 用例互不干扰:每条用例开始都换新存储并
_reset(),所以测试可以乱序、重复跑,这是回归测试的前提。
面向 “将来测试人员会怎么挑刺”:
- 可能拿特殊字符试 XSS →
escapeHtml和highlight都有专门用例,断言<img不会出现在输出里; - 可能说 “搜索只匹配了标题” → 用例明确覆盖描述、地点、分类字段,并写死预期条数;
- 可能换台电脑、清掉浏览器数据 → 测了 “首次访问自动播种”“导出再导入”;
- 可能点一个已被删除的详情页 →
dom.flow.test.js里有 “id 不存在时给友好提示” 的用例; - 不写
assert.ok(true)这种假测试 —— 每条断言都对着一条具体业务规则。
七、GitHub 签入记录

八、遇到的问题与解决
| 问题 | 尝试 | 结果 | 收获 |
|---|---|---|---|
| 照片 base64 直接存导致 localStorage 超限 | 先直接存,发大图报 QuotaExceeded | 用 canvas 压到 480px/0.72 质量 | 上传类功能必须先想体积 |
| 悬浮预览在卡片边缘被截断 | 初始固定右侧 | 改为跟随鼠标并做边界翻转 | 浮层要算视口边界 |
file:// 下 clipboard API 不可用 |
直接用 navigator.clipboard | 加 execCommand 降级 | 本地打开和线上环境行为不同 |
九、评价队友
值得学习的地方:这次结对中,搭档在测试走查和边界情况上帮了大忙。一开始我只顾着把 “发布成功” 这条主流程跑通,是他对着界面一条一条点,找出了好几个我漏掉的异常路径:必填项留空直接点发布会不会白屏、搜索一个根本不存在的词、点进一条已经被删掉的详情页。
需要改进的地方:需求对齐可以更早一点。中间有一次他直接按自己的想法加了一个功能,结果和我们砍范围的决定冲突,返工了一轮。如果下次开工前先把 “这次做什么、明确不做什么” 列成一张表双方确认,能少走弯路。整体来说配合很顺畅,下次继续

浙公网安备 33010602011771号