2026秋软件工程第二次结对作业:校园失物招领程序实现
校园失物招领 Web 系统 —— 第二次结对编程博客
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering |
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering/homework/16744 |
| 作业这个作业的目标 | 基于第一次结对作业的原型,实现"发布信息—浏览或搜索—查看详情—联系发布者—更新状态"的完整流程 |
| 结对成员 | 072408101(陈柏睿)、072308130(陈柯男) |
| 我的博客 | https://www.cnblogs.com/baiyi060404 |
| 结对同学博客 | https://www.cnblogs.com/PramanixKBlue |
| GitHub 仓库 | https://github.com/baiyi060404/072408101-072308130 |
一、具体分工
这次我们先通过聊天同步进度,确认从手机端改做网页端后,一起讨论导航栏放在顶部、寻物和招领分两列展示、物品找回后从首页移除并放入搜索、状态修改入口放在哪里等问题。
- 072408101(陈柏睿):负责项目仓库与代码基线,先搭好数据层和动态渲染,让
index.html能直接运行;实现发布、搜索、我的发布、收藏、编辑等数据链路;最后负责处理 pull request 合并冲突。 - 072308130(陈柯男):负责网页端界面重设计与交互优化,参考同学反馈把主界面调整为“左图右信息”;完成搜索页接入真实数据、我的页与编辑资料页匹配、保存后返回、本地用户 ID 识别、删除模拟数据,并提交 pull request。
两人共同完成的部分:功能取舍与状态流转规则、数据模型与 localStorage key 设计、问题定位与交叉测试。
二、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 25 |
| Estimate | 估计这个任务需要多少时间 | 20 | 20 |
| Development | 开发 | 770 | 905 |
| Analysis | 需求分析(包括学习新技术) | 90 | 110 |
| Design Spec | 生成设计文档 | 60 | 70 |
| Design Review | 设计复审 | 30 | 25 |
| Coding Standard | 代码规范 | 20 | 20 |
| Design | 具体设计 | 90 | 100 |
| Coding | 具体编码 | 300 | 360 |
| Code Review | 代码复审 | 60 | 70 |
| Test | 测试(自我测试,修改代码,提交修改) | 120 | 150 |
| Reporting | 报告 | 85 | 95 |
| Test Report | 测试报告 | 40 | 45 |
| Size Measurement | 计算工作量 | 15 | 15 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 30 | 35 |
| 合计 | 905 | 1045 |
三、解题思路描述与设计实现说明
1. 代码实现思路
这次的核心不是“把界面画出来”,而是把原型里的静态数据换成真正的数据层,并让多个页面共享同一份数据。我们选择了 Web 方案,做成单文件入口 + hash 路由的 SPA:
index.html只负责页面结构和视图容器,不写业务数据。js/data.js是数据层,统一封装数据模型、localStorage读写、种子数据,以及发布、查询、筛选、搜索、收藏、状态更新等函数,通过window.LostFoundStore暴露给 UI。js/app.js负责路由解析和动态渲染,任何页面都不再维护自己的列表副本,而是统一从LostFoundStore读取,保证“发布 → 首页/搜索/我的发布”数据一致。
关键决策是无登录情况下如何识别“我发布的记录”:我们没有做登录系统,而是在本地生成并持久化一个用户 ID,发布时写进记录的 ownerId,之后用 ownerId === getCurrentUserId() 过滤“我的寻物 / 我的招领”。这样既不引入后端,又能满足状态管理需求。
2. 数据流图
3. 关键代码片段与解释
下面是数据层的发布与读取。createItem 负责补全 id、ownerId、secret 等字段,addItem 写入 localStorage 后立刻重建 APP_DATA,因此发布成功返回首页时无需刷新页面就能看到新记录。
function createItem(input) {
input = input || {};
var type = TYPE_VALUES.indexOf(input.type) !== -1 ? input.type : TYPES.LOST;
var category =
CATEGORIES.indexOf(input.category) !== -1 ? input.category : "其他";
var status =
STATUS_VALUES.indexOf(input.status) !== -1
? input.status
: STATUSES.ACTIVE;
return {
id: input.id || generateId(),
ownerId: input.ownerId || getCurrentUserId(),
type: type,
name: String(input.name || "").trim(),
category: category,
location: String(input.location || "").trim(),
time: String(input.time || "").trim(),
description: String(input.description || "").trim(),
contact: String(input.contact || "").trim(),
image: input.image || "",
status: status,
secret: input.secret ? String(input.secret) : generateSecret(),
createdAt: input.createdAt || new Date().toISOString(),
};
}
function addItem(input) {
seedIfNeeded();
var items = readItems();
var item = createItem(input);
items.unshift(item);
writeItems(items);
buildAppData();
return item;
}
搜索页的数据读取、过滤与排序。排序按 createdAt 倒序,保证最新发布的内容排在最前,同时时间筛选复用同一条数据链。
function renderSearchResults(showMessage) {
var view = getView("search");
if (!view) return 0;
var store = window.LostFoundStore;
if (!store || typeof store.getItems !== "function") return 0;
var filters = getSearchFilters();
var items = store.getItems().filter(function (item) {
return searchMatches(item, filters);
});
items.sort(function (a, b) {
var timeA = a.createdAt || "";
var timeB = b.createdAt || "";
if (timeA === timeB) return 0;
return timeA > timeB ? -1 : 1;
});
// 渲染结果列表、结果计数与空状态
}
“我的寻物 / 我的招领”按 ownerId 过滤,只展示当前设备上自己发布的信息:
function renderMyLostList() {
var view = getView("myLost");
var store = window.LostFoundStore;
if (!view || !store) return;
var allItems = store.getItems();
var currentUserId = store.getCurrentUserId();
var items = allItems.filter(function (item) {
return item.type === "寻物" && item.ownerId === currentUserId;
});
// 再按“全部 / 寻找中 / 已找到”筛选并渲染
}
四、附加特点设计与展示
1. 设计的创意与意义
- 本机用户识别:不引入登录,也能稳定区分“自己发布的记录”,让状态管理和编辑只对本人开放。
- 收藏持久化:详情页收藏后刷新不丢失,收藏页按寻物/招领筛选,并显示真实数量。
- 搜索时间维度的筛选与排序:支持“今天 / 近 3 天 / 近 7 天 / 近 30 天”,并按发布时间倒序展示。
- 编辑页状态管理:编辑信息的同时可直接调整物品状态,减少“先编辑再单独改状态”的重复操作。
- 图片自适应裁剪:上传的图片按比例
object-fit: cover填满固定容器,避免拉伸变形。 - 响应式界面:手机框架与桌面端布局共存,不同分辨率下都能清晰显示。
- 评论交流:详情页支持按物品留言、自动带出昵称、300 字限制,以及评论作者或物品发布者的删除权限。
这些设计的意义是让“发布 → 浏览/搜索 → 详情 → 联系 → 更新状态”整条流程真正闭环,而不是只停留在可点击的原型。
2. 实现思路
- 收藏:
lost_found_favorites单独存收藏的 id 数组,提供addFavorite / removeFavorite / getFavorites / isFavorite,收藏页只存 id,渲染时再通过getItemById取完整记录。 - 我的发布:用
campus_lost_found_user_id记录本机用户 ID,发布时写入ownerId。 - 时间筛选:
searchTimeMatch根据当前日期计算时间窗口,time字段参与筛选,createdAt负责排序。 - 编辑:编辑页复用发布页表单视觉,提交时调用
updateItem,只允许修改ownerId匹配的记录。 - 评论:用
campus_lost_found_comments_v1按物品 id 隔离存储,昵称自动读取个人信息,限制 300 字,作者或物品发布者可删除。
3. 重要代码片段
收藏数据层:
function addFavorite(id) {
var favoriteId = String(id || "");
if (!favoriteId) return getFavorites();
var ids = readFavoriteIds();
if (ids.indexOf(favoriteId) === -1) {
ids.push(favoriteId);
writeFavoriteIds(ids);
}
return getFavorites();
}
function isFavorite(id) {
return readFavoriteIds().indexOf(String(id || "")) !== -1;
}
详情页收藏按钮状态由真实数据驱动:
window.toggleFavorite = function () {
var view = getView("detail");
var store = window.LostFoundStore;
if (!view || !store || !currentDetailId) return;
if (store.isFavorite(currentDetailId)) {
store.removeFavorite(currentDetailId);
} else {
store.addFavorite(currentDetailId);
}
var button = $("#favoriteBtn", view);
var icon = $("#favoriteIcon", view);
var text = $("#favoriteText", view);
updateFavoriteButton(button, icon, text, store.isFavorite(currentDetailId));
};
发表评论会把内容按当前物品保存,并记录评论作者的用户 ID:
allComments[currentDetailId].push({
id: "comment_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8),
userId: currentUserId,
nickname: nickname.slice(0, 20),
content: content,
time: getCommentTimeText(new Date()),
});
saveAllDetailComments(allComments);
renderDetailComments(currentDetailId);
4. 实现成果展示
首页集中展示“进行中”的寻物和招领信息,顶部提供搜索入口:

