2026秋软件工程第二次结对作业之程序实现——校园失物招领
一、概览信息
-
结对同学:冯安娜(102401402)、李思淇(052401304)
-
我们队创建的仓库 GitHub 地址:https://github.com/Anna-khalil/102401402-052401304
-
在线演示(GitHub Pages):https://anna-khalil.github.io/102401402-052401304/
二、具体分工
| 成员 | 主要分工 |
|---|---|
| 冯安娜 | 建仓与项目骨架、核心业务逻辑(core.js)、数据持久化与渲染层(store.js / ui.js / app.js)、单元测试、README、GitHub Pages + CI、PSP 预估与实际、博客主笔 |
| 李思淇 | fork 仓库与 PR 协作、独立走查回归、测试用例补充、README 与博客互审 |
两人共同:接口约定定稿、核心流程验收(发布→搜索→详情→联系→状态更新闭环)、提交前对照评分规则逐项检查。
三、PSP 表格
实施前先记录预估,实现后补实际耗时并做偏差分析。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 25 |
| Estimate | 估计这个任务需要多少时间 | 20 | 25 |
| Development | 开发 | 575 | 600 |
| Analysis | 需求分析(包括学习新技术) | 60 | 50 |
| Design Spec | 生成设计文档 | 45 | 40 |
| Design Review | 设计复审 | 30 | 30 |
| Coding Standard | 代码规范(为目前的开发制定合适的规范) | 20 | 30 |
| Design | 具体设计 | 60 | 60 |
| Coding | 具体编码 | 240 | 240 |
| Code Review | 代码复审 | 40 | 50 |
| Test | 测试(自我测试,修改代码,提交修改) | 120 | 100 |
| Reporting | 报告 | 100 | 100 |
| Test Report | 测试报告 | 45 | 50 |
| Size Measurement | 计算工作量 | 15 | 15 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 40 | 60 |
| 合计 | 705 | 725 |
四、解题思路描述与设计实现说明
4.1 代码实现思路(文字描述)
本次作业要求「下载全部文件后用谷歌浏览器运行 html 文件就能展现预期结果」,我们据此做了三个关键决策:
- 技术选型:纯 HTML + CSS + 原生 JavaScript,零依赖、零构建。 不用框架、不用构建工具、不需要服务器和
npm install,双击index.html即可运行,最大程度降低测试人员复现成本。 - 数据持久化:localStorage + 项目命名空间。 没有后端,发布的信息必须存在浏览器里。注意一个隐蔽的坑:Chrome 下所有
file://页面共享同一个 origin,键名如果随便起(如items)会与别人的项目数据串扰,因此键名统一带命名空间lf_102401402_*。读取时做了两层防御:JSON 解析失败不覆盖原数据、localStorage 不可用(隐私模式)时降级为内存存储。 - 架构分层:业务规则与界面彻底分离。 全部数据逻辑收敛在
core.js(校验、搜索、筛选、状态机、相似度、格式化,纯函数、不碰 DOM);存储收敛在store.js;渲染收敛在ui.js;事件编排在app.js。core.js用 UMD 双导出(module.exports+window.Core),因此既能在浏览器加载,又能被 Node 直接require做单元测试——可测试性在架构层面就设计进去,而不是事后补。
数据流是单向的:
用户操作(点击/输入)
↓
app.js(事件编排,持有全局状态)
├─→ core.js(纯业务逻辑:校验/搜索/状态机/相似度)
├─→ store.js(localStorage 读写,损坏防御)
└─→ ui.js(DOM 渲染,插入前 HTML 转义)
↓
界面更新 + 持久化
这样做的好处是关注点分离:改界面不碰逻辑,改逻辑不影响界面,core.js 的规则可以被 Node 单独验证。
4.2 关键实现的流程图或数据流图




