2026 秋软件工程结对作业(第二次之程序实现)
校园失物招领 · 结对作业博客
| 这个作业属于哪个课程 | 前往班级 |
|---|---|
| 这个作业要求在哪里 | 前往查看 |
| 这个作业的目标 | 基于第一次结对作业的原型,用纯前端(HTML/CSS/JS)实现校园失物招领 WEB 应用的核心功能 |
| 队员1学号姓名 | 102401309 叶凯乐 |
| 队员2学号姓名 | 102401234 黄俊凡 |
| 结对同学博客链接 | 叶凯乐;黄俊凡 |
| 本次作业博客 | 本文链接 |
| GitHub 仓库 | campus-lost-and-found |
一、结对分工与过程
1.1 具体分工
| 成员 | 负责内容 |
|---|---|
| 叶凯乐 | 数据层与业务逻辑(core / storage / images / backup / comments / claims / stats)、单元测试、图片压缩与 XSS 防护 |
| 黄俊凡 | 页面模板与交互(views / ui / app)、hash 路由、响应式布局与导航重构、深色模式、统计页、图片全屏查看器、真实 Chrome 验收脚本 |
1.2 PSP 表格
| PSP2.1 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|
| Planning(计划) | 30 | 40 |
| Estimate(估计任务时间) | 30 | 30 |
| Analysis(需求分析) | 60 | 90 |
| Design Spec(设计说明) | 60 | 90 |
| Design Review(设计复审) | 30 | 45 |
| Coding Standard(代码规范) | 20 | 20 |
| Design(具体设计) | 60 | 80 |
| Coding(编码) | 300 | 640 |
| Code Review(代码复审) | 60 | 120 |
| Test(测试) | 120 | 240 |
| Test Report(测试报告) | 60 | 90 |
| Size Measurement(计算工作量) | 30 | 40 |
| Postmortem(事后总结) | 30 | 45 |
| 合计 | 890 | 1670 |
二、解题思路与设计实现
2.1 代码实现思路(文字描述)
作业要求下载后能直接用 Chrome 打开,因此我们使用 HTML、CSS 和 JavaScript 编写页面,把数据保存在浏览器中,省去安装依赖和启动服务器的步骤。
- 页面导航。整个应用只有一个
index.html,通过#/home、#/search等地址切换首页、搜索、发布和详情等页面。已保存的信息在刷新后仍可读取,页面支持浏览器前进和后退。 - 代码组织。校验、搜索和状态更新放在
core.js,本地存储放在storage.js,页面模板放在views.js,页面事件和交互处理放在app.js。业务逻辑不依赖 DOM,测试可以直接引用网页使用的代码。 - 本地数据保存。用键
campus-lost-found:v1保存{ version, ownerId, posts: [] },首次打开时生成匿名发布者标识。通过ownerId判断哪些信息属于「我的发布」。 - 使用流程。用户发布信息后,可以在列表中浏览或搜索,进入详情复制联系方式。物品找回或归还后,发布者在「我的发布」中更新状态,首页和详情页也会显示对应结果。
2.2 关键流程图
数据流:表单输入 → core.validatePost 校验 → core.createPost 生成记录 → storage.save 写入 localStorage → 重新渲染列表。读数据时 storage.load 会先做结构校验,损坏则拒绝覆盖。
2.3 重要代码片段与解释
① 数据模型与校验(core.js)——所有字段集中定义、集中校验,非法类型、超长、未来时间、坏图片都会被拦下:
const CATEGORIES = Object.freeze([
'证件卡片', '数码设备', '钥匙', '包袋', '衣物饰品', '生活用品', '书本文具', '其他'
]);
const LIMITS = Object.freeze({ title: 60, location: 80, description: 1000, contact: 100 });
function validatePost(input, now = new Date()) {
const errors = {};
if (input.type !== 'lost' && input.type !== 'found') errors.type = '请选择寻物或招领';
// 每个必填字段:非空 + 长度上限;occurredAt 还要是合法日期且不能晚于现在
// image 必须是 data:image/(jpeg|png|webp);base64 白名单,否则拒绝
return errors;
}
② localStorage 持久化(storage.js)——保存前先 validState 校验,损坏数据绝不静默覆盖;空间不足抛 STORAGE_WRITE,表单内容保留:
function save(state) {
if (!validState(state))
throw new Core.DomainError('STORAGE_CORRUPT', '本地数据格式不正确,未覆盖原有数据');
try {
storage.setItem(key, JSON.stringify(state));
} catch (_) {
throw new Core.DomainError('STORAGE_WRITE', '保存失败……填写内容仍然保留。');
}
return state;
}
③ 业务与浏览器双导出——同一个 core.js 在浏览器挂到 window.CampusCore,在 Node 里走 module.exports,测试直接测真实实现:
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.CampusCore = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () { /* ... */ });
三、附加特点设计与展示
3.1 附加特点
基本功能完成后,我们重点补充了四项功能,方便用户查找物品、联系发布者和保存信息。
- 组合筛选。只记得物品类别和大致地点时,可以同时设置类别、地点和日期范围,缩小搜索结果;也可以按发布时间或物品发生时间排序。比如查找在图书馆遗失的校园卡,就不必逐条翻看其他地点的钥匙、耳机等信息。
- 一键复制联系方式。详情页提供复制按钮,减少手动抄写微信号或 QQ 号的麻烦。如果浏览器不允许自动复制,页面会保留可选中的联系方式,供用户手动复制。
- 物品照片与默认插画。发布寻物或招领信息时,可以选择上传照片,帮助浏览者辨认物品;没有照片时,卡片按类别显示默认插画。上传图片会先压缩,减少本地存储占用。详情图支持双击放大、拖拽和滚轮缩放,方便查看物品细节。
- 数据备份与恢复。可以把本机数据导出为 JSON 文件,在需要时重新导入。导入时按编号合并记录,并恢复备份中的发布者标识,方便继续管理原来的发布信息。
3.2 实现思路
图片处理在 js/images.js:validateFile 做类型/大小/非空校验,fitDimensions 保持宽高比缩到最长边 1200px,preparePhoto 用 canvas drawImage 重绘后 toDataURL('image/jpeg', quality),超过存储上限就逐次降质量、再降尺寸,最多 8 次。
组合筛选由 core.filterPosts 处理,一条信息需要同时满足已选择的条件才会显示。复制功能会等待剪贴板写入成功后再提示成功,失败时提供手动复制的方式。备份功能放在 js/backup.js,导入前先检查文件格式和数据结构,通过后再合并记录。
3.3 代码片段
async function preparePhoto(file) {
validateFile(file); // 类型 / 大小 / 非空
const url = URL.createObjectURL(file);
try {
const image = await loadImage(url);
if (image.naturalWidth * image.naturalHeight > MAX_PIXELS)
throw new Error('图片分辨率过高……');
const canvas = document.createElement('canvas');
let maxSide = 1200, quality = 0.84;
for (let attempt = 0; attempt < 8; attempt += 1) {
const size = fitDimensions(image.naturalWidth, image.naturalHeight, maxSide);
canvas.width = size.width; canvas.height = size.height;
const context = canvas.getContext('2d');
context.fillStyle = '#ffffff';
context.fillRect(0, 0, size.width, size.height);
context.drawImage(image, 0, 0, size.width, size.height);
const result = canvas.toDataURL('image/jpeg', quality);
if (result.length <= Core.MAX_IMAGE_LENGTH) return result;
quality > 0.5 ? (quality -= 0.16) : ((maxSide *= 0.75), (quality = 0.72));
}
throw new Error('图片无法压缩到可保存的大小,请换一张图片');
} finally { URL.revokeObjectURL(url); }
}
认领申请的确认逻辑(js/claims.js):发布者确认后,信息自动标记为已解决。以下为节选,省略了记录不存在等校验和更新时间的处理。
function decideClaim(posts, postId, claimId, decision, ownerId, now = new Date()) {
const post = posts.find((item) => item.id === postId);
if (post.ownerId !== ownerId) throw new Core.DomainError('FORBIDDEN', '只能处理自己的信息');
if (!['accepted', 'rejected'].includes(decision)) throw new Core.DomainError('VALIDATION', '无效结果');
const claim = post.claims.find((c) => c.id === claimId);
if (claim.status !== 'pending') return posts; // 已处理则幂等返回
return posts.map((item) => item.id === postId
? { ...item,
claims: item.claims.map((c) => c.id === claimId ? { ...c, status: decision } : c),
status: decision === 'accepted' ? 'resolved' : item.status }
: item);
}
3.4 成果展示