发布页可选择“我要寻物 / 我要招领”,填写物品类型、名称、地点、时间、描述和联系方式:

搜索页支持关键词搜索,并按物品类型和时间范围筛选:

检索结果按发布时间倒序展示,最新发布的信息排在最前:

详情页展示图片、描述、时间、地点和联系方式,并提供收藏、联系和评论功能:

编辑页可修改已发布信息,并通过“物品状态”下拉栏调整状态:

“我的寻物 / 我的招领”只展示自己发布的信息,支持筛选、标记状态和编辑:

“我的收藏”按寻物 / 招领筛选,并可取消收藏:

五、目录说明和使用说明
目录结构
项目根目录
├─ index.html # 实际运行入口(单文件 SPA,hash 路由切换视图)
├─ css/
│ └─ style.css # 全局样式:布局、卡片、弹窗、移动端与桌面端适配
├─ js/
│ ├─ data.js # 数据层:模型、localStorage、种子数据、增删改查、收藏
│ └─ app.js # UI 与路由:视图切换、动态渲染、表单与事件
└─ images/
├─ campus-card.jpg / keys.jpg / water-bottle.jpg
├─ umbrella.jpg / earphones.jpg / books.jpg / backpack.jpg
└─ home_show_top.png
使用说明
- 下载或克隆整个仓库,保持目录结构不变。
- 用谷歌浏览器双击
index.html打开,无需服务器或构建工具。 - 可测试以下流程:
- 发布:底部“发布”选择寻物/招领,填写表单并提交。
- 搜索:输入关键词,或按物品类型、发布时间筛选,结果按时间倒序展示;左上角可返回首页。
- 详情:查看图片、描述、时间、地点和联系方式,可收藏、联系发布者,也可在评论区留言。
- 我的发布:在“我的寻物 / 我的招领”中筛选、标记状态或进入编辑页。
- 编辑:可修改物品信息,并通过“物品状态”下拉栏调整状态。
- 收藏:在“我的收藏”中筛选、取消收藏。
数据保存在浏览器 localStorage,主要 key 为 lost_found_items、lost_found_favorites、campus_lost_found_user_id、campus_lost_found_comments_v1。
六、单元测试
1. 测试工具与学习过程
我们使用 Node.js 内置的 node:test 模块,不依赖任何构建工具,一条命令即可运行:
node --test test/
学习方式:先阅读 Node 官方文档的 node:test 示例,再结合邹欣老师《关于单元测试和回归测试》中的观点,把“可重复、自动化、失败能定位到函数”作为测试目标。因为 data.js 挂载在全局 window 上,测试时用一个轻量 vm 沙箱模拟 localStorage 即可。
我们的简易教程:新建 test/data.test.js,先写一个 createStore() 用 vm 沙箱加载 js/data.js 并返回 LostFoundStore,再用 test("用例名", () => {...}) 编写断言,最后在项目根目录执行 node --test test/ 即可一次性运行全部用例。
2. 部分单元测试代码
下面三个用例分别覆盖 addItem、updateStatus,以及收藏相关的 addFavorite / removeFavorite / isFavorite 函数。
const test = require("node:test");
const assert = require("node:assert");
const fs = require("node:fs");
const vm = require("node:vm");
function createStore() {
const storage = {};
const sandbox = {
console, JSON, Date, Math,
localStorage: {
getItem: (k) => storage[k] || null,
setItem: (k, v) => { storage[k] = String(v); },
removeItem: (k) => { delete storage[k]; },
},
};
sandbox.window = sandbox;
vm.createContext(sandbox);
vm.runInContext(fs.readFileSync("js/data.js", "utf8"), sandbox);
return sandbox.LostFoundStore;
}
test("addItem 会生成 id 和 ownerId", () => {
const store = createStore();
const item = store.addItem({
type: "寻物", name: "测试物品", category: "其他",
location: "图书馆", time: "2026-10-10 10:00", contact: "138****0000",
});
assert.ok(item.id);
assert.ok(item.ownerId);
assert.equal(item.type, "寻物");
});
test("updateStatus 校验管理码", () => {
const store = createStore();
const item = store.addItem({
type: "招领", name: "钥匙", category: "钥匙",
location: "食堂", time: "2026-10-10 12:00", contact: "138****0000",
});
assert.equal(store.updateStatus(item.id, "000000", "已归还"), null);
assert.equal(store.updateStatus(item.id, item.secret, "已归还").status, "已归还");
});
test("收藏去重与取消收藏", () => {
const store = createStore();
store.addFavorite("item1");
store.addFavorite("item1");
assert.deepEqual(store.getFavorites(), ["item1"]);
store.removeFavorite("item1");
assert.equal(store.isFavorite("item1"), false);
});
3. 测试数据构造思路
我们按“正常路径 + 边界 + 非法输入”来构造用例,并设想测试人员可能的“刁难”:
- 正常新增:完整字段能正常入库。
- 空关键词:搜索返回全部。
- 无结果:不存在的物品返回空数组。
- 错误管理码:
updateStatus返回null,不修改状态。 - 正确管理码:状态正确更新。
- 重复收藏:id 不重复写入。
- 取消收藏:删除后
isFavorite为 false。 - 空表单:必填字段缺失时发布失败。
- 非法类别/状态:自动回退到“其他 / 进行中”。
- 边界时间:近 3 天、近 7 天、近 30 天的边界值。
这些用例覆盖了数据层的主要分支,也覆盖了测试人员最容易构造的异常场景。
七、GitHub 代码签入记录
我们为每次改动单独提交,并让队友通过 pull request 合并:

