软件工程第四次作业:"校园失物招领"程序实现
软件工程第四次作业:"校园失物招领"程序实现
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601 软件工程与软件工程实践 |
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次之程序实现) |
| 我的博客/本作业链接 | 陈薇薇 102402109 / 本次作业 |
| 结对队友博客/本作业链接 | 李青容 102402111 / 本次作业 |
| GitHub 仓库 | 102402109-102402111 |
一、项目简介
"校园失物招领"是一个无需后台即可运行的校园失物招领 Web 应用。我们基于第一次结对作业的原型,把"发布信息—浏览或搜索—查看详情—联系发布者—更新状态"实现为可操作的完整流程。
用户可发布寻物或招领信息,通过关键词、类型、类别筛选内容,在详情中查看并复制联系方式;发布者可在"我的发布"中把信息更新为"已找到"或"已归还"。
为保证助教下载后可以直接复现,项目使用原生 HTML、CSS、JavaScript 和浏览器 localStorage,不依赖服务器、数据库或第三方框架,Chrome 直接打开 index.html 即可运行。
二、结对分工
本次结对作业由我和李青容同学共同完成,具体分工如下:
| 成员 | 负责内容 |
|---|---|
| 陈薇薇 102402109 | 数据层 data.js、首页 index.html + app.js、发布页 publish.html + publish.js、详情页 detail.html + detail.js、单元测试 |
| 李青容 102402111 | 我的发布 my.html + my.js、README 编写、需求分析与功能评审、测试用例设计、fork + PR 协作演示、页面验收测试 |
两人共同参与需求讨论、功能规划与测试验证。代码实现由陈薇薇主要负责,李青容负责需求把控与质量验收,通过 GitHub 的 fork + PR 流程完成协作。
三、PSP
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 45 |
| Estimate | 估计这个任务需要多少时间 | 20 | 30 |
| Development | 开发 | 400 | 520 |
| Analysis | 需求分析(包括学习新技术) | 40 | 60 |
| Design Spec | 生成设计文档 | 30 | 25 |
| Design Review | 设计复审 | 20 | 15 |
| Coding Standard | 代码规范 | 15 | 10 |
| Design | 具体设计 | 50 | 70 |
| Coding | 具体编码 | 180 | 240 |
| Code Review | 代码复审 | 30 | 40 |
| Test | 测试 | 40 | 60 |
| Reporting | 报告 | 60 | 80 |
| Test Report | 测试报告 | 20 | 30 |
| Size Measurement | 计算工作量 | 10 | 10 |
| Postmortem & Process Improvement Plan | 事后总结 | 30 | 40 |
| 合计 | 945 | 1315 |
PSP 分析:
预估与实际存在差距,主要原因:
- 学习成本被低估:Node.js、npm、Mocha 测试框架都是边学边用,环境配置和工具调试消耗了大量时间;
- 页面交互排查反复:卡片跳转、图片渲染、localStorage 缓存问题,经常需要清缓存重新测试;
- 代码语法错误排查:
data.js加image字段时漏写了逗号,导致整个脚本崩溃,排查花了额外时间。
以后的项目中要在预估阶段为"学习新技术"和"环境搭建"留出更多缓冲时间。
四、解题思路与设计实现
4.1 代码实现思路
项目基于第一次结对作业的 HTML 原型,采用原生 HTML + CSS + JavaScript 实现,无需后端。整体架构分三层:
① 数据层(js/data.js)
负责所有数据 CRUD,基于浏览器 localStorage 持久化。封装接口:
getAllItems()—— 读取全部数据,首次访问时自动写入种子数据addItem(item)—— 新增一条信息getItemById(id)—— 按 id 查询updateItemStatus(id, status)—— 更新状态(已找到/已归还)deleteItem(id)—— 删除filterItems({keyword, type})—— 关键词搜索 + 分类筛选formatTime(ts)—— 时间格式化
② 页面层(HTML)
index.html—— 首页,只保留空的<div class="cards">,卡片由 JS 动态渲染publish.html—— 发布表单,含图片上传detail.html—— 详情展示,含状态操作按钮my.html—— 我的发布列表
③ 逻辑层(js/*.js)
每个页面有一个对应的 JS 文件,负责调用数据层 API、渲染界面、绑定事件。
核心设计原则:所有页面通过数据层交互,数据只有一份,不存在同步问题。
4.2 关键流程图
4.3 有价值的代码片段
① 数据层统一封装(js/data.js)
function filterItems({ keyword = "", category = "全部", type = "全部" } = {}) {
const kw = keyword.trim().toLowerCase();
return getAllItems().filter(item => {
if (type !== "全部" && item.type !== type) return false;
if (kw) {
const haystack = [
item.name, item.description, item.location, item.category
].join(" ").toLowerCase();
if (!haystack.includes(kw)) return false;
}
return true;
});
}
解释: 搜索覆盖物品名称、描述、地点、类别四个字段,用 toLowerCase() 实现大小写不敏感匹配,用 trim() 处理前后空格。这是整个应用最核心的"搜索 + 筛选"逻辑。
② 首页动态渲染(js/app.js)
cardsContainer.innerHTML = items.map(item => `
<div class="card" data-id="${item.id}">
${item.image ? `
<div class="card-image">
<img src="${item.image}" alt="${escapeHtml(item.name)}"
onerror="this.parentElement.style.display='none'">
</div>
` : ""}
<div class="card-top">
<span class="tag ${item.type === "寻物" ? "lost" : "found"}">${item.type}</span>
<span class="status">${formatTime(item.createdAt)}</span>
</div>
<h3>${escapeHtml(item.name)}</h3>
<p>${escapeHtml(item.description)}</p>
<div class="card-bottom">
<span class="location">📍 ${escapeHtml(item.location)}</span>
<span class="detail-btn">查看详情 →</span>
</div>
</div>
`).join("");
解释: 用 map + join 生成 HTML 字符串,escapeHtml 防 XSS 攻击,图片容错处理(onerror 隐藏加载失败的图片块,不影响卡片布局)。
五、附加特点设计与展示
5.1 设计的创意独到之处与意义
除基础功能外,额外实现了以下人性化设计:
① 一键复制联系方式
用户在详情页不需要手动长按复制,点击按钮即可将 QQ / 微信 / 手机号复制到剪贴板,减少手动操作,提高联系效率。
② 物品图片上传与展示
发布时允许上传图片(Base64 编码存入 localStorage),首页卡片和详情页都能看到物品真实照片,避免"文字描述不清晰"的沟通障碍。
③ 状态标记与视觉反馈
信息发布后可标记为"已找到"或"已归还",卡片上显示状态徽章,其他用户一眼看出信息是否有效,减少无效联系。
④ 分类筛选 + 关键词搜索
顶部支持"全部 / 寻物 / 招领"分类切换,搜索框支持物品名称、描述、地点、类别多字段模糊匹配。
5.2 实现思路
- 一键复制:使用
navigator.clipboard.writeText()API 复制到剪贴板 - 图片上传:使用
FileReader.readAsDataURL()转 Base64 字符串,再作为<img src>渲染 - 状态标记:在数据对象的
status字段上标记,渲染时根据状态显示徽章 - 分类筛选:
filterItems函数同时接收keyword和type参数,联合过滤
5.3 关键代码片段(图片上传)
imageInput.addEventListener("change", function (event) {
const file = event.target.files[0];
if (!file) return;
if (file.size > 500 * 1024) {
alert("图片太大啦,请选择 500KB 以内的图片!");
return;
}
const reader = new FileReader();
reader.onload = function (e) {
imageBase64 = e.target.result;
imagePreview.innerHTML = `
<img src="${imageBase64}" alt="预览">
<button type="button" class="remove-img" id="removeImgBtn">×</button>
`;
};
reader.readAsDataURL(file);
});
解释: 用 FileReader 读图片文件,转成 Base64 字符串。因为 localStorage 只能存文本,Base64 是最简单的方案。加了 500KB 大小限制,避免 localStorage 溢出。
5.4 实现成果
首页(含图片卡片、搜索框、分类筛选):


详情页(含大图 + 复制按钮 + 状态标记):

发布页(含图片上传):


我的发布页:

六、目录与使用说明
6.1 目录组织
校园失物招领/
├── index.html 首页(浏览、搜索、分类)
├── publish.html 发布信息页
├── detail.html 详情页(查看、复制、标记状态、删除)
├── my.html 我的发布
├── css/
│ └── style.css 全局样式
├── js/
│ ├── data.js 数据层(localStorage 封装)
│ ├── app.js 首页逻辑
│ ├── publish.js 发布逻辑
│ ├── detail.js 详情逻辑
│ └── my.js 我的发布逻辑
├── images/ 图片素材
├── tests/
│ └── data.test.js 数据层单元测试
├── package.json npm 项目配置
└── README.md 项目说明文档
6.2 如何运行网页
- 下载/克隆本项目所有文件到本地
- 使用谷歌浏览器(Chrome),推荐配合 VS Code 的 Live Server 插件运行
- 在 VS Code 里右键
index.html→ Open with Live Server - 浏览器自动打开
http://127.0.0.1:5500/index.html
⚠️ 不建议直接双击
index.html,因为浏览器对file://协议下的 localStorage 有安全限制,可能导致卡片不显示。
6.3 数据存储说明
所有数据保存在浏览器 localStorage 的 lostFoundItems 字段下。首次打开自动写入 6 条示例数据。
清空数据方法: 按 F12 → Application → Local Storage → 删除 lostFoundItems 字段。
6.4 运行单元测试
环境要求: Node.js(v18 以上)
# 1. 安装依赖
npm install
# 2. 运行测试
npm test
预期输出: 12 passing
七、单元测试
7.1 测试工具介绍
我们选用 Mocha + Chai + JSDOM 组合:
- Mocha —— 测试运行框架,负责组织测试用例
- Chai —— 断言库,提供
expect(...).to.equal(...)语法 - JSDOM —— 在 Node.js 中模拟浏览器 DOM 和 localStorage 环境
学习过程:
我们之前没有单元测试经验,通过查阅资料(廖雪峰的 JS 教程、Mocha 官方文档)逐步上手。核心难点是 localStorage 在 Node.js 中不存在,需要用 JSDOM 模拟。
简易教程:
# 第 1 步:初始化项目
npm init -y
# 第 2 步:安装测试工具
npm install --save-dev mocha chai jsdom
# 第 3 步:修改 package.json 的 scripts
# "test": "mocha tests/**/*.test.js"
# 第 4 步:编写测试文件 tests/data.test.js
# 第 5 步:运行
npm test
7.2 部分单元测试代码
const { expect } = require("chai");
const { JSDOM } = require("jsdom");
const dom = new JSDOM("", { url: "http://localhost" });
global.window = dom.window;
global.localStorage = dom.window.localStorage;
const data = require("../js/data.js");
describe("校园失物招领 - 数据层单元测试", () => {
beforeEach(() => {
localStorage.clear();
});
it("首次调用 getAllItems 应返回 6 条种子数据", () => {
const items = data.getAllItems();
expect(items).to.be.an("array");
expect(items.length).to.equal(6);
});
it("addItem 应新增一条并返回带 id 的对象", () => {
data.getAllItems();
const newItem = data.addItem({
type: "寻物", name: "测试物品", category: "其他",
location: "测试地点", time: "2026-10-06T10:00",
description: "测试描述", contact: "test-contact"
});
expect(newItem).to.have.property("id");
expect(newItem.status).to.equal("进行中");
expect(data.getAllItems().length).to.equal(7);
});
it("updateItemStatus 应将信息状态改为已找到", () => {
const ok = data.updateItemStatus(1, "已找到");
expect(ok).to.be.true;
expect(data.getItemById(1).status).to.equal("已找到");
});
it("filterItems 按类型'寻物'应只返回 type=寻物 的数据", () => {
const res = data.filterItems({ type: "寻物" });
expect(res.length).to.be.greaterThan(0);
res.forEach(item => expect(item.type).to.equal("寻物"));
});
// ... 共 12 个测试用例
});
测试的函数:
getAllItems—— 初始化种子数据addItem—— 新增并生成 idgetItemById—— 按 id 查找updateItemStatus—— 状态更新filterItems—— 搜索与筛选deleteItem—— 删除formatTime—— 时间格式化
7.3 测试数据构造思路
考虑正常、边界、异常三类情况:
正常情况:
- 数据初始化为 6 条
- 新增 1 条后变 7 条
- 搜索"校园卡"能匹配
- 状态更新成功
边界情况:
- 搜索不存在的关键词 → 返回空数组
- 查找不存在的 id → 返回 null
- 更新不存在的 id → 返回 false
异常情况:
formatTime(null)→ 返回空字符串formatTime(undefined)→ 返回空字符串
面向未来测试人员的"刁难",我们的考虑:
- 每个用例独立 ——
beforeEach里清空 localStorage,避免数据污染 - 不依赖执行顺序 —— 每个测试自己构造前置条件
- 覆盖异常输入 —— 空值、越界 id、不存在的关键词都已测试
- 断言具体 —— 不只看真假,还断言具体值(如 id 存在、status 正确)
测试运行结果:

八、GitHub 签入记录
开发过程遵循"每完成一个功能就 commit 一次"的原则。

commit 记录说明:
| Commit | 说明 |
|---|---|
| chore: 初始化项目结构 | 建 index.html / publish.html / style.css |
| feat(data): 新增数据层 | data.js 封装 localStorage |
| feat(index): 首页动态渲染 | app.js 渲染卡片 + 搜索 + 分类 |
| feat(publish): 发布表单对接数据层 | 表单校验 + addItem |
| feat(detail): 新增详情页 | detail.html + detail.js |
| feat(my): 新增「我的发布」 | my.html + my.js |
| test: 添加数据层单元测试 | 12 个用例 |
| docs: 编写 README | 目录说明 + 使用说明 |
| style: 首页与详情页添加物品配图 | 图片渲染 |
| feat(publish): 支持上传物品图片 | Base64 图片上传 |
fork + PR 协作记录:
按照结对编程的要求,我们采用了 fork + PR 的协作流程:一人创建主仓库,另一人 fork 后提交 PR,最后合并。


九、遇到的代码模块异常或结对困难及解决方法
问题 1:`file://` 协议下 localStorage 无法使用
问题描述:
最初直接双击 index.html 打开,浏览器 Console 报错:
Unsafe attempt to load URL ... 'file:' URLs are treated as unique security origins.
导致 localStorage 无法使用,卡片无法渲染。
做过哪些尝试:
- 尝试换浏览器(Edge、Chrome)—— 都报错
- 尝试加
localStorage.clear()—— 没用
是否解决:
解决了。改用 VS Code 的 Live Server 插件(或 python -m http.server)通过 HTTP 协议访问,问题消失。
有何收获:
理解了浏览器对 file:// 协议的安全限制,以及 Web 开发应该走 HTTP 协议。
问题 2:`data.js` 加 `image` 字段后语法错误
问题描述:
给种子数据加 image 字段后,页面全白,Console 报:
Uncaught SyntaxError: Unexpected identifier 'image' data.js:20
做过哪些尝试:
- 检查引号是否配对
- 检查字段名拼写
- 最后发现:
createdAt: 1759721520000后面漏了逗号,导致image字段语法错误
是否解决:
解决了。补齐 6 条记录末尾的逗号。
有何收获:
JS 对象末尾的逗号很容易漏,尤其是多字段对象。以后加字段要仔细检查。
问题 3:浏览器缓存导致图片显示不出来
问题描述:
加了图片字段后,刷新首页还是没图。
做过哪些尝试:
- 检查图片路径 —— 正确
- 检查
data.js有没有保存 —— 已保存
是否解决:
解决了。原因是浏览器 localStorage 里存着旧数据(没有 image 字段),页面按旧数据渲染。清掉 localStorage 后恢复。
有何收获:
使用 localStorage 时,改了数据结构必须清缓存才能看到效果。开发阶段应该养成"清缓存 + 强刷"的习惯。
结对困难:两人如何高效协作
问题描述:
两人无法时刻在同一台电脑前,代码合并容易冲突。
做过哪些尝试:
- 用 GitHub 的 fork + PR 流程 —— 队友改完提交 PR,我合并
- 用 commit message 规范 让双方都能看懂对方改了什么
是否解决:
解决了。通过 fork + PR 流程,两人可以并行开发,主仓库始终保持可用状态。
有何收获:
掌握了 GitHub 协作的核心流程,理解了 PR 在代码审查和团队协作中的价值。
十、评价队友
值得学习的地方:
队友在需求评审和测试验收环节非常认真。尤其是:
- 在需求评审阶段,对"标记状态后其他用户如何看到结果"这一流程提出了明确的改进建议,避免了我们只做"成功路径"而忽略异常情况
- 主动学习了 GitHub 的 fork + PR 协作流程,配合我完成了协作演示
- 在页面验收时,找出了几个边界情况(比如搜索无结果、图片上传超限)的提示缺失问题
需要改进的地方:
- 代码参与度可以更高一些
- commit 记录可以更细化,方便回溯
整体评价:
队友是个可靠的搭档。沟通顺畅、进度同步、分工清晰,配合过程没有出现明显扯皮或返工。虽然本次代码主要由我实现,但队友在需求评审与测试验收上的投入,帮助项目在细节上更完善,期待下次继续合作。

浙公网安备 33010602011771号