3.5 页面走查与修改记录
几轮测试发现并修复以下问题:
| 轮次 | 发现的问题 | 处理结果 |
|---|---|---|
| 第 1 轮 | 深色模式下「我的发布」页底部仍为浅色 | 给 html 根元素也设置深色背景 |
| 第 2 轮 | 统计数值字号过大、日期卡槽与排序卡槽风格不统一、发布页空态字体偏粗深 | 统一字号 / 边框 / 圆角 / 占位色 |
| 第 3 轮 | 详情页右侧下方空缺较多 | 新增「温馨提示」卡片填充 |
| 第 4 轮 | 上传图片后溢出成为信息卡片背景 | 图片改用 max 约束并加溢出裁剪 |
| 第 5 轮 | 图片查看需浏览器式体验 | 重做为全屏查看器,支持拖拽与缩放 |
| 第 6 轮 | 合并布局重构后,深色模式部分卡片(图标按钮 / 类型选择 / 图片上传区等)配色不统一 | 补齐深色背景覆盖,输入聚焦不再变白 |
| 第 7 轮 | 发布页顶部多余返回键导致标题偏移 | 移除顶部返回键(表单底部仍有取消按钮) |
| 第 8 轮 | 深色模式下物品插画仍为浅色块 | 插画加滤镜压暗,融入深色背景 |
| 第 9 轮 | 左侧边栏导航导致内容左偏移、卡片位置不统一 | 导航改为顶部横向布局 |
四、目录说明与使用说明
4.1 目录组织
campus-lost-and-found/
├── index.html 唯一网页入口(hash 路由)
├── css/styles.css 页面样式,桌面 / 窄屏自适应
├── js/
│ ├── core.js 表单校验、搜索、归属与状态业务逻辑(可被 Node 直接引用)
│ ├── comments.js 物品留言(评论)
│ ├── claims.js 认领申请与发布者确认
│ ├── storage.js 数据结构检查及 localStorage 持久化
│ ├── backup.js 数据导出 / 导入备份
│ ├── seed.js 8 条示例信息
│ ├── stats.js 数据统计
│ ├── ui.js 文本转义、路由、复制与图标工具
│ ├── images.js 图片文件检查、等比缩小与压缩
│ ├── views.js 各页面 HTML 模板
│ └── app.js 页面导航、事件、表单与状态同步
├── tests/ Node 自动化单元测试(node:test,零依赖)
├── scripts/browser-check.cjs 可选的真实 Chrome 验收脚本(Playwright)
└── package.json 仅含测试命令,网页运行不依赖 Node
4.2 如何运行
- 在 GitHub 选择 Code → Download ZIP 解压整个项目(或
git clone)。 - 找到
index.html,右键 → 打开方式 → Google Chrome(不需要装 Node、npm install或启动本地服务器)。 - 首页显示 8 条标注「示例」的信息;点导航「发布信息」选择寻物/招领即可发布。
- 注意保留
css、js目录与index.html的相对位置,不要只拷走一个 HTML。
当前版本使用浏览器本地存储,没有连接服务器,不同电脑上的数据不会自动互通。留言和认领申请也保存在当前浏览器中;发布者处理认领的过程可以通过模拟申请进行演示。
运行单元测试(仅开发时,需 Node 22+,项目零 npm 依赖):
npm test # 运行全部单元测试
npm run test:coverage # 附带覆盖率报告
五、单元测试
5.1 测试工具与学习过程
我们使用 Node 内置的 node:test 和 node:assert/strict,不需要额外安装测试框架。写测试前先了解如何定义用例、比较结果和检查异常,再把这些写法用到表单校验、搜索和存储测试中。
共编写 106 个单元测试用例,分布在 8 个文件:core.test.cjs(43 个,业务逻辑、搜索与撤回)、storage.test.cjs(18 个,持久化与数据安全)、ui.test.cjs(13 个,转义/路由/剪贴板/高亮)、images.test.cjs(9 个,图片校验与缩放)、backup.test.cjs(7 个,备份导入导出)、comments.test.cjs(6 个,留言)、claims.test.cjs(6 个,认领)、stats.test.cjs(4 个,统计)。此外还有 scripts/browser-check.cjs,用 Playwright 驱动真实 Chrome 做了 30 余项端到端验收。
5.2 部分单元测试代码与说明
编写一个测试可以分为以下几步:
- 在
tests目录新建以.test.cjs结尾的文件。 - 引入测试工具和要测试的模块,准备输入数据。
- 用
test描述一种情况,调用函数,再用断言检查结果。 - 在项目目录执行
npm test。如果失败,根据输出中的用例名称和断言位置检查原因。
例如,下面是一个可以单独运行的用例,用来检查纯空格的物品名称是否会被拒绝:
const test = require('node:test');
const assert = require('node:assert/strict');
const Core = require('../js/core.js');
test('纯空格的物品名称不能通过校验', () => {
const errors = Core.validatePost({ title: ' ' });
assert.ok(errors.title);
});
assert.ok(errors.title) 检查是否返回了名称字段的错误。如果校验遗漏了纯空格的情况,这条断言就会失败。这里仅检查名称字段,其他字段的要求由各自的用例验证。
下面节选了搜索、编辑和存储测试,公共测试数据的定义已省略:
test('标题搜索不区分英文大小写并忽略首尾空格', () => {
assert.deepEqual(
Core.filterPosts(records, { keyword: ' airpods ' }).map((p) => p.id),
['new']
);
});
test('本人编辑更新资料,保留唯一编号和原始发布时间', () => {
const changed = Core.editPost(original, { ...valid, title: '白色耳机', id: 'hacked' }, 'me', now);
assert.equal(changed.title, '白色耳机');
assert.equal(changed.id, 'p1'); // id 不允许被覆盖
assert.equal(changed.createdAt, original.createdAt); // 发布时间不变
assert.equal(original.title, '蓝牙耳机'); // 原对象不被修改(不可变更新)
});
test('拒绝未知版本并保留原数据', () => {
storage.setItem(STORAGE_KEY, JSON.stringify({ version: 2, ownerId: 'me', posts: [] }));
assert.throws(() => repository(storage).load(), (e) => e.code === 'STORAGE_CORRUPT');
assert.equal(storage.getItem(STORAGE_KEY), JSON.stringify({ version: 2, ownerId: 'me', posts: [] }));
});
第一条用例检查带空格的小写关键词能否找到目标记录;第二条检查编辑名称时,编号和原始发布时间是否保持不变;第三条检查读到不支持的数据版本时,程序是否报错并保留原数据。
运行结果:106 项通过,0 项失败。上面的入门示例用于说明测试写法,不另计入项目的测试数量。
5.3 构造测试数据的思路与边界考虑
- 固定时间:测试里用
new Date(2026, 9, 8, 14, 0)固定「当前时间」,避免用例受真实时钟影响;occurredAt也取固定字符串,未来时间/非法日期(如 2 月 30 日)可被稳定复现。 - 按分支准备输入:根据校验函数中的判断,分别准备正常输入和异常输入。例如将 6 个必填字段逐个置空,检查非法图片、未知数据版本、重复编号和非法时间戳是否被拒绝。这样能检查具体分支,避免只验证顺利发布的情况,但不代表已经覆盖所有可能的输入。
- 只测纯函数与数据层:DOM 行为交给
browser-check.cjs的真实浏览器验收,单元测试保持快且确定。
六、GitHub 提交记录
完整可见仓库

