2026秋软件工程结对作业(第二次之程序实现)
一、开头信息与结对表
本次博客作者:[102401622] 吴圣洲 结对同学:[102401615]秦文贵
课程:软件工程(2026 秋)第二次结对作业 Deadline1:2026-10-10 23:59
二、具体分工
两人先共同确认数据字段与存储接口,再按模块分工;每完成并检查一个独立功能提交一次,提交记录可在仓库签入历史中核对。
| 学号 | GitHub 账号 | 主要工作 | 提交次数 |
|---|---|---|---|
| 102401615 | vg666-NiKo | 项目初始化、首次作业原型归档、页面框架与首页浏览/类型筛选、发布与表单校验、搜索与详情、联系方式复制、我的发布与状态更新、编辑与删除、照片上传与压缩预览、相似线索推荐、README 与组员信息同步、合并 Pull Request | 14 |
| 102401622 | drkalerq | README 使用流程化重写、三张使用流程图与模块数据流图、首页「只显示最新 30 条」限制与对应单元测试、全局样式重构与页面视觉优化、单元测试补充与交叉检查 | 4 |
分工边界(写进 docs/开发计划.md 并被实际执行):
- 102401615 负责功能实现:从页面框架到最后一项加分功能,一行行把需求落到代码;
- 102401622 负责文档、流程图、视觉与测试:把实现写清楚、把流程画出来、把测试补到位,并在搭档的代码上做交叉检查。
三、PSP 表格
实施前记录预估(见仓库 docs/PSP.md),实际耗时按阶段提交时间与实际投入核对后填写。
| PSP 2.1 阶段 | 内容 | 102401615 预估 | 102401615 实际 | 102401622 预估 | 102401622 实际 |
|---|---|---|---|---|---|
| Estimate | 估计任务时间 | 15 | 20 | 15 | 15 |
| Analysis | 需求与技术分析 | 40 | 45 | 30 | 35 |
| Design Spec | 生成设计文档 | 30 | 25 | 20 | 30 |
| Design Review | 设计复审 | 15 | 15 | 20 | 20 |
| Coding Standard | 代码规范 | 15 | 10 | 10 | 10 |
| Design | 具体设计 | 45 | 40 | 25 | 30 |
| Coding | 编码/功能完善 | 240 | 260 | 100 | 90 |
| Code Review | 代码复审 | 30 | 25 | 50 | 45 |
| Test | 测试、修复和回归 | 80 | 90 | 120 | 110 |
| Test Report | 测试报告 | 20 | 20 | 30 | 25 |
| Size Measurement | 工作量统计 | 10 | 10 | 10 | 10 |
| Postmortem | 总结与改进计划 | 40 | 35 | 40 | 40 |
| 合计 | 不重复计入分组 | 580 | 595 | 470 | 460 |
关于实际耗时:上表「实际」列为按阶段提交时间区间与各阶段验收文档推算的参考值,提交前请替换为两人自己记录的真实数字。我们已经发现的偏差规律有两条:① 编码与测试的实际耗时都高于预估(异步图片、时间边界这类问题比预想难);② 设计文档与设计复审低于预估(原型已在第一次作业定型,省下了重复设计)。
四、解题思路描述与设计实现说明
4.1 代码实现思路(文字描述)
总体选择:不引入任何框架与依赖,用原生 HTML + CSS + JavaScript 写成可直接双击打开的网页。
理由有三个:
- 评审方(助教)只需要拿到仓库、打开
index.html就能验收,不需要npm install、不需要起服务器,省掉所有环境问题; - 数据量很小(校园失物信息),用浏览器
localStorage存就够,不必上后端; - 没有框架意味着没有构建步骤,两人用 GitHub 直接协作、肉眼可读全部代码,反而更容易做代码复审。
分层:把「业务规则」与「界面」彻底分开。
index.html 页面骨架(五个页面 + 底部导航)
css/style.css 视觉样式
js/core.js 业务规则:校验、搜索、排序、状态文案、编辑删除、首页取最新 30 条 ← 不含任何 DOM 代码
js/storage.js 存储:读写 localStorage、版本与格式校验、失败处理
js/images.js 图片:类型与大小校验、缩放、压缩、异步选择状态
js/clipboard.js 剪贴板:写入结果处理
js/recommendations.js 线索推荐:名称相似度、时间窗口、评分排序
js/pages/*.js 页面模块:搜索、详情、我的发布
js/app.js 组装:共享数据、卡片渲染、导航、首页筛选、发布与编辑提交
tests/*.test.js 单元测试(Node 内置测试运行器)
这个分层最重要的收益是可测试:core.js、storage.js、recommendations.js、images.js、clipboard.js 都不碰 DOM,写成「既能 module.exports 给 Node,又能挂到 window 给浏览器」的双导出形式,于是 85 个单元测试全部能在命令行里跑,不需要浏览器自动化。
单一数据源与「先保存、后更新」。
页面里只有一份内存数据 items,所有读写都经过它:
function saveItems(nextItems) {
const result = store.save(nextItems); // 先尝试写入
if (result.ok) items = nextItems; // 只有写入成功,才更新内存
return result;
}
发布、编辑、删除、状态变更全都走这一条路径。这样就不会出现「页面提示成功、刷新后数据没了」的假成功。
统一校验入口。 表单校验集中在 core.validatePost,返回 { data, errors, valid }:页面只负责把 errors 渲染到对应字段下方,绝不各写一套判断。发布和编辑复用同一个函数与同一个表单,编辑时额外保留编号、发布时间、归属、照片与完成状态。
关键交互的失败兜底。 复制联系方式失败时提示并自动选中文本(按 Ctrl+C 手动复制);照片解码失败时保留原照片;存储不可写时直接禁用发布按钮——任何一步失败都不报告成功。
4.2 关键实现的流程图与数据流图
三条核心使用流程的流程图(步骤框 + 判断分支 + 异常处理,虚线框为需要人工处理的分支):
图 1 发布信息流程(含「校验未通过」与「保存失败」两条分支)

图 2 浏览查找与线索流程(含「搜索结果为空」与「信息已完成不再推荐」两条分支)

图 3 我的发布管理流程(含三种操作分支与「保存失败保持原状态」)

图 4 模块与数据流图(数据从用户操作到落盘再到渲染的流向,以及模块各自的职责边界)

数据流图里能看清三个设计决定:页面模块只做「界面 + 事件」;业务规则模块不碰 DOM 所以可被 Node 测试;存储模块是唯一的写入口,只有它返回成功,内存数据才更新。
4.3 重要代码片段与解释
片段 1:统一的表单校验(js/core.js)
function validatePost(input) {
const data = normalizeInput(input); // 先 trim,统一成字符串字段
const errors = {};
if (!['lost', 'found'].includes(data.type)) errors.type = '请选择寻物或招领。';
if (!categories.includes(data.category)) errors.category = '请选择有效的物品类别。';
[['title', '物品名称', 40], ['place', '地点', 80], ['contact', '联系方式', 100]].forEach(function (field) {
if (!data[field[0]]) errors[field[0]] = '请填写' + field[1] + '。';
else if (data[field[0]].length > field[2]) errors[field[0]] = field[1] + '不能超过 ' + field[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 日;④ 返回值同时携带 data,页面直接拿干净数据去创建记录。
片段 2:存储的失败保护(js/storage.js)
function load(seed) {
try {
const raw = storage.getItem(KEY);
if (raw === null) { writable = true; return { items: copy(seed), warning: '', writable: true }; }
const saved = JSON.parse(raw);
if (!saved || saved.version !== 1 || !validItems(saved.items)) throw new Error('invalid data');
writable = true;
return { items: copy(saved.items), warning: '', writable: true };
} catch (_) {
writable = false;
return { items: copy(seed), warning: '无法读取本地数据,暂展示初始演示信息并暂停发布。…', writable: false };
}
}
function save(items) {
if (!writable) return { ok: false, error: '当前本地数据无法读取,发布已暂停,原记录未被覆盖。' };
if (!validItems(items)) return { ok: false, error: '信息数据格式有误,未保存。' };
try {
storage.setItem(KEY, JSON.stringify({ version: 1, items: items }));
return { ok: true, error: '' };
} catch (_) {
return { ok: false, error: '保存失败,可能是存储空间不足或浏览器禁止本地保存。…' };
}
}
为什么这样写:① 存储里带 version 与记录格式校验,读到损坏或不兼容数据时不覆盖原数据,而是暂停写入并给出提示,用户可以先备份;② save 用 {ok, error} 而不是抛异常,调用方必须先判断成功才更新页面;③ storage 对象是参数注入的,测试里传一个假的 storage 就能模拟「容量不足」「拒绝写入」,不必真的把浏览器存储塞满。
片段 3:首页只取最新的 30 条(js/core.js + js/app.js)
/* core.js */
const homeLimit = 30;
function homeItems(items, type) {
const sorted = filterItems(items, type); // 已按发布时间倒序
return { items: sorted.slice(0, homeLimit), total: sorted.length, limit: homeLimit };
}
/* app.js:只在渲染时截断,数据本身不动 */
const shown = core.homeItems(items, homeFilter);
const clipped = shown.total > shown.limit;
document.getElementById('result-count').textContent = clipped
? '共 ' + shown.total + ' 条,显示最新 ' + shown.items.length + ' 条'
: '共 ' + shown.total + ' 条';
hint.hidden = !clipped; // 列表末尾提示「其余 N 条请用搜索查找」
为什么这样写:① 截断只发生在读取侧,localStorage 里的数据永远是完整的,搜索页仍能检索全部;② 条数文案把「总数」和「显示数」都写出来,用户不会误以为信息丢了;③ 上限与筛选顺序写在 core 里,可以用 35 条数据在 Node 里直接验证。
片段 4:线索推荐的可解释匹配(js/recommendations.js)
function similarity(left, right) {
const a = normalize(left), b = normalize(right); // NFKC + 去标点 + 小写
if (!a || !b) return 0;
if (a === b) return 1;
if (Math.min(a.length, b.length) >= 2 && (a.includes(b) || b.includes(a))) return 0.85;
function pairs(text) { const result = new Set(); for (let i = 0; i < text.length - 1; i++) result.add(text.slice(i, i + 2)); return result; }
const x = pairs(a), y = pairs(b); if (!x.size || !y.size) return 0;
const shared = [...x].filter(value => y.has(value)).length;
return shared / (x.size + y.size - shared); // 连续双字集合的交并比
}
function eventTimestamp(value) { // 解析「年-月-日 时:分」
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(value)) return NaN;
const [year, month, day, hour, minute] = value.match(/\d+/g).map(Number);
const date = new Date(0); date.setUTCFullYear(year, month - 1, day); date.setUTCHours(hour, minute, 0, 0);
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day ||
date.getUTCHours() !== hour || date.getUTCMinutes() !== minute) return NaN; // 回查分量,拒绝日期溢出
return date.getTime();
}
为什么这样写:① 中文没有空格分词,用连续双字集合的交并比能抓住「黑色雨伞 / 蓝色雨伞」共享的「色雨、雨伞」(2/4 = 0.5),而「黑色雨伞 / 黑色水杯」只有「黑色」共享(1/5 = 0.2),低于 0.25 的阈值被排除;② 归一化用 NFKC,全角「Air Pods」与「air-pods」判为相同,标点不参与匹配;③ 时间按分量构造后回查,2026-02-30、24:00 直接返回 NaN,避免把无效时间当成有效时间参与 7 天窗口比较,也避开时区换算带来的跨时区偏差。
片段 5:异步选图不覆盖新结果(js/images.js)
function createSelection(process, onChange) {
let ticket = 0; let state = { image: '', busy: false, error: '' };
// emit(next):更新 state 并回调 onChange(此处省略)
async function select(file) {
const current = ++ticket; const previous = state.image; // 取号,记住旧图
emit({ image: previous, busy: true, error: '' });
try {
validateFile(file);
const image = await process(file); // 压缩是异步的
if (!image || !validImage(image)) throw Error('图片处理失败或压缩后仍过大,请换一张图片。');
if (current === ticket) emit({ image, busy: false, error: '' }); // 只有最后一次才算数
} catch (error) {
if (current === ticket) emit({ image: previous, busy: false, error: error.message || '无法读取图片,请重新选择。' });
}
}
return { select, set, getState: () => ({ ...state }) };
}
为什么这样写:连续选两张照片时,第一次的压缩可能后完成,把第二张(用户真正想要的)覆盖掉。这里用递增的 ticket 取号,只有「当前最后一次」的回调才允许写入状态,用户中途点「移除照片」也会因为 ticket 变化而让在途任务失效。压缩本身按 0.82 → 0.65 → 0.45 → 0.3 逐级降质,直到数据长度合规或彻底失败,避免一次压死导致透明背景出现色边。
五、附加特点设计与展示
5.1 创意独到之处与意义
作业要求的是「发布 / 浏览 / 搜索 / 详情 / 状态管理」。我们在满足全部要求之外,做了四个自己觉得真的有用的设计:
特点 1(核心):相似线索推荐 —— 把「两类人」接上。
寻物的人只有一个心理动作:反复看自己发的信息有没有人回复;而捡到东西的人发的是招领。两边都在同一个平台上,却天然不看对方的列表,这是失物招领类产品最大的效率漏点。所以详情页会主动列出「相反类型 + 同类别 + 名称相似 + 时间相近」的未完成信息,并给出可核对的中文理由(「物品名称相同 · 类别相同 · 地点相近(文字匹配)· 时间相差不超过 3 天」)。意义在于:不需要用户改变行为,系统替他把两类信息推到眼前。
特点 2:照片在本地压缩后内嵌保存 —— 0 后端也能带图。
纯前端项目最怕「图片存不下」。我们的做法是先缩到最长边 1280px、转 JPEG、必要时逐级降质,压缩后的 data: 数据直接写进记录字段。意义在于:不引入图片服务器、不产生本地文件依赖,刷新、复制仓库、换电脑都能直接带图打开。
特点 3:所有失败都不许「假成功」。
复制联系方式失败 → 提示并自动选中文本供 Ctrl+C;照片解码失败 → 保留原来那张并说明;保存失败 → 表单与列表保持原样、停在当前页。意义在于:校园场景里用户是焦虑的,一次假成功会让人以为对方收到了消息,导致彻底错过物品。
特点 4:首页只展示最新 30 条 + 明确告知。
信息是时间流,越旧的越没用;但数据不能删。首页只渲染最新 30 条,条数处写明「共 N 条,显示最新 30 条」,列表末尾说明其余信息可用搜索找到。意义在于:列表不会无限增长,老信息也不会被悄悄丢掉。
5.2 实现思路
| 特点 | 实现思路 |
|---|---|
| 相似线索推荐 | 详情页渲染时按编号取当前记录,过滤出「相反类型 + 同类 + 未完成」,名称相似度 < 0.25 直接排除,两侧事件时间都有效且相差 > 7 天排除;得分 = 名称相似度 × 60 + 15(类别)+ 地点相似度 × 15 + 时间加分(≤1 天 10 / ≤3 天 7 / ≤7 天 4),排序后取前 3 条;分数只用于内部排序,页面只展示可核对的中文理由。推荐不落库,每次打开详情重新计算,所以状态变更、编辑、删除后结果天然同步。 |
| 照片压缩 | validateFile 先卡类型与 5 MB;fitSize 按最长边 1280 等比缩放;canvas 转 JPEG,质量从 0.82 逐级降到 0.3,直到数据长度 ≤ 500000;透明背景先填白避免黑底;createSelection 用递增票号管理异步状态,处理中禁用提交。 |
| 不误报失败 | 复制走 clipboard.writeText 的 Promise,只有 await 成功才返回 {ok:true};存储用 {ok,error} 返回值取代异常;所有「写入动作」统一「先保存、后更新列表」,任何一步失败都不提示成功。 |
| 首页 30 条 | 上限与截断写在 core.homeItems,只作用于渲染;条数与提示文案由 total 与 limit 计算得出,筛选后的结果同样最多 30 条。 |
5.3 关键代码片段与解释
线索推荐的主循环(js/recommendations.js):
items.forEach(function (item) {
if (item.id === source.id || item.type !== opposite ||
item.status !== 'active' || item.category !== source.category) return; // 相反类型、同类别、未完成
const name = similarity(source.title, item.title);
if (name < 0.25) return; // 名称无关直接排除
const targetTime = eventTimestamp(item.eventTime);
const validTime = Number.isFinite(sourceTime) && Number.isFinite(targetTime);
const gap = validTime ? Math.abs(sourceTime - targetTime) / DAY : NaN;
if (validTime && gap > 7) return; // 超过 7 天排除
const location = similarity(source.place, item.place);
let score = Math.round(name * 60) + 15;
const reasons = [name === 1 ? '物品名称相同' : '物品名称相似', '类别相同'];
if (location >= 0.25) { score += Math.round(location * 15); reasons.push(location === 1 ? '地点相同' : '地点相近(文字匹配)'); }
if (validTime) { score += gap <= 1 ? 10 : gap <= 3 ? 7 : 4; reasons.push(gap <= 1 ? '时间相差不超过 1 天' : gap <= 3 ? '时间相差不超过 3 天' : '时间相差不超过 7 天'); }
results.push({ item, score, reasons });
});
results.sort((a, b) => b.score - a.score ||
(Date.parse(b.item.createdAt) || 0) - (Date.parse(a.item.createdAt) || 0) ||
a.item.id.localeCompare(b.item.id)); // 分数相同时排序稳定,结果可复现
两处值得说明:① reasons 与 score 同时产出,页面显示的是人话理由而不是「匹配度 87%」,避免把算法分数误当成「找回概率」;② 排序加了发布时间与编号两级兜底,保证同分时顺序稳定,否则每次刷新线索顺序都会变,测试也无法断言。
5.4 实现成果展示
程序全部截图取自正式网页在 Chrome 中的真实运行结果(视口 620×900、2 倍高清)。
| 首页信息流与 30 条上限 | 搜索与组合筛选 |
|---|---|
| 首页 | 搜索 |
| 发布表单 | 照片压缩预览 |
|---|---|
| 发布表单 | 照片预览 |
| 校验未通过(错误定位到字段) | 发布成功返回首页 |
|---|---|
| 校验 | 发布成功 |
| 详情与一键复制联系方式 | 相似线索推荐(加分功能) |
|---|---|
| 详情 | 线索 |
| 我的发布统计与状态管理 | 编辑我的发布(复用发布表单) |
|---|---|
| 我的 | 编辑 |
六、目录说明与使用说明
6.1 目录是如何组织的
目录按「页面 → 业务规则 → 存储 → 加分功能 → 测试 → 文档」分层,同一个文件只负责一件事:
102401615-102401622/
├── index.html 正式网页入口(五个页面 + 底部导航,双击即可打开)
├── css/
│ └── style.css 蓝白主题、信息卡片、底部导航、响应式与视觉变量
├── js/
│ ├── core.js 业务规则(无 DOM):校验、搜索、排序、状态文案、编辑删除、首页 30 条
│ ├── data.js 首次打开时的 5 条虚构演示数据
│ ├── storage.js 本地读写、版本与格式校验、失败处理(含 writable 保护)
│ ├── clipboard.js 复制结果处理(只有写入成功才报告成功)
│ ├── images.js 图片校验、缩放、压缩、异步选择状态、照片渲染
│ ├── recommendations.js 线索推荐:相似度、时间窗口、评分、推荐理由
│ ├── app.js 组装:共享数据、卡片渲染、导航、首页筛选、发布与编辑提交
│ └── pages/
│ ├── search.js 搜索条件、组合筛选、结果列表、清空筛选
│ ├── detail.js 详情渲染、联系方式展开与复制、失败时手动选中
│ └── my-posts.js 我的统计、状态筛选、编辑入口、状态变更与删除确认
├── tests/ 9 个测试文件、85 个单元测试
│ ├── core.test.js 筛选排序、状态文案、首页 30 条上限(11)
│ ├── publish.test.js 发布校验与创建(8)
│ ├── search.test.js 关键词与组合筛选(10)
│ ├── storage.test.js 读写、损坏数据、容量不足(7)
│ ├── clipboard.test.js 复制成功/失败分支(4)
│ ├── images.test.js 类型与大小限制、缩放、异步顺序、草稿恢复(15)
│ ├── my-posts.test.js 统计、状态变更、恢复进行中(8)
│ ├── manage-posts.test.js 编辑、删除、取消(10)
│ └── recommendations.test.js 线索规则、相似度、时间边界、排序(12)
├── docs/
│ ├── 开发计划.md 范围、阶段提交与验收标准
│ ├── PSP.md 两人预估与实际耗时
│ ├── 阶段2验收.md ~ 阶段8验收.md 各阶段自动检查结果 + 人工验收步骤
│ ├── 线索推荐设计.md 加分功能的用途、规则、流程与代码说明
│ └── images/ 三张使用流程图
└── prototype/ 第一次作业的原型与博客原稿(保留历史,不作为本次完成版本)
组织原则:能单独出错的规则就单独成文件。core.js 里没有一句 DOM 操作,所以能被 Node 直接 require 测试;storage.js 的 storage 对象是注入的,所以测试可以模拟容量不足;images.js 把「规则」与「浏览器解码」分开,规则部分可测、解码部分留给人工验收。
6.2 测试人员如何运行你的网页
运行网页(无需任何环境)
- 从 https://github.com/vg666-NiKo/102401615-102401622 下载 ZIP 并解压(不要只下载
index.html,css/、js/必须一起保留); - 用 Chrome 打开根目录的
index.html; - 首次打开会看到 5 条虚构演示信息;
- 建议始终用同一个浏览器、同一个目录打开,因为数据存在浏览器本地(
localStorage,键名campus-lost-found.v1)。移动文件夹或清除站点数据会导致旧数据不可见。
建议的验收路径(约 3 分钟)
| 步骤 | 操作 | 预期 |
|---|---|---|
| 1 | 底部「发布」→ 填名称、地点、联系方式(可加照片)→ 发布 | 回到首页,绿色提示「发布成功」,新记录置顶 |
| 2 | 首页点卡片 → 「联系发布者」→ 「复制联系方式」 | 提示已复制;如被浏览器拒绝,则提示并按 Ctrl+C 可复制 |
| 3 | 「搜索」→ 输入关键词,叠加类别与「仅看未完成」 | 结果即时变化;无结果显示「尝试更短的关键词或清空筛选」 |
| 4 | 「我的」→ 标记已找回/已归还 → 确认 | 统计卡片随之变化;首页、搜索、详情同步显示新状态 |
| 5 | 「我的」→ 编辑 → 改内容 → 保存修改 | 编号与完成状态不变;取消编辑则原信息不变 |
| 6 | 打开一条未完成信息的详情 → 底部「相似线索」 | 最多 3 条相反类型线索,并给出推荐理由 |
| 7 | 按 F5 刷新 | 以上修改全部保留 |
运行单元测试(需要 Node.js 22 或更新版本,无需 npm install)
cd 102401615-102401622 # 进入项目根目录
node --test tests/*.test.js # 预期 85 个测试全部通过
6.3 补充说明:容易出现误解的地方
- 「我的发布」是用本地字段
isMine标记的,不是登录系统,换设备看不到归属; - 复制成功只代表浏览器剪贴板 API 接受了写入,真实粘贴效果建议到记事本核对;
file://协议下localStorage行为依赖浏览器,请按上表用 Chrome 实际验证。
七、单元测试
7.1 测试工具与简易教程
我们选用的测试工具:Node.js 内置测试运行器(node:test + node:assert/strict)。
怎么学的:一开始想装 Jest,但看到 Node 18 之后自带了测试运行器,只需要两个内置模块就能写断言,于是决定不引入任何依赖(这和我们整个项目「零依赖」的思路一致)。学习路径是:先读 Node 官方文档里 Test runner 一节的三个示例 → 在我们已经写好的纯函数模块(core.validatePost)上写第一个测试 → 跑通后再把范围扩大到搜索、存储、图片、推荐。
我们自己整理的简易教程(照着做就能跑起来):
第 1 步:把业务规则写成「能被 require 的模块」。文件末尾加上双导出:
if (typeof module !== 'undefined' && module.exports) module.exports = api; // Node 里测试用
else root.LostFoundCore = api; // 浏览器里用
第 2 步:在 tests/ 下建同名测试文件,用 test() 描述「期望的行为」(写成中文句子,失败时一眼看懂):
const test = require('node:test');
const assert = require('node:assert/strict');
const { filterItems } = require('../js/core.js');
test('全部信息按发布时间倒序展示', () => {
assert.deepEqual(filterItems(items, 'all').map(x => x.id), ['newer', 'middle', 'older']);
});
第 3 步:常用断言够用就好。
| 断言 | 用途 | 例子 |
|---|---|---|
assert.equal(a, b) |
值相等 | 状态文案是「待寻找」 |
assert.deepEqual(a, b) |
数组/对象深度相等 | 筛选结果的 id 顺序 |
assert.ok(x) |
真值 | Number.isNaN(...) 判定非法日期 |
assert.throws(fn, /…/) |
期望抛错并可匹配消息 | 越权修改他人信息 |
assert.equal(Number.isNaN(x), true) |
判 NaN(不能直接用 equal 比 NaN) |
时间解析失败 |
第 4 步:运行并读结果。
node --test tests/*.test.js # 全部通过时输出 # pass 85 / # fail 0
node --test tests/core.test.js # 只跑一个文件,调试时用
第 5 步(进阶):把外部依赖变成参数,就能测异常分支。例如 storage.createStore(browserStorage) 接收任意 storage 对象,测试里传一个「写入就抛错」的假对象,就复现了「浏览器存储容量不足」;copyText(text, clipboard) 接收剪贴板对象,测试里传 { writeText: async () => { throw new Error('denied') } } 就复现了「用户拒绝授权」。
测试规模的变化(每个阶段完成后补测试,只加不减):阶段 4 收尾 31 个 → 复制与我的发布 43 个 → 照片上传 68 个 → 线索推荐 80 个 → 首页 30 条上限 85 个。当前 node --test tests/*.test.js 的输出是 85 passed / 0 failed(约 0.3 秒)。
7.2 部分单元测试代码与它测的函数
(1)tests/core.test.js → 测 core.homeItems(首页最新 30 条上限)
function manyItems(count, type) {
return Array.from({length: count}, (_, index) => ({
id: 'item-' + index,
type: type || 'lost',
createdAt: new Date(Date.UTC(2026, 8, 1, 0, index)).toISOString(),
status: 'active'
}));
}
test('首页上限为 30 条,超出时只保留最新 30 条', () => {
const list = manyItems(35);
const shown = homeItems(list, 'all');
assert.equal(homeLimit, 30);
assert.equal(shown.total, 35);
assert.equal(shown.items.length, 30);
assert.deepEqual(shown.items.map(x => x.id), list.slice().reverse().slice(0, 30).map(x => x.id));
});
test('首页数量正好 30 条时不产生截断', () => { /* 29 / 30 两组数据 */ });
test('首页类型筛选后同样只保留最新 30 条,并统计筛选后总数', () => { /* 40 寻物 + 40 招领,筛「招领」应 total=40、显示 30 */ });
test('首页截断不改变原始列表', () => {
const list = manyItems(35); const snapshot = structuredClone(list);
homeItems(list, 'all');
assert.deepEqual(list, snapshot);
});
测的是:数量上限、总量统计、筛选与上限的叠加关系,以及截断不产生副作用(不能因为渲染就删了数据)。
(2)tests/recommendations.test.js → 测 rules.similarity / rules.eventTimestamp / rules.recommend
const source = {id:'lost',type:'lost',title:'黑色雨伞',category:'生活用品',place:'食堂门口',
eventTime:'2026-10-06 12:00',createdAt:'2026-10-06T12:00:00+08:00',status:'active',…};
const found = {...source, id:'found', type:'found', eventTime:'2026-10-06 13:00'};
test('只推荐相反类型的未完成记录,排除自身、同类型与已完成', () => {
assert.deepEqual(ids([source, found, {...found, id:'done', status:'completed'}, {...source, id:'same'}]), ['found']);
});
test('相同名称和包含关系命中,中文连续双字允许部分相似', () => {
assert.equal(rules.similarity('黑色雨伞','黑色雨伞'), 1);
assert.equal(rules.similarity('雨伞','黑色雨伞'), 0.85);
assert.ok(rules.similarity('黑色雨伞','蓝色雨伞') >= 0.25);
assert.equal(rules.similarity('黑色雨伞','黑色水杯'), 0.2); // 低于阈值,不推荐
});
test('时间窗口包含七天边界,超过七天排除', () => {
assert.deepEqual(ids([source, {...found, eventTime:'2026-10-13 12:00'}]), ['found']); // 正好 7 天:通过
assert.deepEqual(ids([source, {...found, eventTime:'2026-10-13 12:01'}]), []); // 7 天 1 分:排除
});
test('时间解析拒绝日期溢出,跨月及跨年差值正确', () => {
assert.ok(Number.isNaN(rules.eventTimestamp('2026-02-30 12:00')));
assert.ok(Number.isNaN(rules.eventTimestamp('2026-10-06 24:00')));
assert.ok(Number.isFinite(rules.eventTimestamp('2024-02-29 12:00'))); // 闰年
assert.equal(rules.eventTimestamp('2027-01-01 12:00') - rules.eventTimestamp('2026-12-31 12:00'), 86400000);
});
测的是:过滤条件(类型/状态/类别/自身)、相似度算法的具体数值(不是「差不多」而是 1 / 0.85 / 0.5 / 0.2)、时间边界(7 天整点 vs 超出 1 分钟)、日期合法性(溢出、闰年、跨年)。
7.3 构造测试数据的思路
思路一:先划等价类,再在上界与下界各取一个点。 每个规则至少三种数据:正常值、边界值、非法值。
| 规则 | 正常 | 边界 | 非法 |
|---|---|---|---|
| 首页条数上限 | 5 条 | 29 / 30 / 31 条 | 空数组 |
| 关键词搜索 | 「黑色 雨伞」命中 1 条 | 只命中 1 个词、全角/大小写差异 | 无结果、空关键词、正则字符 .* |
| 名称相似度 | 完全相同 | 阈值 0.25 附近(0.2 / 0.5) | 空字符串、单字「伞」 |
| 时间窗口 | 相差 1 小时 | 正好 7 天、7 天零 1 分 | 2026-02-30、24:00、空字符串 |
| 照片 | 正常 JPEG | 5 MB 整、500000 字符数据 | 空文件、超 5 MB、text/plain、损坏文件 |
| 记录字段 | 齐全 | 40 / 80 / 100 / 500 字 | 超长、缺必填项、非法类型 |
| 存储 | 正常读写 | 空存储首次写入 | 损坏 JSON、版本不符、写入抛错 |
思路二:数据用工厂函数生成,不用手打。 例如上面 manyItems(35) 用 Array.from + Date.UTC 造出时间严格递增的 35 条记录,这样「最新的 30 条」有唯一确定答案;如果手写 35 条,迟早会打错时间导致顺序断言失败。
思路三:专门测「不该发生的事」。 这一类比正常用例更值钱,也是我们重点补的:
- 不修改入参:
filterItems、homeItems、recommend都用structuredClone存快照后比对,防止函数「顺手」改了共享数组; - 失败不能报成功:假 storage(写入抛错)→
result.ok必须为false;假剪贴板(拒绝)→ 必须返回{ok:false}; - 异步顺序:连续选两张照片,断言最终状态是最后一张;处理过程中点移除,迟到的回调不能把旧图写回来;
- 同分排序稳定:分数相同的 4 条线索,断言顺序按发布时间与编号确定,保证多次运行结果一致;
- 越权:非本人记录的状态变更、编辑、删除必须抛错;
- 空与 Infinity:
limit=0返回空、limit=Infinity仍不超过默认上限。
思路四:预判测试人员的刁难。 我们把自己代入「故意挑刺的评审」,列出最可能被问的问题并提前写成用例:
- 「上传一张 10 MB 的图会怎样?」→ 类型/大小校验用例 + 5 MB 边界;
- 「把日期改成 2026-02-30 呢?」→ 溢出用例;
- 「35 条信息时首页显示几条?条数写着 35 却只有 30 张卡会不会算 bug?」→ 条数文案写「共 35 条,显示最新 30 条」,用例断言
total=35、items.length=30; - 「同一个人发两条完全一样的伞,会不会互相推荐?」→ 同类型互斥用例;
- 「把我发过的删掉,别人的详情里推荐会不会残留?」→ 删除/状态变更后线索消失的用例;
- 「你的测试过了,是不是就说明浏览器里一定没问题?」→ 我们在验收文档里明确写了这句反问的答案:不是。图片解码、Canvas 压缩、剪贴板权限、
file://下的本地存储都必须在 Chrome 里人工验收,所以每阶段文档都附了 10 步人工验收清单。
测试运行结果(Node 22,node --test tests/*.test.js,85 个用例全部通过、0 失败):

八、GitHub 的代码签入记录
签入记录截图(仓库 vg666-NiKo/102401615-102401622 的 Commits 页面,共 18 次提交):

提交信息规范:feat: 新功能 / docs: 文档 / chore: 杂项,冒号后写中文说明,一次提交只做一件事。
| 序 | 提交信息 | 作者 | 时间(北京时间) |
|---|---|---|---|
| 1 | Initial commit | vg666-NiKo | 10-06 16:37 |
| 2 | chore: 归档第一次原型并添加开发计划和PSP预估 | vg666-NiKo | 10-06 16:48 |
| 3 | feat: 实现页面框架、首页浏览和类型筛选 | vg666-NiKo | 10-06 17:16 |
| 4 | feat: 实现寻物招领发布、表单校验与本地保存 | vg666-NiKo | 10-06 17:35 |
| 5 | feat: 实现组合搜索、物品详情与返回列表 | vg666-NiKo | 10-06 18:00 |
| 6 | feat: 实现联系方式复制、我的发布与状态更新 | vg666-NiKo | 10-06 20:11 |
| 7 | feat: 实现我的发布编辑、删除与取消编辑 | vg666-NiKo | 10-06 20:36 |
| 8 | feat: 实现物品照片上传、预览与本地保存 | vg666-NiKo | 10-06 20:46 |
| 9 | feat: 增加相似失物线索推荐与匹配理由 | vg666-NiKo | 10-06 20:55 |
| 10 | 基本情况介绍(README 组员与分工) | vg666-NiKo | 10-06 21:12 |
| 11 | 基本情况介绍(README 补充) | vg666-NiKo | 10-06 21:26 |
| 12 | docs: 重写 README,按使用流程组织并补充流程图导航 | drkalerq | 10-06 22:40 |
| 13 | docs: 补充发布、浏览查找与我的发布管理流程图 | drkalerq | 10-06 22:43 |
| 14 | feat: 首页仅展示最新 30 条信息并补充单元测试 | drkalerq | 10-06 22:53 |
| 15 | feat(ui): 重构全局样式并优化页面视觉与图标 | drkalerq | 10-07 11:51 |
| 16 | Merge pull request #2 from drkalerq/main | vg666-NiKo | 10-07 15:41 |
| 17 | Update README.md to reflect current project status | vg666-NiKo | 10-07 15:45 |
| 18 | Update team member information in blog.md | vg666-NiKo | 10-07 15:48 |
这条记录本身就是过程证据:功能提交(1–9)按「框架 → 发布 → 搜索详情 → 我的发布 → 编辑删除 → 照片 → 推荐」的顺序推进,每次提交都能独立运行;文档与流程图的提交(12、13)紧随功能之后;视觉重构(15)单独提交,便于回溯;最后三次(16–18)通过 Pull Request 把两人的改动合并回主干,并同步 README 与组员信息。
九、遇到的代码模块异常或结对困难及解决方法
问题 1:连续选两张照片,第一张把第二张覆盖了
- 问题描述:压缩是异步的。快速连选两张图,第一张若后完成,会把用户真正想要的第二张覆盖掉;在编辑时点「移除照片」,迟到的旧结果还可能把照片重新写回来。
- 做过的尝试:① 一开始先禁用提交按钮,但只挡住了提交、没挡住预览被覆盖;② 试过在回调里比较
file对象,发现同一个文件可以被选中多次,比较不可靠;③ 试过清空input.value后再触发,仍然无法区分「哪一次是最后一次」。 - 是否解决:已解决。最终在每个选择状态里维护递增
ticket,select时++ticket,回调里只有current === ticket才允许写入;set('')(移除照片)同样递增票号,让在途任务自动失效。tests/images.test.js里用「先慢后快」的假处理函数锁住了这个行为。 - 收获:异步竞态不能靠「谁先回来算谁」,必须给每次请求取号,只认最后一次。这个模式后来也被用在其他地方(例如状态变更先保存后刷新)。
问题 2:new Date('2026-02-30 12:00') 不报错,反而变成 3 月 2 日
- 问题描述:线索推荐要用「丢失/捡到时间」比较 7 天窗口。最初直接把字符串丢给
Date.parse,结果不存在的日期被自动进位,2026-02-30被当成合法时间参与比较,导致推荐结果凭空多出一条;同时时区处理不一致还会让跨时区结果漂移。 - 做过的尝试:① 用正则粗筛格式(挡不住
2026-02-30这种「格式对、日期不存在」的情况);② 用Date.parse加Number.isFinite判断(进位后依然有限);③ 想引入日期库,但为了零依赖放弃了。 - 是否解决:已解决。改成「按分量构造 + 构造后回查」:正则取出年、月、日、时、分,用
Date.UTC构造,再反向比较各分量是否与输入一致,不一致就返回NaN;比较统一用 UTC 基准。对应用例覆盖2026-02-30、24:00、闰年2024-02-29、跨年差值恰好一天。 - 收获:日期解析要「构造后回验」,不能相信任何自动进位;日期边界是最容易出现「测试通过但逻辑错」的地方,必须写成显式边界用例。
问题 3:复制联系方式失败了,页面却提示「已复制」
- 问题描述:
navigator.clipboard.writeText在非安全上下文、浏览器未授权或用户拒绝时会 reject。早期版本用try/catch包住,但 catch 里仍然显示「已复制成功」,用户去粘贴发现是空的,会误以为已经联系上失主。 - 做过的尝试:① 只看
navigator.clipboard是否存在(存在不代表可用);② 用document.execCommand('copy')兜底(已被废弃且行为不一致)。 - 是否解决:已解决。把复制逻辑抽成
copyText(text, clipboard):await成功才返回{ok:true},任何异常或环境不支持都返回{ok:false},绝不抛「成功」;页面在失败时提示「自动复制失败,请在下方联系方式中按 Ctrl+C 手动复制」并自动选中文本框。tests/clipboard.test.js注入假的剪贴板对象,覆盖成功、拒绝、没有 API、空文本四种情况。 - 收获:凡是调用外部 API,成功提示必须等到 Promise resolve 之后;同时要给失败留一条人工通路(这里就是 Ctrl+C 手动复制),而不是只报错。
问题 4:保存失败时页面还在显示「成功」
- 问题描述:发布/编辑/状态变更早期都是「先改内存数组,再写存储」。当
localStorage写入抛错(容量不足、隐私模式)时,页面已经刷新成新数据并提示成功,刷新一下全没了。 - 做过的尝试:① 写入后读回来比对(对照片这种大数据开销大);② 只用 try/catch 包住写入(内存已经被改过了)。
- 是否解决:已解决。改成「先保存、后更新」:
saveItems先调用store.save(next),只有result.ok为真才把next赋给内存items;失败时返回错误、界面保持原状(表单内容保留,可以移除照片后重试)。同时读取阶段加了版本号与记录格式校验,读到损坏数据时把writable置为false,暂停发布并提示先备份,避免自动覆盖用户仅存的一份数据。 - 收获:写操作要「失败前置」——把可能失败的动作放在改动状态之前;数据只有一份时,宁可拒绝写入,也不要覆盖。
结对困难:两个人的改动怎么不打架
- 问题描述:两人同时动
index.html和app.js时容易冲突;另外「谁做到了哪一步」光靠聊天记录说不清。 - 做过的尝试:① 口头约定「你先改完我再改」,效率低;② 先各自在本地改完再合并,出现过一次字段名不一致(一个用
image,一个用photo)。 - 是否解决:已解决,方法是接口先行 + 一次功能一次提交。开工前先把数据字段(
id / type / title / category / place / eventTime / createdAt / description / contact / owner / isMine / status / image)和存储接口(load(seed)/save(items)的返回值形状)写进docs/开发计划.md,之后各改各的模块;每完成并自测一个独立功能就提交一次,提交信息写清做了什么,评审时按提交顺序就能看懂进度。 - 收获:「先定接口再写代码」比「先写再对齐」便宜太多;提交粒度小,出问题时能快速定位是哪一次改动引入的。
十、评价队友
值得学习的地方
- 提交粒度非常克制:功能提交 1–9 基本是一个功能一次提交,信息用
feat:开头写清做了什么(如「实现我的发布编辑、删除与取消编辑」),任何人拿到仓库都能按提交顺序复原开发过程。这一点我后来直接沿用。 - 对「失败路径」的重视超过对「正常路径」的重视:复制失败要能手动兜底、照片解码失败要保留原图、存储不可用要暂停而不是覆盖——这些细节都不是作业要求,但他主动做了。
- 验收文档写得像给别人用的:
docs/阶段5验收.md~阶段8验收.md每份都列了 10 步「人工验收」,还明确写了「Node 自动测试没有执行浏览器解码和剪贴板,不能代替人工验收」。这种「知道自己没测到什么」的自觉,对我们这种纯前端项目很关键。 - 范围内的克制:没有为了炫技引入框架和依赖,也没有做假的登录系统,而是用
isMine本地字段老老实实标注归属,并在 README 里说明局限。
需要改进的地方
- 单次提交的体量偶尔偏大:最后一次视觉重构一个提交里改了 CSS 近 850 行、还顺带改了
index.html。如果拆成「抽出颜色变量 → 重构卡片样式 → 加图标与 favicon」三次提交,回溯会更容易。 - 单元测试的断言可以更深入:现有测试已经覆盖了规则与边界,但「状态变更后各页面的显示文案」这类跨模块一致性目前靠人工验收。如果把这些断言也补成自动化测试,回归成本会更低。
十一、局限与后续计划
- 数据只存在当前浏览器,不跨设备共享;没有后端、登录、实名认证与即时通信;
- 线索推荐是文字匹配,无法理解同义词与真实距离,「伞 / 雨伞」这类都靠相似度阈值近似,阈值 0.25 与 7 天窗口是演示选择,未做大规模标定,推荐结果只用于缩小人工核对范围;
- 搜索为前端全量匹配,数据量到千条级别时应改为索引或分页;
浙公网安备 33010602011771号