4.3 重要的/有价值的代码片段及解释
片段一:UMD 双导出 ——「可测试」的关键
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(); // Node 环境:require 拿到对象
} else {
root.Core = factory(); // 浏览器环境:挂到 window
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// 全部纯函数:validatePost / searchItems / sortItems / canManage /
// completePost / revertPost / findSimilar / similarity / recommend /
// formatTime / calcStats / escapeHtml / normalize ...
return { /* 导出函数对象 */ };
});
解释:同样的逻辑,浏览器用经典 <script> 标签加载(避开 file:// 下 ES 模块被 CORS 拦截的坑),Node 用 require 加载做单元测试。这是「双击 html 能跑」与「Node 能测」能同时满足的原因。
片段二:统一校验入口(表驱动,发布/编辑复用)
var CATEGORIES = ['电子设备', '证件卡片', '钥匙', '服饰箱包', '书籍文具', '其他'];
var AREAS = ['教学区', '图书馆', '食堂', '宿舍区', '运动场', '其他'];
function validatePost(input) {
var data = normalizeInput(input); // 先 trim、统一成字符串字段
var errors = {};
if (data.type !== 'lost' && data.type !== 'found') errors.type = '请选择寻物或招领。';
if (CATEGORIES.indexOf(data.category) === -1) errors.category = '请选择有效的物品类别。';
if (AREAS.indexOf(data.area) === -1) errors.area = '请选择有效的区域。';
[['title', '物品名称', 40], ['location', '具体地点', 80], ['contact', '联系方式', 100]]
.forEach(function (f) {
if (!data[f[0]]) errors[f[0]] = '请填写' + f[1] + '。';
else if (data[f[0]].length > f[2]) errors[f[0]] = f[1] + '不能超过' + f[2] + '字。';
});
if (data.description.length > 500) errors.description = '详细描述不能超过500字。';
if (data.eventTime && !validLocalTime(data.eventTime)) errors.eventTime = '请填写有效的日期与时间。';
return { data: data, errors: errors, valid: Object.keys(errors).length === 0 };
}
解释:① 只在这里判一次,发布与编辑共用,不会出现「发布校验了、编辑漏校验」;② 必填与长度用表驱动,加字段只加一行;③ validLocalTime 不用 new Date() 直接判断,而是先按格式取分量再回填比较,避免 2026-02-30 被自动进位成 3 月 2 日(返回 NaN 拒绝);④ 返回值同时携带 data,页面直接拿干净数据去创建记录。
片段三:双层状态权限 ——「没有显示按钮」不是完整规则
function canManage(post, ownerId) {
return Boolean(post && !post.demo && ownerId && post.ownerId === ownerId);
}
function completePost(posts, id, ownerId, now) {
now = now || new Date();
var post = posts.filter(function (p) { return p.id === id; })[0];
if (!post) return { ok: false, error: '这条信息不存在。' };
if (!canManage(post, ownerId)) return { ok: false, error: '只有发布者可以更新状态。' };
if (post.status === 'resolved') return { ok: false, error: '这条信息已经完成,无需重复操作。' };
var updated = posts.map(function (p) {
return p.id === id ? Object.assign({}, p, { status: 'resolved', resolvedAt: now.toISOString() }) : p;
});
return { ok: true, posts: updated };
}
解释:界面只对本人显示操作按钮,但业务函数会再次检查权限,避免把「没有显示按钮」当作完整规则(防开发者工具篡改)。函数返回新数组,界面等保存成功后才使用新数据,从而避免「保存失败却显示已完成」的假成功。demo 演示数据同样不可修改。
片段四:组合搜索 + 已解决置底排序
function searchItems(items, opts) {
opts = opts || {};
// 先按原始关键词以空白分词,再逐词归一化(normalize 会去除空白,顺序不能反)
var rawKw = String(opts.keyword || '').trim();
var tokens = rawKw ? rawKw.split(/\s+/).map(normalize).filter(Boolean) : [];
return items.filter(function (it) {
if (opts.type && it.type !== opts.type) return false;
if (opts.category && it.category !== opts.category) return false;
if (opts.area && it.area !== opts.area) return false;
if (opts.status && it.status !== opts.status) return false;
if (tokens.length) {
var hay = normalize([it.title, it.description, it.location, it.area, it.category].join(' '));
if (!tokens.every(function (t) { return hay.indexOf(t) !== -1; })) return false;
}
return true;
});
}
function sortItems(list) {
return list.slice().sort(function (a, b) {
var ra = a.status === 'resolved' ? 1 : 0;
var rb = b.status === 'resolved' ? 1 : 0;
if (ra !== rb) return ra - rb; // 未解决在前
var ta = ra ? (a.resolvedAt || '') : (a.createdAt || '');
var tb = rb ? (b.resolvedAt || '') : (b.createdAt || '');
return ta < tb ? 1 : (ta > tb ? -1 : 0); // 组内时间倒序
});
}
解释:关键词先按原始文本以空白分词、再逐词归一化(顺序不能反——normalize 会去除空白,先归一化就分不了词了),每个 token 要求全部命中(AND)而非任一命中;normalize 做 NFKC + 小写 + 去标点,中英文、全半角混输都能匹配;筛选条件全部省略时退化为返回全部。sortItems 让求助信息优先被看到,已解决的置底但不删除——既保留可查,又不打扰需要帮助的人;返回新数组避免页面间数据污染。
五、附加特点设计与展示
5.1 创意独到之处与意义
在满足题目核心功能之外,从「用户真实使用」的角度做了四个体验向设计:
| 特点 | 创意与意义 |
|---|---|
| 相似线索推荐 | 寻物的人反复看自己发的信息,捡到东西的人发招领,两边在同一个平台却天然不看对方的列表——这是失物招领类产品最大的效率漏点。详情页主动推荐「相反类型 + 同类别 + 名称相似」的未完成信息,并给出可核对的中文理由(「物品名称相似 · 类别相同」),不需要用户改变行为,系统替他把两类信息推到眼前 |
| 一键复制联系方式 | 联系方式是「联系发布者」的唯一载体,微信/QQ/手机号记错就前功尽弃。点一下复制,缩短「看到→联系」的路径 |
| 类别/区域/状态组合筛选 + 一键重置 | 只靠关键词搜不到「图书馆捡到的所有电子产品」;三维标签与关键词任意叠加,筛选复杂后一键重置 |
| 统计条 + 已解决置底 + 相对时间 | 统计条(全部/进行中/已完成/解决率)暗示「在这里发帖真的能找回东西」;「2 小时前/昨天/3 天前」比冷冰冰的日期更易读 |
5.2 实现思路
- 相似线索推荐:
similarity(a,b)用连续双字集合的交并比——中文没有空格分词,「黑色雨伞/蓝色雨伞」共享「色雨、雨伞」(2/4=0.5),「黑色雨伞/黑色水杯」只有「黑色」(1/5=0.2),阈值 0.25 排除后者;normalize用 NFKC,全角「Air」与半角「Air」判为相同。推荐在详情页打开时实时计算、不落库,状态变更后天然同步。 - 一键复制:优先
navigator.clipboard.writeText,Promise 拒绝(file://等非安全上下文)时降级为隐藏 textarea +document.execCommand('copy');仍失败则明确提示手动复制路径,不显示虚假的「已复制」。 - 组合筛选:筛选状态集中在
state对象,任何操作只改 state 再统一render(),渲染逻辑只有一份,不会出现「改了 A 忘了 B」的状态不同步。 - 相对时间:
formatTime(iso, now)按分钟/小时/天分档,now可注入便于单元测试。
5.3 重要的/有价值的代码片段及解释
相似度与推荐主循环
function similarity(a, b) {
var x = normalize(a), y = normalize(b);
if (!x || !y) return 0;
if (x === y) return 1;
if (Math.min(x.length, y.length) >= 2 && (x.indexOf(y) !== -1 || y.indexOf(x) !== -1)) return 0.85;
var xp = bigrams(x), yp = bigrams(y); // 连续双字集合
if (!xp.length || !yp.length) return 0;
var shared = xp.filter(function (g) { return yp.indexOf(g) !== -1; }).length;
return shared / (xp.length + yp.length - shared);
}
function recommend(source, items) {
var opposite = source.type === 'lost' ? 'found' : 'lost';
var results = [];
items.forEach(function (it) {
if (it.id === source.id || it.type !== opposite ||
it.status !== 'active' || it.category !== source.category) return;
var name = similarity(source.title, it.title);
if (name < 0.25) return; // 名称无关直接排除
var reasons = [name === 1 ? '物品名称相同' : '物品名称相似', '类别相同'];
results.push({ item: it, score: Math.round(name * 60) + 15, reasons: reasons });
});
return results.sort(function (a, b) { return b.score - a.score; }).slice(0, 3);
}
一键复制(带降级,不假成功)
function copyText(text, done) {
function fallback() {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:0;';
document.body.appendChild(ta); ta.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(ta);
done(ok);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
function () { done(true); },
function () { fallback(); }
);
} else { fallback(); }
}
// 调用侧:done(false) 时 Toast 提示「自动复制受限,请选中文字按 Ctrl+C」
-
5.4 实现成果展示
在线演示:https://anna-khalil.github.io/102401402-052401304/
- 【图 1:首页截图 —— 统计条 + 组合筛选 + 信息卡片列表】
![]()
- 【图 2:详情页截图 —— 联系发布者弹窗 + 一键复制 + 相似线索推荐】
![]()
- 【图 3:发布页截图 —— 逐字段校验红字提示 + 相似信息提醒弹窗】
![]()
- 【图 4:我的发布截图 —— 标记已找到 / 已归还 + 删除二次确认】
![]()
![]()
- 【图 5:搜索空状态截图 —— 无结果引导 + 一键重置】
![]()
六、目录说明和使用说明
6.1 目录组织
102401402-052401304/
├── index.html # 入口:四视图单页(首页/详情/发布/我的发布)+ 底部 Tab + 弹窗
├── css/
│ └── style.css # 全部样式(蓝白科技简约风,响应式,移动端风格 PC 居中)
├── js/
│ ├── core.js # 核心纯业务逻辑(UMD 双导出:校验/搜索/筛选/状态机/相似度/格式化)
│ ├── data.js # 8 条内置示例数据(demo 标记,不可修改状态)
│ ├── store.js # localStorage 读写(命名空间键 + 版本校验 + 损坏防御 + 内存降级)
│ ├── ui.js # 渲染层(卡片/弹窗/Toast/一键复制/HTML 转义)
│ └── app.js # 应用入口(路由、事件绑定、状态管理、发布/搜索/状态闭环)
├── test/
│ └── core.test.js # 单元测试(node:test,本地 `node --test test/` 运行)
├── docs/ # PSP 记录、数据流图(Mermaid)、开发计划
├── .github/workflows/ # CI:每次 push 自动跑单元测试
└── README.md # 目录说明 + 使用说明(评分者入口)
分层说明:core.js(业务规则,无 DOM)→ store.js(持久化)→ ui.js(界面)→ app.js(编排)。核心逻辑与界面分离,core.js 可被 Node 直接 require 做单元测试。
6.2 测试人员如何运行
- 下载 /
git clone本仓库全部文件到本地,保持目录结构不变; - 用谷歌浏览器(Chrome) 双击打开
index.html; - 无需安装环境、无需联网、无需后端(数据存浏览器 localStorage)。
核心流程走查建议:
- 首页浏览 → 搜索「校园卡」(试试多关键词、无结果时看空状态引导);
- 点卡片看详情 → 「联系发布者」弹窗 → 一键复制联系方式;
- 底部「发布」填表单(试试留空必填项看红字提示、试试发布与已有信息相似的名字看提醒);
- 发布成功 → 「我的发布」里「标记已找到/已归还」→ 回首页看卡片置灰沉底、统计条数字变化;
- 试试标记演示数据(应无操作按钮,不可修改)。
注意事项:数据保存在本机浏览器的 localStorage 中,换浏览器/换电脑数据不同属正常现象;想重置示例数据按 F12 控制台执行 localStorage.clear() 后刷新。运行单元测试才需要 Node.js 18+。
七、单元测试
7.1 测试工具的选择、学习过程与简易教程
工具:Node.js 内置的 node:test(Node 18+ 自带)+ 内置 assert 断言库。
为什么选它:作业附录推荐了 Mocha 等框架,但它们需要 npm install 安装依赖;node:test 是 Node 官方内置模块,任何装了 Node 18+ 的机器上 node --test test/ 直接就能跑,与「纯原生 JS、零依赖」的技术路线一致。核心逻辑因为抽成了纯函数(core.js 不碰 DOM),只需 require('../js/core.js') 就能测,无需模拟浏览器。
学习过程:先读 Node 官方文档 Test runner 章节和廖雪峰老师的 JS 教程,写最小示例跑通「测试失败会怎样报错」,再按「先测核心逻辑、后补存储边界」的顺序逐步补全用例。
简易教程(给第一次接触单元测试的同学):
-
安装 Node.js 18+(官网下载 LTS 版,一路下一步);
-
在项目根目录建
test/文件夹,写xxx.test.js:const test = require('node:test'); const assert = require('node:assert/strict'); const Core = require('../js/core.js'); test('搜索能按名称命中', function () { const r = Core.searchItems(fixture(), { keyword: '保温杯' }); assert.strictEqual(r.length, 1); }); -
在项目根目录执行
node --test test/,看到绿勾即通过; -
修改代码后重复运行;可用
node --test --experimental-test-coverage test/查看覆盖率。
7.2 测试代码与说明
展示部分用例(完整 55 例全部通过,见仓库 test/core.test.js,本地 node --test 运行):
const test = require('node:test');
const assert = require('node:assert/strict');
const Core = require('../js/core.js');
// 被测函数覆盖:validatePost / searchItems / sortItems / canManage /
// completePost / revertPost / findSimilar / similarity / formatTime / calcStats
test('validatePost:缺少必填项时逐字段报错', function () {
const r = Core.validatePost({ type: 'lost', category: '钥匙', area: '图书馆' });
assert.strictEqual(r.valid, false);
assert.ok(r.errors.title && r.errors.location && r.errors.contact);
});
test('validatePost:名称超长被拒绝', function () {
const r = Core.validatePost({ type: 'lost', title: 'x'.repeat(41), category: '其他', area: '其他', location: 'A', contact: 'B' });
assert.strictEqual(r.valid, false);
assert.ok(r.errors.title);
});
test('validatePost:2026-02-30 非法日期被拒绝(分量回查)', function () {
const r = Core.validatePost({ type: 'lost', title: '伞', category: '其他', area: '其他', location: 'A', contact: 'B', eventTime: '2026-02-30 12:00' });
assert.strictEqual(r.valid, false);
assert.ok(r.errors.eventTime);
});
test('searchItems:多关键词空格分隔要求全部命中(AND)', function () {
const r = Core.searchItems(fixture(), { keyword: '图书馆 保温杯' });
assert.ok(r.every(it => it.title.indexOf('保温杯') !== -1 && it.location.indexOf('图书馆') !== -1));
});
test('searchItems:全角/半角、大小写归一化后匹配', function () {
assert.strictEqual(Core.searchItems(fixture(), { keyword: 'air' }).length,
Core.searchItems(fixture(), { keyword: 'Air' }).length);
});
test('sortItems:未解决在前、已解决置底', function () {
const r = Core.sortItems(fixture());
const firstResolved = r.findIndex(it => it.status === 'resolved');
assert.ok(firstResolved === -1 || r.slice(firstResolved).every(it => it.status === 'resolved'));
});
test('completePost:非发布者不可修改(双层权限兜底)', function () {
const r = Core.completePost(fixture(), 'p1', 'someone-else');
assert.strictEqual(r.ok, false);
assert.ok(/发布者/.test(r.error));
});
test('completePost:demo 演示数据不可修改', function () {
const r = Core.completePost(fixture(), 'p_demo1', fixture()[0].ownerId);
assert.strictEqual(r.ok, false);
});
test('completePost:已完成信息不可重复标记', function () {
const r = Core.completePost(fixture(), 'p_done1', 'owner-a');
assert.strictEqual(r.ok, false);
assert.ok(/已经完成/.test(r.error));
});
test('revertPost:改回进行中并清空 resolvedAt', function () {
const r = Core.revertPost(fixture(), 'p_done1', 'owner-a');
assert.strictEqual(r.ok, true);
assert.strictEqual(r.posts.filter(p => p.id === 'p_done1')[0].status, 'active');
assert.strictEqual(r.posts.filter(p => p.id === 'p_done1')[0].resolvedAt, null);
});
test('similarity:名称互相包含得高分,无关名称低于阈值', function () {
assert.ok(Core.similarity('黑色雨伞', '蓝色雨伞') > Core.similarity('黑色雨伞', '黑色水杯'));
});
test('calcStats:统计总数与解决率', function () {
const s = Core.calcStats(fixture());
assert.strictEqual(s.total, fixture().length);
assert.strictEqual(s.resolved + s.active, s.total);
});
7.3 构造测试数据的思路
白盒为主,按每个函数的正常、边界、异常三类输入设计:
- 正常情况:每个函数的典型输入(搜索能命中、状态能正常转移、表单填全能通过);
- 边界情况:空关键词(应返回全部)、40/41 字名称、空数组统计为 0、临界时间(一分钟内显示「刚刚」)、重复标记已完成项、多关键词组合;
- 异常情况:非法状态转移、非发布者修改、
null/非数组输入(返回空数组)、非法日期(2026-02-30分量回查拒绝)、HTML 字符(转义后不破坏页面)、损坏 JSON(不覆盖原数据)。
对测试设计的自我评价:这 55 个用例覆盖了全部核心纯函数的主要分支(校验、搜索、排序、状态机、权限、相似度、统计、转义、存储防御),业务与存储层行覆盖率 core.js 97.6% / store.js 93.3%,能满足本程序测试要求;未覆盖的是纯 UI 层交互(Toast 文案、弹窗显示)和真实浏览器剪贴板权限差异,这部分依靠 Chrome 手工回归走查补足(见第 9 节)。
八、GitHub 代码签入记录截图

九、遇到的代码模块异常或结对困难及解决方法
| 问题 | 做过哪些尝试 | ||
|---|---|---|---|
file:// 下 ES 模块被 CORS 拦截,双击打开白屏 |
先逐行排查代码,后定位为 Chrome 对 file:// 协议模块加载的限制;改用经典 <script> 标签按序加载 + UMD 双导出 |
✅ | 交付「双击 html 能跑」的作业要避开 ES 模块;UMD 能同时满足浏览器与 Node 两套环境 |
| localStorage 键名与他人项目串扰 | 键名加项目命名空间 lf_102401402_*;读取时版本号+格式校验,损坏 JSON 不覆盖原数据 |
✅ | file:// 下所有页面共享同一 origin,键名必须带命名空间 |
| 核心逻辑一开始混在 DOM 里无法测试 | 把业务规则抽成纯函数放 core.js,渲染层只做 HTML 生成 | ✅ | 纯函数 + 关注点分离是「可测试」的前提,也反向让代码更清晰 |
| 与队友接口约定不一致 | 动手前先定稿字段与函数签名(见协作文档第 5 节),改动先同步文档再改代码 | ✅ | 接口先行能省下大量返工 |
十、评价你的队友
值得学习的地方:李思淇同学从使用者操作顺序考虑问题的思路很值得学习——搜索无结果时有没有清空入口、联系方式能否方便复制、已完成信息还能从哪里找到,这些细节直接影响使用体验;作为独立走查者,她按 README 从零操作,正好模拟了助教视角,帮我们发现「以为能用、实际用不起来」的问题。
需要改进的地方:希望她能把走查发现的问题更早整理成可复现的记录(写明关键词/操作/预期/实际),这样定位更高效。






浙公网安备 33010602011771号