七、困难与解决
| 问题 | 尝试与解决 | 是否解决 | 收获 |
|---|---|---|---|
| 纯前端没有后端,如何「区分谁发布的」 | 用 localStorage 存匿名 ownerId,示例记录 ownerId='demo';「我的发布」只过滤当前 ownerId |
✅ | 明确了「本机演示」与「真实账号认证」的边界 |
| 图片存 localStorage 容易超限 | canvas 等比缩到 1200px、压缩 JPEG、质量/尺寸递降重试,图片大小上限 36 万字符 | ✅ | 理解了浏览器存储配额与图片压缩的工程权衡 |
| 用户输入带 HTML 会注入脚本 | 全量 escapeHTML + 图片 base64 白名单校验,拒绝 javascript:、SVG 脚本 |
✅ | XSS 防御要做在「输出」和「输入校验」两端 |
| localStorage 被改坏导致白屏 | validState 保存/读取双重校验,损坏时不静默覆盖,给出备份指引 |
✅ | 数据完整性比「能跑」更重要 |
| 多标签页/未保存离开造成数据丢失 | 监听 storage 事件同步;dirty 标记 + beforeunload 提示 |
✅ | 状态同步要覆盖多入口场景 |
| 同一份逻辑如何同时跑在浏览器和 Node 测试 | 用 IIFE 双导出(浏览器挂 window、Node 走 module.exports) |
✅ | 模块边界清晰,测试才能直接测真实实现 |
| 详情图上传后溢出成为卡片背景 | 图片沿用 width/height:100% 在 grid 里解析到自然尺寸;改用 max-width/max-height:100% + overflow:hidden 裁剪 |
✅ | 百分比高度在 grid/flex 里解析要谨慎,约束图片用 max 更稳 |
| 深色模式页面底部发白 | 只给 body 设了深色背景、html 仍是浅色,内容较短时底部露出;给 html 根元素也设深色背景 |
✅ | 主题切换要同时覆盖 html 与 body 两级背景 |
八、队友评价
8.1 值得学习的地方
- 叶凯乐 → 黄俊凡:对页面细节比较细心,除了页面跳转,也考虑了未保存就离开的提醒。检查页面时能发现配色、字体和控件样式不统一的问题,并逐步调整。
- 黄俊凡 → 叶凯乐:数据校验写得比较集中,查问题时容易找到对应位置。测试也考虑了空输入、非法日期和保存失败等情况,这些是我容易遗漏的地方。
8.2 需要改进的地方
- 叶凯乐 → 黄俊凡:可以更早锁定配色与卡片尺寸规范,减少后期统一返工。
- 黄俊凡 → 叶凯乐:部分注释可更精简,前期 commit 可以拆得更小、更聚焦。
九、个人总结
这次作业我主要负责页面和交互。原型转成网页后才发现,页面画出来只是第一步,发布、搜索、查看详情和修改状态能否顺畅地连起来,还需要反复操作检查。比如上传照片后图片超出卡片、深色模式下部分背景仍然发白,这些问题都是实际使用时才注意到的。
前期没有把布局和常用控件的样式确定好,后面花了不少时间调整,也让编码和测试的耗时超出了预估。下次我会先把基本流程跑通,尽早检查不同屏幕和页面状态下的效果,再补充其他功能,减少最后集中返工的情况。
本次的界面布局暂时只做了电脑上查看的布局,手机布局尚未完成,可考虑在后续继续加入。

浙公网安备 33010602011771号