八、遇到的异常或困难及解决方法
-
问题:
file://下使用 ES 模块会被 CORS 拦截。
尝试:最初想用import/export拆分数据层。
解决:改回普通<script src>+ 全局对象window.LostFoundStore。
收获:面向运行环境的架构决策要前置。 -
问题:发布页无论选寻物还是招领,首页都显示成寻物。
尝试:检查表单 class 与类型判断逻辑。
解决:found-mode加在内部#publishContent上,而不是#view-publish上,修正读取对象。
收获:DOM 层级和状态来源要单一、明确。 -
问题:详情页“···”里的编辑按钮点击无反应。
尝试:检查内联 onclick 的变量作用域。
解决:内联 HTML 无法访问 JS 闭包内的currentDetailId,改为暴露goToEditCurrentItem()。
收获:内联事件应只调用全局函数,数据在函数内部解析。 -
问题:示例数据时间写死后,过几天筛选结果就不符合“近 3 天 / 近 7 天”。
尝试:改成固定日期仍会过期。
解决:种子时间按“相对今天”动态生成,并对已存在的旧示例记录做一次迁移,同时保留用户自建数据。
收获:演示数据要考虑时间维度。
九、用户体验反馈
在这次结对编程的过程中,我们也考虑了上一次设计的需求分析缺少实际用户同学的体验,为此,我们在设计的过程中、完成了大部分功能后,都向身边的同学进行了访谈和咨询了实际使用体验,并针对这些反馈进行了对应的改进,以下是部分截图:

这是我们在设计过程中,向身边同学咨询他们希望在首页看到失物如何展示,在结合了我们的讨论过后,我们写入对应算法更新了失物排列展示的逻辑。

这是我们在项目大体完工后,将文件打包给身边同学进行实际测试,并根据他提供的建议改进了首页的样式,突出区分“招领”和“寻物”两列物品的不同。
十、评价队友
这次结对编程里,我们几乎每天都会同步进度。最开始决定从手机端改成网页端,队友就主动承担了网页版布局的重新设计,并且一直在思考“导航放顶部还是底部”“寻物和招领用两列还是别的形式”这些问题,还会去问身边同学的真实找东西习惯,把外部反馈带回来一起讨论。
开发过程中我们形成了比较明确的分工:我先把数据层和动态渲染搭起来,让项目可以先跑通;队友 fork 仓库后在另一端把界面和交互逐步补齐。他先后完成了搜索页改用真实数据、我的页与编辑资料页的信息匹配、保存后返回我的页,以及用本地用户 ID 识别当前设备发布的寻物和招领,并删掉了“我的招领 / 我的寻物”里原来的模拟数据。中途还主动提交了 pull request,遇到冲突时也会把问题说清楚,最后我们一起把冲突解决掉。
值得学习的地方:
队友的主动性很强,从网页端布局重设计、征求同学意见,到 fork 仓库、提交 pull request,很多推进都不是等我安排,而是他自己往前推。他的问题意识也很好,能发现“详情页返回会直接跳到搜索”“我的招领 / 我的寻物没法修改已发布信息”“个人资料显示和编辑是两套”这些直接关系到功能可用性的细节问题;同时沟通很透明,每改一版都会说明改了什么,冲突了也会讲清楚原因,方便双方一起定位和解决。
需要改进的地方:
协作还可以更仔细点,有次fork 的时间早于主仓最后一次提交,导致后面出现 pull request 冲突、需要覆盖文件再提交;后面我们可以约定“改之前先拉最新代码、按文件分工”,减少合并冲突和重复劳动。
整体来看,队友是一个愿意主动承担、能发现问题、也愿意沟通的合作者,这次能顺利跑通完整流程,他贡献很大。
十一、总结
这次结对编程把第一次的原型真正接上了数据层,完成了“发布 → 浏览/搜索 → 详情 → 联系 → 更新状态”的闭环。最深的体会是:好看的界面只是外壳,数据一致性和状态流转才是功能可用的关键;而把数据层拆出来、统一通过 LostFoundStore 访问,让后续加收藏、编辑、时间筛选都变得简单。

浙公网安备 33010602011771号