软件工程第四次作业:“校园失物招领”程序实现
本文是李青容(102402111)对本次结对编程和项目实现过程的个人记录。文章重点说明项目设计、我负责的模块、测试与验收、GitHub 协作过程以及协作体会。
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601 软件工程与软件工程实践 |
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次之程序实现) |
| 我的博客 | 李青容 102402111 / oqst |
| 结对队友博客 | 陈薇薇 102402109 / Vvvicky- |
| GitHub 主仓库 | Vvvicky005/102402109-102402111 |
| 我的 fork / PR | opqrst121100/102402109-102402111 / PR #1 |
| GitHub 个人主页 | https://github.com/opqrst121100 |
一、项目简介
“校园失物招领”是一个面向校内师生的失物招领 Web 应用。项目不需要后端服务器、数据库或第三方前端框架,数据保存在浏览器 localStorage 中,下载源码后通过本地 HTTP 服务即可运行。
项目实现的核心流程为:
发布信息 → 浏览或搜索 → 查看详情 → 联系发布者 → 更新信息状态
用户既可以发布“寻物”信息,也可以发布“招领”信息;首页支持关键词搜索和类型筛选;详情页可以查看完整描述、复制联系方式、把状态更新为“已找到”或“已归还”;“我的发布”页面则集中展示本人发布过的全部信息。
本项目是在第三次结对作业需求分析和原型设计的基础上继续实现的。我们在本次开发中,把原先只停留在页面原型上的功能,落实成了可以实际操作的完整流程。
从我的参与视角看,我主要关注“发布之后如何管理”这一使用闭环,并参与了需求评审、页面实现、测试用例设计、README 编写、fork + PR 协作演示和最终验收。核心数据层及首页、发布页、详情页主要由陈薇薇同学完成。
二、结对分工
本次作业由陈薇薇(102402109)和我(李青容,102402111)共同完成。
| 成员 | 负责内容 |
|---|---|
| 陈薇薇 102402109 | js/data.js 数据层;首页 index.html + js/app.js;发布页 publish.html + js/publish.js;详情页 detail.html + js/detail.js;数据层单元测试 |
| 李青容 102402111 | “我的发布”页面 my.html + js/my.js;需求分析与功能评审;测试用例和验收场景设计;README 编写;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 分析:
- 学习成本高于预估。 这是我第一次系统接触 Mocha、Chai 和 JSDOM,环境配置、测试写法和报错排查都占用了额外时间。
- 联调时间容易低估。 页面之间通过
localStorage传递数据,判断“是数据没写入、页面没刷新,还是缓存里仍是旧数据”需要反复验证。 - 验收不是简单的点一遍页面。 搜索无结果、非法 id、空值格式化、状态更新失败等情况,都需要单独设计测试用例。
- 下次改进。 在开发前先明确数据字段和函数接口,并把测试用例提前列出来,可以减少联调阶段的返工。
四、解题思路与设计实现
4.1 代码实现思路
项目采用原生 HTML、CSS 和 JavaScript,整体分为三层。
1. 数据层:js/data.js
数据层统一封装对 localStorage 的读写,避免每个页面重复处理数据。主要接口包括:
getAllItems():读取全部信息,第一次访问时写入 6 条种子数据;addItem(item):新增信息并生成 id;getItemById(id):按 id 查找;updateItemStatus(id, status):更新“已找到”或“已归还”状态;deleteItem(id):删除信息;filterItems({ keyword, type }):按关键词和类型筛选;formatTime(ts):把时间戳转换为易读文本。
2. 页面层:HTML
index.html:首页,包含搜索、分类筛选和卡片列表;publish.html:发布信息表单;detail.html:物品详情、联系方式、状态操作和删除操作;my.html:我的发布列表。
3. 逻辑层:js/*.js
每个页面使用独立的 JavaScript 文件调用数据层接口,负责渲染页面和绑定事件。所有页面共享同一个数据源,因此不需要额外处理前后端同步问题。
4.2 关键流程
用户打开首页
│
├─ 首页
│ ├─ 输入关键词或选择类型
│ ├─ 查看卡片列表
│ └─ 点击卡片进入详情
│
├─ 详情页
│ ├─ 查看完整描述和联系方式
│ ├─ 复制联系方式
│ ├─ 标记“已找到 / 已归还”
│ └─ 删除信息
│
├─ 发布页
│ ├─ 填写物品信息
│ ├─ 进行表单校验
│ └─ 写入 localStorage
│
└─ 我的发布
├─ 展示本人发布的全部信息
├─ 查看状态标记
└─ 点击进入详情继续管理
这一流程的重点是保证“发布”不是终点,而是后续浏览、联系和状态管理的起点。
4.3 有价值的代码片段:我的发布页面
我负责的 js/my.js 需要完成三个任务:读取所有信息、在没有数据时显示引导提示、把数据渲染成可点击的卡片。
下面是该页面中的核心逻辑:
const myCards = document.getElementById("myCards");
function escapeHtml(str) {
if (!str) return "";
return String(str)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
function renderMyCards() {
const items = getAllItems();
if (items.length === 0) {
myCards.innerHTML = `
<div class="empty-tip">
😶 你还没有发布过信息,去
<a href="publish.html">发布一条</a>
吧
</div>`;
return;
}
myCards.innerHTML = items.map(item => `
<div class="card" data-id="${item.id}">
<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>
${item.status !== "进行中"
? `<div class="status-badge">${item.status}</div>`
: ""}
</div>
`).join("");
document.querySelectorAll(".card").forEach(card => {
card.addEventListener("click", () => {
window.location.href = `detail.html?id=${card.dataset.id}`;
});
});
}
renderMyCards();
这段代码有三个值得说明的设计:
- 空状态提示。 如果用户还没有发布任何信息,页面不会只留下空白区域,而是引导用户进入发布页。
- 防止 XSS。 用户输入的名称、描述和地点在拼接进 HTML 前会经过
escapeHtml处理,避免把输入内容直接当作 HTML 执行。 - 保持页面闭环。 卡片显示类型、时间、地点和状态;点击卡片后带着
id跳转到详情页,方便继续更新状态或删除信息。
五、附加特点设计与展示
5.1 设计意义
“我的发布”是本次项目中我比较关注的附加功能。
传统的失物招领页面通常只强调“发布”和“浏览”,但信息发布者还需要一个可以集中管理自己内容的入口。否则信息发布后,用户只能重新搜索或不断翻列表,既不方便,也无法快速确认状态是否更新成功。
因此,“我的发布”页面的意义是:
- 把信息发布后的管理操作集中起来;
- 让用户知道自己曾经发布过哪些内容;
- 通过状态徽标快速区分“进行中”和“已找到 / 已归还”;
- 用统一的卡片样式保持和首页一致的交互体验。
5.2 实现思路
该页面复用了数据层提供的 getAllItems() 和 formatTime(),没有重新维护一份“我的数据”,避免首页和“我的发布”出现数据不一致。
具体实现包括:
- 页面加载后调用
getAllItems()获取数据; - 使用
map()动态生成卡片; - 使用
escapeHtml()处理用户输入; - 对非“进行中”的信息显示状态徽标;
- 点击卡片后跳转到
detail.html?id=...; - 无数据时显示空状态提示。
5.3 关键代码片段:输入安全处理
用户输入不能直接拼接进页面,因此我在卡片渲染前增加了转义函数:
function escapeHtml(str) {
if (!str) return "";
return String(str)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
虽然本项目主要运行在本地环境中,但把用户输入直接写入 innerHTML 属于不安全的做法。先转义再渲染,可以避免特殊字符破坏页面结构。
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 我的发布逻辑
├── tests/
│ └── data.test.js 数据层单元测试
├── package.json npm 项目配置
└── README.md 项目说明
6.2 如何运行网页
- 克隆或下载主仓库源码;
- 使用 VS Code 打开项目目录;
- 安装 Live Server 插件,或在终端运行
python -m http.server; - 通过
http://127.0.0.1:5500/index.html或本地 HTTP 地址访问页面; - 建议使用 Chrome 浏览器进行测试。
不建议直接双击 index.html 使用 file:// 协议打开,因为浏览器对本地文件的 localStorage 有安全限制,可能导致首页数据无法正常渲染。
6.3 数据存储说明
所有数据保存在浏览器 localStorage 的 lostFoundItems 字段中。第一次打开时会自动写入 6 条示例数据。
如果需要清空数据,可以按 F12 打开开发者工具,在 Application → Local Storage 中删除 lostFoundItems,然后刷新页面。
6.4 运行单元测试
环境要求:Node.js 18 及以上版本。
# 安装依赖
npm install
# 运行测试
npm test
预期结果为 12 passing。
七、单元测试
7.1 测试工具介绍
本项目使用 Mocha + Chai + JSDOM:
- Mocha:组织并运行测试用例;
- Chai:提供
expect(...)断言; - JSDOM:在 Node.js 中模拟浏览器的
window和localStorage环境。
学习过程中最主要的困难是:Node.js 默认没有浏览器的 localStorage。因此测试文件先创建一个 JSDOM 环境,再把其中的 window 和 localStorage 暴露给数据层,最后才能加载 js/data.js。
简易安装步骤如下:
npm init -y
npm install --save-dev mocha chai jsdom
并在 package.json 中配置:
{
"scripts": {
"test": "mocha tests/**/*.test.js"
}
}
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("getItemById 对不存在的 id 应返回 null", () => {
expect(data.getItemById(99999)).to.be.null;
});
it("updateItemStatus 对不存在的 id 应返回 false", () => {
expect(data.updateItemStatus(88888, "已找到")).to.be.false;
});
it("filterItems 关键词不存在时应返回空数组", () => {
expect(data.filterItems({ keyword: "不存在xyz" }))
.to.be.an("array").that.is.empty;
});
it("formatTime 对空值应返回空字符串", () => {
expect(data.formatTime(null)).to.equal("");
expect(data.formatTime(undefined)).to.equal("");
});
});
7.3 测试数据构造思路
测试用例按照“正常情况、边界情况、异常情况”三类设计:
| 类型 | 测试场景 | 预期结果 |
|---|---|---|
| 正常 | 首次读取数据 | 返回 6 条种子数据 |
| 正常 | 新增一条信息 | 数据变为 7 条,并且新对象有 id |
| 正常 | 按有效 id 查询 | 返回对应信息 |
| 正常 | 更新有效 id 的状态 | 返回 true,状态变为目标值 |
| 正常 | 按“校园卡”搜索 | 至少返回 1 条结果 |
| 正常 | 按“寻物”筛选 | 所有结果的 type 都为“寻物” |
| 边界 | 查询不存在的 id | 返回 null |
| 边界 | 更新不存在的 id | 返回 false |
| 边界 | 搜索不存在的关键词 | 返回空数组 |
| 边界 | 删除指定 id | 数据量减少,原 id 查询为 null |
| 异常 | formatTime(null) |
返回空字符串 |
| 异常 | formatTime(undefined) |
返回空字符串 |
面向未来测试人员,我们主要考虑了以下问题:
- 每个用例保持独立。
beforeEach中清空localStorage,避免上一个用例的数据污染下一个用例。 - 不依赖执行顺序。 每个用例自行准备前置数据,单独运行也能通过。
- 断言具体值。 不只判断“是否为真”,还会检查 id、数量、状态和筛选结果。
- 覆盖异常输入。 空值、非法 id、搜索无结果等情况都被纳入测试。
测试运行结果:

八、GitHub 签入记录
项目采用“每完成一个相对完整的功能就提交一次”的方式,提交信息尽量使用 feat、docs、test、chore 等前缀,方便回溯。
主仓库的主要提交记录如下:
| 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 图片上传 |
| docs:添加结对队友贡献说明,用于演示 PR 协作 | |
| 需要说明的是,主仓库的核心实现和“我的发布”页面提交主要由陈薇薇账号 Vvvicky005 完成。GitHub 的 commit author 表示实际执行提交的账号,并不代表该模块只有该账号参与。我的可核验提交是 README 贡献说明 6646b67,并通过 PR #1 合并。除此之外,我参与的需求评审、测试设计和页面验收反馈,有一部分由主仓库维护者统一修改,因此没有形成以我账号署名的 commit。 |
fork + PR 协作过程
本次结对编程采用 fork + PR 流程:
- 陈薇薇创建团队主仓库
Vvvicky005/102402109-102402111; - 我 fork 主仓库,得到
opqrst121100/102402109-102402111; - 我在 README 中加入结对队友贡献说明,并提交
6646b67; - 我向主仓库发起 PR #1;
- 陈薇薇审核后合并 PR,主仓库产生合并提交
e411eb5。
相关链接:
PR 与合并截图:


通过这次协作,我实际走了一遍“fork → 修改 → 提交 → 发起 PR → 审核合并”的流程,也理解了 PR 在代码审查和团队协作中的作用。
九、遇到的代码模块异常或结对困难及解决方法
问题 1:file:// 协议下 localStorage 无法使用
问题描述:
最初直接双击 index.html 打开页面时,浏览器控制台报出类似错误:
Unsafe attempt to load URL ... 'file:' URLs are treated as unique security origins.
结果 localStorage 无法使用,首页卡片也没有渲染出来。
尝试过程:
- 更换 Edge 和 Chrome 浏览器,问题仍然存在;
- 尝试手动执行
localStorage.clear(),仍然无法解决; - 检查后发现不是浏览器兼容性问题,而是
file://协议本身的限制。
解决方法:
使用 VS Code 的 Live Server 插件,或者通过 python -m http.server 启动本地 HTTP 服务,再访问 http://127.0.0.1:...。
收获:
Web 项目应尽量通过 HTTP 协议运行。直接打开本地文件虽然简单,但与真实浏览器环境不一致,也会引入安全限制。
问题 2:data.js 增加字段后出现语法错误
问题描述:
在种子数据中加入 image 字段后,页面变成空白,控制台提示:
Uncaught SyntaxError: Unexpected identifier 'image'
尝试过程:
- 检查引号和括号是否配对;
- 检查字段名是否拼写正确;
- 最后发现前一条记录的
createdAt后漏写了逗号,导致后面的image被解析为语法错误。
解决方法:
补齐对象字段之间的逗号,并重新加载页面。
收获:
JavaScript 对象字段较多时,任何一个逗号遗漏都可能导致整个脚本无法执行。提交前应先用浏览器控制台或测试确认脚本能够正常加载。
问题 3:浏览器缓存导致图片不显示
问题描述:
为数据增加图片字段后,即使源码已经修改,首页仍然显示旧内容,刷新页面也没有变化。
尝试过程:
- 检查图片路径,路径本身没有问题;
- 检查
data.js是否保存,确认文件已经更新; - 最后发现
localStorage中保存的是修改前的数据,旧记录没有image字段。
解决方法:
在开发者工具中清除 lostFoundItems,让页面重新写入新的种子数据,再进行强制刷新。
收获:
使用 localStorage 时,修改数据结构不一定能直接影响已经保存的旧数据。开发阶段需要养成“清理缓存 + 刷新 + 重新验证”的习惯。
结对困难:两人异地协作如何避免冲突
问题描述:
两人不能始终在同一个终端或同一台电脑前工作。如果都直接修改主分支,很容易产生覆盖或合并冲突。
尝试过程:
- 按页面和模块划分责任范围,减少同时修改同一文件;
- 使用清晰的 commit message,让对方知道修改内容;
- 通过 fork + PR 演示完整协作流程;
- 由主仓库负责人审核并合并 PR。
解决方法:
最终采用 fork + PR 方式完成协作。我修改 README 后提交 6646b67,发起 PR #1,陈薇薇审核并合并,主仓库保持可用状态。
收获:
PR 不只是“提交代码的按钮”,它还是代码审查、记录修改意图和降低协作风险的机制。下一次合作中,如果涉及接口或数据字段变更,应该先在沟通中确认约定,再让对方开始依赖该接口。
十、评价队友
值得学习的地方:
陈薇薇在本次项目中承担了大量核心实现,尤其是数据层设计、页面渲染和单元测试。她有几个方面值得我学习:
- 实现能力较强。 能把需求快速拆成数据层、页面层和逻辑层,并通过统一的
data.js避免页面之间各自维护数据。 - 提交粒度合理。 每个主要功能都有相对独立的 commit,提交信息能够说明修改范围,方便我理解和验收。
- 愿意解释关键逻辑。 在联调阶段,她能够说明某个函数为什么这样设计,以及数据如何从表单写入 localStorage,再由首页读取。
可以改进的地方:
如果下一次继续合作,建议在修改数据字段或函数接口前,提前同步接口约定。这样可以让负责验收和文档的一方更早准备测试用例,减少后期检查旧数据、重新联调的时间。
整体评价:
陈薇薇是一位可靠、执行力强的搭档。她在本次项目中完成了主要业务代码,我在需求评审、my.html + my.js、测试用例、README 和 PR 演示方面提供补充。双方在分工、沟通和验收上没有出现明显推诿,项目也按时形成了可运行的版本。期待下次合作时,我能在核心编码上承担更多任务。

浙公网安备 33010602011771号