软件工程第四次作业:第二次结对作业
| 这个作业属于哪个课程 | H202601 软件工程与软件工程实践 |
|---|---|
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次之程序实现) |
| 这个作业的目标 | 将校园失物招领原型实现为可运行的 Web 应用,完成核心业务流程,并通过 GitHub 协作、PSP 和单元测试实践结对开发、版本管理与软件测试。 |
| 设计原型稿与在线演示 | Figma 设计原型稿 Figma 在线演示 |
| GitHub 仓库 | 112401237-102401533 |
| 结对成员 | 112401237 吴诗莹、102401533 丁晓炜 |
| 队友博客 | 吴诗莹博客 |
一、项目简介与结对分工
1.1 项目背景与目标
校园卡、钥匙、耳机和雨伞等物品丢失或被捡到后,学生通常通过班级群、宿舍群或朋友圈发布信息。这些信息容易分散、被新消息覆盖,失主和拾物者也可能不在同一个群聊中。
本项目设计校园失物招领系统,将寻物和招领信息集中展示,帮助用户完成信息发布、搜索、详情查看、联系方式获取、收藏、评论线索和状态修改等操作。项目同时关注手机端使用体验,通过统一的卡片布局、底部导航和状态颜色,让用户能够快速完成核心流程。
1.2 结对分工
🔵分工如下
| 阶段 | 吴诗莹 | 丁晓炜 | 合作方式 |
|---|---|---|---|
| 第 1 阶段:需求分析 | 分析首页浏览、搜索、筛选和详情页需求 | 分析发布、个人中心和状态管理需求 | 共同阅读作业要求,确定用户角色、核心页面和功能范围 |
| 第 2 阶段:数据与接口设计 | 确定首页、搜索和详情页需要读取的字段及公共接口 | 设计寻物、招领数据结构和localStorage存储方式 | 共同确定字段名称、图片数组、用户身份、更新权限和模块接口 |
| 第 3 阶段:核心页面开发 | 完成首页、搜索结果页、关键词搜索、类型/类别/地点/状态筛选和通用详情页 | 完成发布页、表单校验、图片上传、发布成功页、“我的”页面和“我的发布”页面 | 分别在功能分支开发,通过约定的公共数据结构连接页面 |
| 第 4 阶段:功能完善 | 完善详情页图片轮播、发布者信息、联系方式复制和响应式布局 | 完善个人资料、收藏、归还记录、浏览记录、状态修改、编辑和删除功能 | 共同完成页面跳转、公共样式、工具函数和模块联调 |
| 第 5 阶段:测试与修复 | 测试搜索、筛选、详情和联系方式复制流程 | 测试存储、发布、图片、个人中心和状态修改流程 | 共同使用 Google Chrome 测试,处理接口异常和跨页面数据同步问题 |
| 第 6 阶段:协作提交 | 提交浏览、搜索和详情模块代码,参与合并审查 | 提交发布、个人中心和状态管理模块代码,参与合并审查 | 共同完成 GitHub commit、Pull Request、冲突处理、README、PSP、博客和最终验收 |
1.3 PSP 表格
| PSP2.1 阶段 | 中文说明 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 35 | 50 |
| Estimate | 估计任务所需时间 | 35 | 45 |
| Development | 开发阶段汇总(Analysis 至 Test) | 940 | 1375 |
| Analysis | 需求分析,包括学习图片上传、Base64 和本地存储等技术 | 80 | 110 |
| Design Spec | 生成发布页、个人中心和状态管理设计说明 | 55 | 75 |
| Design Review | 与队友复核字段、接口和页面跳转设计 | 40 | 60 |
| Coding Standard | 制定 HTML、CSS、JavaScript 和 Git 提交规范 | 25 | 35 |
| Design | 设计发布表单、三格图片槽位、个人中心卡片和操作按钮 | 80 | 115 |
| Coding | 编写发布页、图片上传、个人中心、编辑、状态和删除功能 | 420 | 620 |
| Code Review | 检查代码结构、文件范围和公共接口调用 | 60 | 90 |
| Test | 自测发布、图片持久化、编辑、状态修改、删除和刷新流程 | 180 | 270 |
| Reporting | 编写个人负责模块的报告内容 | 90 | 130 |
| Test Report | 整理发布、图片和个人中心测试结果 | 50 | 75 |
| Size Measurement | 统计负责文件、功能点和提交规模 | 20 | 30 |
| Postmortem & Process Improvement Plan | 总结接口未统一、PR 冲突和提交范围问题,提出改进措施 | 40 | 60 |
| 合计 | 1210 | 1765 |
2.1 代码实现思路
本项目采用原生 HTML、CSS 和 JavaScript 实现校园失物招领系统。系统没有使用复杂的后台服务,而是使用浏览器 localStorage 保存数据,从而保证用户发布、编辑和修改状态后,刷新页面数据仍然存在。
项目将数据处理和页面显示分离。js/data.js 作为公共数据模块,负责统一处理物品数据的读取、保存、格式化、校验、发布、编辑和状态更新。首页、搜索页、详情页、发布页和个人中心不直接维护数据,而是通过 window.CampusData 调用公共接口。
每条物品信息都使用统一的数据结构,包括物品名称、信息类型、物品分类、描述、地点、具体位置、联系方式、图片、发布时间和当前状态等字段。发布时间使用 createdAt 保存绝对时间,页面显示时再根据当前日期格式化为“10月6日”等文字,避免日期被永久写死。
系统的主要处理流程如下:
- 页面加载时,CampusData.readItems() 从 localStorage 读取数据;
- 如果浏览器第一次运行,则加载项目预设的默认帖子;
- 首页根据信息类型和物品分类筛选帖子,并按照发布时间从新到旧排序;
- 搜索页根据关键词、时间、类型、状态和地点进行多条件筛选;
- 用户点击卡片进入详情页,查看描述、地点和联系方式;
- 发布者可以在“我的发布”中编辑内容或修改信息状态;
- 所有新增、编辑和状态修改操作都会重新写入 localStorage;
- 其他页面重新读取公共数据后,自动显示最新结果。
因此,项目核心是建立一个统一的数据流,让所有页面使用同一份数据,保证发布、浏览、搜索、详情查看和状态维护之间能够连贯工作。
2.2 关键实现的流程图与数据流图
🔵系统核心数据处理与流转图

🔵核心功能流程图
拾物者视角
|
失主视角
|
2.3 重要代码片段
代码片段一:统一数据入口与首次运行初始化
function readItems() {
if (memoryItems) return memoryItems.map(normalizeItem);
try {
const saved = window.localStorage.getItem(ITEMS_STORAGE_KEY);
if (saved) {
const storedItems = JSON.parse(saved).map(normalizeItem);
const storedById = new Map(storedItems.map(item => [item.id, item]));
const retainedItems = storedItems.filter(item =>
!LEGACY_SEED_ITEM_IDS.has(item.id) && !SEED_ITEM_IDS.has(item.id)
);
const currentDefaultMyPostsVersion = readDefaultMyPostsVersion();
const needsDefaultMyItems =
currentDefaultMyPostsVersion < DEFAULT_MY_POSTS_VERSION;
const missingDefaultMyItems = needsDefaultMyItems
? defaultMyItems
.filter(item =>
DEFAULT_MY_ITEM_VERSIONS[item.id] > currentDefaultMyPostsVersion &&
!storedById.has(item.id)
)
.map(normalizeItem)
: [];
const refreshedSeeds = seedItems.map(seed => {
const storedSeed = storedById.get(seed.id);
return normalizeItem({
...seed,
status: storedSeed ? storedSeed.status : seed.status,
views: storedSeed ? storedSeed.views : seed.views
});
});
const migratedItems = retainedItems
.concat(missingDefaultMyItems, refreshedSeeds)
.map(normalizeItem);
if (needsDefaultMyItems) markDefaultMyPostsInitialized();
writeItems(migratedItems);
initializeDefaultActivity(migratedItems);
return migratedItems;
}
} catch (error) {
console.warn("无法读取本地数据,将使用示例数据。", error);
}
const initialItems = defaultMyItems.concat(seedItems).map(normalizeItem);
writeItems(initialItems);
return initialItems;
}
readItems() 是所有页面共享的数据入口。它先读取内存缓存,再读取 localStorage;如果是第一次运行或本地数据无法解析,则加载默认帖子并写回本地存储。读取已有数据时,程序保留用户自己的发布内容,只补充缺少的默认帖子,并保留示例帖子的状态和浏览量。这保证了首页、搜索页、详情页和个人中心始终使用同一份数据。
代码片段二:信息状态从“进行中”到“已找到/已归还”
function updateStatus(id, status) {
const itemId = String(id || "");
const nextStatus = String(status || "");
if (!itemId || !["active", "completed"].includes(nextStatus)) {
return null;
}
const items = readItems();
const item = items.find(entry => entry.id === itemId);
if (!item) return null;
const updatedItems = items.map(entry => entry.id === itemId
? { ...entry, status: nextStatus }
: entry
);
writeItems(updatedItems);
return updatedItems.find(entry => entry.id === itemId) || null;
}
这段代码对应作业要求中的“更新信息状态”。用户在“我的发布”中选择新的状态后,页面调用 updateStatus(),公共数据模块先检查物品编号和状态值,再更新对应记录并写入 localStorage。详情页和首页重新读取数据后,会根据 type 和 status 显示“寻找中/已找到”或“待认领/已归还”,从而形成发布后的完整生命周期。
代码片段三:发布信息的完整处理与数据持久化
function writeItems(items) {
memoryItems = items;
try {
window.localStorage.setItem(
"campus-lost-found-items",
JSON.stringify(items)
);
} catch (error) {
// localStorage 不可用时保留内存数据
}
}
function createItem(input) {
const values = input || {};
const name = String(values.name || "").trim();
const contact = String(values.contact || "").trim();
if (!name || !isValidPhone(contact)) {
return null;
}
const item = {
id: `item-${Date.now()}`,
type: values.type === "found" ? "found" : "lost",
name,
category: normalizeCategory(values.category),
description: String(values.description || "").trim(),
location: normalizeLocation(values.location).name,
locationDetail: String(values.locationDetail || "").trim(),
contact,
images: normalizeImages(values.images),
status: "active",
publisherId: "current-user",
views: 0,
createdAt: values.createdAt || new Date().toISOString()
};
writeItems([item, ...readItems()]);
return item;
}
这段代码实现了发布信息的完整处理流程。系统首先读取用户在表单中填写的物品名称、信息类型、分类、描述、地点、联系方式和图片等内容,然后对必填字段和手机号格式进行校验,并对分类、地点和图片数据进行统一标准化处理。校验通过后,系统会自动生成唯一的物品编号,将信息状态设置为 active,并记录真实的发布时间。新发布的帖子会被添加到现有数据的最前面,同时更新内存数据和 localStorage。因此,发布成功后,首页、搜索结果页、详情页和个人中心都可以读取并显示同一条最新信息。
三、特色设计与项目展示
特点一:搜索多维筛选与首页分类浏览
1️⃣设计创意与意义
校园失物招领场景中,无论是想找回失物的失主,还是希望核对失主的拾金者,其寻找习惯主要分为两类:无明确目标的“粗粒度浏览”与带着特定线索的“精确查找”。项目据此设计了两条互补路径:
- 面向粗粒度浏览的首页分类:通过横向滚动的“全部 / 寻物 / 招领”类型切换,以及“证件、数码、钥匙”等常见类目,让用户无需输入关键词即可快速下钻。无论是失主想随便看看,还是拾金者想主动核对“有没有人在找我捡到的物品”,都能轻松降低查找门槛。
- 面向精确查找的搜索多维筛选:在关键词搜索的基础上,提供时间(如一周内)、状态(进行中/已完成)、地点区域(教学区/餐厅等)及排序方式的组合筛选,满足高阶精准定位的需求。
该设计通过“轻量分类 + 深度筛选”的双轨并行路径,它让用户在不同阶段按照需求接触信息,大幅提升了信息流转效率与用户体验。
2️⃣实现思路
- 首页分类浏览的实现
首页使用currentFilter保存当前的信息类型,使用currentCategory保存当前的物品分类。用户点击按钮时更新这两个变量,然后调用render()重新筛选和显示卡片。所有物品都通过CampusData.readItems()读取,筛选完成后统一使用CampusCard.itemCard()生成信息卡片。这样首页的数据显示和其他页面保持一致。 - 搜索多维筛选的实现
搜索页先读取关键词,再计算每条信息与关键词的匹配程度。物品名称完全匹配时优先级最高,名称部分匹配和分类匹配的优先级依次降低。在得到关键词结果后,程序继续根据时间、类型、状态和地点进行过滤,最后按照用户选择的方式排序并显示结果。筛选条件都保存在filters对象中,点击“重置”时恢复默认值。
3️⃣关键代码片段及解释
- 首页分类与类型的组合筛选
function render() {
const items = CampusData.readItems()
.map((item, index) => ({ item, index, day: CampusData.getItemDayTimestamp(item) }))
.filter(entry => {
const matchesType = currentFilter === "all" || entry.item.type === currentFilter;
const matchesCategory = currentCategory === "all" || entry.item.category === currentCategory;
return matchesType && matchesCategory;
})
.sort((first, second) => {
if (Number.isFinite(first.day) && Number.isFinite(second.day) && first.day !== second.day) return second.day - first.day;
if (Number.isFinite(first.day) && !Number.isFinite(second.day)) return -1;
if (!Number.isFinite(first.day) && Number.isFinite(second.day)) return 1;
return first.index - second.index;
})
.map(entry => entry.item);
grid.innerHTML = items.map(CampusCard.itemCard).join("");
empty.classList.toggle("hidden", items.length > 0);
count.textContent = `${items.length} 条信息`;
}
注:通过独立维护类型与分类两个状态条件,支持“寻物 + 数码”等组合查询;结合时间戳排序与公共卡片渲染函数,保证列表顺序合理、样式统一。
- 搜索结果多维过滤与排序
let items = CampusData.readItems()
.map(item => ({ item, score: getKeywordScore(item, key) }))
.filter(result => result.score > 0)
.filter(result => timeMatches(result.item, filters.time))
.filter(result => typeMatches(result.item, filters.type))
.filter(result => statusMatches(result.item, filters.status))
.filter(result => locationMatches(result.item, filters.location));
if (filters.sort === "newest") {
items.sort((a, b) => getItemTime(b.item) - getItemTime(a.item) || b.score - a.score);
} else if (filters.sort === "views") {
items.sort((a, b) => (b.item.views || 0) - (a.item.views || 0) || b.score - a.score);
} else {
items.sort((a, b) => b.score - a.score || getItemTime(b.item) - getItemTime(a.item));
}
注:采用链式 filter 依次应用时间、类型、状态和地点条件,并将关键词匹配得分 score 作为排序依据或平局参考,确保结果既符合筛选条件,又保持较高的相关度。
4️⃣实现展示
🔵 演示动图如下

搜索多维筛选

首页分类浏览
特点二:“我的页面”一体化管理
1️⃣ 设计创意与意义
“我的页面”并不只是个人资料展示页,而是用户管理失物信息的集中入口。用户可以在这里查看自己的 收藏 → 浏览记录 → 归还记录,也可以对本人发布的信息执行 编辑 → 删除 → 修改状态,同时还能修改个人资料。这些原本分散在不同页面中的操作被集中到一个入口,用户不需要反复寻找功能。发布信息后,后续处理过程也能在这里完成:
发布信息 → 信息传播 → 找到/归还 → 更新状态 → 历史留存
其中,编辑功能是这个闭环中的重要环节。用户发布信息后,如果发现物品名称、特征描述、图片、地点或联系方式填写错误,可以直接进入编辑页面修改,不需要删除原帖后重新发布。修改后的内容会同步到首页、搜索结果、详情页和“我的发布”,从而保证不同页面读取到的数据保持一致。
2️⃣ 实现思路
系统使用统一的数据模块保存物品、收藏和浏览记录,并通过 publisherId 判断发布信息的归属。进入“我的页面”后,程序读取当前用户的发布记录,同时统计收藏、浏览和归还数据,再将结果显示到对应入口中。
本人发布的每张信息卡片都带有“修改状态”“编辑”和“删除”三个操作。点击编辑后,页面根据 URL 中的物品 ID 读取原数据,并自动填充类型、名称、分类、图片、描述、日期、地点和联系方式。编辑页面沿用发布页的布局与地点选择组件,用户不需要重新适应另一套操作方式。
保存前会再次检查日期和手机号。图片可以保留、替换、补充或删除,新增图片通过 FileReader 转为 Data URL 后与其他字段一起交给 CampusData.updateItem()。数据模块完成权限检查后,同时更新内存缓存和 campus-lost-found-items,因此首页、搜索结果、详情页和“我的发布”都能读取到修改后的内容。
删除操作带有二次确认。确认删除后,系统还会清理该物品对应的收藏和浏览记录,避免列表中留下已经失效的数据。
3️⃣ 关键代码片段及解释
第一段代码负责筛选当前用户发布的信息,并按日期从新到旧排列:
function getMyItems(publisherId) {
const ownerId = String(publisherId || "current-user");
return readItems()
.filter(item => item.publisherId === ownerId)
.map((item, index) => ({
item,
index,
day: getItemDayTimestamp(item)
}))
.sort((first, second) => {
if (Number.isFinite(first.day) && Number.isFinite(second.day) && first.day !== second.day) return second.day - first.day;
if (Number.isFinite(first.day) && !Number.isFinite(second.day)) return -1;
if (!Number.isFinite(first.day) && Number.isFinite(second.day)) return 1;
return first.index - second.index;
})
.map(entry => entry.item);
}
publisherId 用来区分信息归属,只有当前用户自己的发布内容会进入“我的发布”。排序时先比较发布时间;日期相同则保留原有顺序,避免列表在刷新后无故跳动。
第二段代码是编辑接口的核心。它先检查用户身份和物品归属,再更新允许修改的字段,最后通过 writeItems() 保存:
function updateItem(id, input, publisherId) {
const itemId = String(id || "");
const ownerId = String(publisherId || "current-user").trim();
if (!itemId || ownerId !== "current-user") return null;
const values = input || {};
const items = readItems();
const current = items.find(item => item.id === itemId);
if (!current || current.publisherId !== "current-user") return null;
const has = key => Object.prototype.hasOwnProperty.call(values, key);
const next = { ...current };
if (has("name")) {
const name = String(values.name || "").trim();
if (name) next.name = name;
}
if (has("description")) {
next.description = String(values.description || "").trim();
}
if (has("images")) {
next.images = normalizeImages(values.images, DEFAULT_MY_ITEM_IDS.has(itemId));
}
const updatedItems = items.map(item => item.id === itemId ? next : item);
writeItems(updatedItems);
return next;
}
接口只接受当前用户的编辑请求,其他用户或示例信息都会被拒绝。writeItems() 同时刷新内存数据和 localStorage,所以保存后进入首页、详情页或“我的发布”都能看到最新内容,物品 ID、发布人和浏览量等字段则保持不变。
4️⃣ 实现展示
🔵 “我的页面”功能如下
两组动图分别展示了“我的页面”的信息管理与记录查看功能:
- 编辑 → 删除 → 修改状态:用户可以修改已发布信息中的文字、图片、地点和联系方式,也可以删除无效信息,或在物品找到、归还后更新信息状态;
- 收藏 → 浏览记录 → 归还记录:用户可以集中查看自己收藏过、浏览过以及已经完成归还的信息,便于后续查找和管理。
其他小设计
除了以上两个特点外,系统还针对实际使用过程进行了多处细节优化,使整体操作更加完整,细节优化如下:
- “我的页面”选择地点时采用二级分类展开,使校园大量地点更加清晰
- 首页下滑浏览了很多帖子后,可以双击“我的”回到顶部,减少页面较长时的返回操作
- 对手机号进行严格的 11 位格式校验以保证数据规范,同时提供联系方式一键复制功能
- 在搜索页提供历史搜索记录保存与热门搜索词推荐,方便用户快速获取高频或近期关注的关键词,减少重复输入成本
- 可以在“我的”页面更改个人信息
四、项目结构与运行说明
4.1 项目目录结构
以下目录根据最新版代码整理。项目将页面、样式、脚本和图片素材分开管理,示例物品图片数量较多,因此不逐一展开:
112401237-102401533/
├── index.html # 首页
├── assets/
│ ├── campus-map.jpg # 校园地图
│ ├── default-profile-avatar.png # 默认头像
│ ├── home-hero.png # 首页顶部图片
│ └── seed-items/ # 示例物品图片
├── css/
│ ├── common.css # 公共布局与底部导航
│ ├── design-system.css # 颜色、字体等设计变量
│ ├── detail.css # 详情页样式
│ ├── home.css # 首页样式
│ ├── profile.css # “我的”及关联页面样式
│ ├── publish.css # 发布、编辑及发布成功页样式
│ └── search.css # 搜索与筛选页样式
├── js/
│ ├── data.js # 公共数据、LocalStorage 与业务接口
│ ├── card.js # 通用物品卡片
│ ├── home.js # 首页分类与列表渲染
│ ├── search.js # 搜索、筛选与排序
│ ├── detail.js # 详情、收藏、复制及浏览记录
│ ├── publish.js # 发布表单、地点与图片处理
│ ├── profile.js # 个人中心、收藏、归还及删除
│ ├── edit-post.js # 本人发布信息编辑
│ ├── status.js # 信息状态修改
│ ├── contact.js # 个人资料编辑与同步
│ └── browse-history.js # 浏览记录
├── pages/
│ ├── search.html # 搜索与筛选
│ ├── detail.html # 信息详情
│ ├── publish.html # 发布信息
│ ├── publish-success.html # 发布成功
│ ├── profile.html # “我的”与“我的发布”
│ ├── edit-post.html # 编辑本人发布
│ ├── status.html # 修改信息状态
│ ├── favorites.html # 我的收藏
│ ├── return-history.html # 归还记录
│ ├── browse-history.html # 浏览记录
│ └── contact.html # 个人信息
├── README.md # 模块与项目说明
└── member-b-module-spec.md # 成员 B 模块接口说明
目录组织说明如下:
index.html和pages/存放页面结构。根目录的index.html是首页入口,pages/存放搜索、详情、发布、编辑、状态、个人中心及记录页面;css/按公共样式和页面模块拆分。common.css、design-system.css负责全站通用样式,其余文件负责详情、首页、搜索、发布和个人中心等页面;js/按数据层、页面脚本和公共卡片组件拆分。data.js是各页面共用的数据入口,物品查询、发布、编辑、删除、状态修改、收藏和浏览记录等操作都通过window.CampusData完成;assets/存放校园地图、首页图片、头像和示例物品图片等静态资源;- 项目使用原生 HTML、CSS 和 JavaScript,没有引入 Vue、React 等第三方前端框架,也不依赖后端数据库。项目说明、需求说明和模块接口说明放在根目录,方便测试人员了解数据约定。
页面不各自维护物品副本,而是共用 js/data.js 的数据接口,避免首页、详情页和“我的页面”出现数据不一致。
“我的发布”已经整合到 profile.html,最新版中不再使用单独的 my-posts.html。发布和编辑页面共用 publish.css 及地点、图片处理逻辑,详情和搜索页面共用卡片与数据接口。
4.2 项目运行说明
项目使用原生 HTML、CSS 和 JavaScript 编写,没有引入 Vue、React 等第三方前端框架,也没有 package.json,因此不需要执行 npm install。项目不需要后端服务、数据库或第三方账号。测试环境建议使用安装了 Python 3 和 Google Chrome 的电脑。若电脑已有其他静态 HTTP 服务器,也可以使用同等功能的工具。
项目提供两种运行方式。方案一不需要启动服务器,也不需要占用端口;方案二使用本地 HTTP 服务器,适合浏览器对 file:// 页面限制较严格的情况。
4.2.1 方案一:直接打开 index.html
- 解压项目压缩包;
- 打开解压后的
112401237-102401533-main文件夹; - 双击根目录中的
index.html; - 浏览器打开首页后即可使用系统。
项目页面之间使用相对路径连接,图片、样式和 JavaScript 文件均使用项目内的相对路径,因此不需要额外安装依赖或配置环境。页面跳转、发布、编辑、收藏、浏览记录和个人资料等功能都可以通过这种方式操作。
4.2.2 方案二:使用本地端口运行
如果某些浏览器对 file:// 页面限制较严格,可以在包含 index.html 的项目根目录打开 PowerShell,使用本地服务器运行:
py -m http.server 4179
然后访问:
http://127.0.0.1:4179/index.html
如果系统无法识别 py,可以改用:
python -m http.server 4179
若选择服务器方式且 4179 端口已被占用,可以换成其他端口,例如:
py -m http.server 4180
此时访问地址也要同步改为:
http://127.0.0.1:4180/index.html
修改代码后,无论采用哪种方式,都可以按 Ctrl + F5 强制刷新。
4.2.3 测试人员操作说明
测试人员可以按以下流程复现主要功能:
- 打开首页,在“全部、寻物、招领”之间切换,并按证件、数码、钥匙、生活用品和书籍等分类浏览;
- 进入搜索页,输入物品名称,并组合测试时间、类型、状态、地点和排序条件;
- 打开任意物品详情,检查图片、具体位置、发布者、联系方式、复制按钮和收藏功能;
- 点击底部“发布”,选择“寻物”或“招领”,填写所有必填项;
- 在最多三个图片槽位中上传图片。已选择的图片可以删除并重新上传;不上传图片时,列表使用默认蓝色正方体图标;
- 选择日期和校内地点,填写具体位置及 11 位手机号后发布,确认系统进入发布成功页;
- 从发布成功页打开详情,检查刚发布的名称、图片、地点、联系方式和发布者信息;
- 进入“我的”,查看发布数量及进行中、已完成的统计,随后测试“修改状态”“编辑”和“删除”;
- 在个人信息页修改用户名、大学、学院、年级和联系方式,保存后检查个人中心及本人发布详情是否同步更新;
- 分别进入“我的收藏”“浏览记录”和“归还记录”,确认相应数据能够正常显示;
- 刷新浏览器并重新进入页面,确认发布、编辑、收藏和个人资料等数据仍然保留。
4.2.4 本地数据与重置说明
项目数据保存在浏览器当前站点的 localStorage 中,主要包括物品信息、个人资料、收藏、浏览记录和搜索历史。浏览器会把不同端口视为不同站点,因此从 4179 改到 4180 后,原端口保存的数据不会自动出现。如需恢复初始状态,可打开 Chrome 开发者工具,进入:
Application → Local Storage → http://127.0.0.1:4179 → Clear
清除后刷新页面,系统会重新加载默认示例数据。用户上传的图片以 Data URL 形式保存在浏览器中,测试时应选择体积较小的 JPG、PNG 或 WebP 图片,避免超过 localStorage 的容量限制。
五、单元测试
根据作业要求,单元测试文件不提交到 GitHub。本节测试在最终版本代码的本地副本中完成,测试对象与仓库中的业务代码一致。博客仅展示测试方法、代表性代码片段、测试数据设计和运行结果截图。
5.1 测试工具与学习过程
本项目使用 Vitest 作为单元测试框架,使用 jsdom 模拟浏览器环境,并通过 @vitest/coverage-v8 统计代码覆盖率。
选择这套工具主要考虑了项目的实际结构。项目由原生 HTML、CSS 和 JavaScript 编写,没有使用 Vue、React 等框架;核心业务集中在 js/data.js,其中又依赖 window 和 localStorage。Vitest 可以直接测试普通 JavaScript,jsdom 则提供测试所需的浏览器对象,不需要为了测试重写项目。
学习时先阅读了 Vitest Getting Started 和 Test Environment,了解 describe、it、expect、测试生命周期和 jsdom 环境;然后查阅 Coverage,加入 V8 覆盖率报告。实际编写时先测试 isValidPhone() 这类无状态函数,再处理 localStorage、模块缓存和日期边界。这样出现失败时比较容易判断是断言错误、测试数据污染,还是业务逻辑本身有问题。
5.1.1 简易教程
环境要求:已安装 Node.js 和 npm。本项目测试时使用 Node.js 24.18.0。为了不影响仓库签入记录,我们先从 GitHub 下载最终版本代码,在仓库外复制出一份本地测试副本,再把测试配置和测试文件放入该副本。
- 进入本地测试副本的根目录,安装测试依赖:
npm install
- 运行全部测试:
npm test
- 修改测试时使用监听模式:
npm run test:watch
- 生成覆盖率报告:
npm run test:coverage
- 浏览器打开本地生成的
coverage/index.html,可以查看每个函数和分支的覆盖情况。node_modules/、coverage/、tests/和测试配置均只保存在本机,不执行git add、git commit或git push。
一个最小的 Vitest 用例如下:
import { describe, expect, it } from "vitest";
describe("手机号校验", () => {
it("接受合法的 11 位手机号", () => {
expect(CampusData.isValidPhone("13800138000")).toBe(true);
});
});
项目的数据模块会把数据缓存在闭包变量中,所以仅调用 localStorage.clear() 还不够。测试辅助函数会先执行 vi.resetModules(),再重新加载 js/data.js:
export async function loadCampusData() {
vi.resetModules();
delete window.CampusData;
delete globalThis.CampusData;
await import("../../js/data.js");
globalThis.CampusData = window.CampusData;
return window.CampusData;
}
这样每个测试都从独立状态开始,不会因为前一个用例创建或删除了物品而产生偶发失败。
5.2 单元测试代码与测试对象
本地测试副本中的测试代码放在 tests/ 目录中,该目录未提交到 GitHub:
tests/
├── helpers/
│ └── load-campus-data.js
├── setup.js
├── data-validation.test.js
├── data-api.test.js
├── item-crud.test.js
├── date-activity.test.js
└── publish-validation.test.js
总共34个用例,各测试文件的职责如下:
| 测试文件 | 测试对象 | 用例数 |
|---|---|---|
data-validation.test.js |
手机号、必填字段、分类和地点规范化、图片过滤、北京时间转换、损坏存储数据降级 | 11 |
data-api.test.js |
公共查询接口、状态文字、图片路径、编辑字段、旧日期兼容、半年筛选和失败分支 | 9 |
item-crud.test.js |
发布信息创建、读取、编辑、状态修改、删除、权限和个人资料同步 | 6 |
date-activity.test.js |
自然日筛选、日期显示、收藏切换、浏览去重和关联数据清理 | 5 |
publish-validation.test.js |
发布表单必填项、未来日期和手机号校验 | 3 |
下面是编辑权限测试的节选:
it("拒绝其他用户修改本人信息,也拒绝修改其他人的信息", () => {
const created = data.createItem(validItem({ id: "item-unit-owner" }));
expect(data.updateItem(
created.id,
{ name: "越权修改" },
"other-user"
)).toBeNull();
expect(data.updateItem(
"seed-id-card-found",
{ name: "修改示例信息" },
"current-user"
)).toBeNull();
});
这个用例测试 updateItem(id, values, publisherId) 的权限边界。第一条断言模拟其他用户修改当前用户的帖子;第二条断言模拟当前用户修改系统示例帖子。两种操作都应返回 null,原数据不能变化。
下面是日期边界测试:
it("按照北京时间自然日判断当天和最近一周", () => {
const now = "2026-10-06T12:00:00+08:00";
const today = { createdAt: "2026-10-06T00:01:00+08:00" };
const weekStart = { createdAt: "2026-09-30T23:59:00+08:00" };
const tooOld = { createdAt: "2026-09-29T23:59:00+08:00" };
expect(data.matchesTimeRange(today, "day", now)).toBe(true);
expect(data.matchesTimeRange(weekStart, "week", now)).toBe(true);
expect(data.matchesTimeRange(tooOld, "week", now)).toBe(false);
});
这里没有用“距离现在不超过 24 小时”代替自然日,而是把时间固定在北京时间,并分别测试当天零点后、最近一周起点和起点前一分钟。
5.2.1 测试结果
在本地测试副本根目录依次执行普通测试和覆盖率测试:
npm test
npm run test:coverage
运行结果和覆盖率统计如下图所示:

图中上半部分是 npm test 的运行结果,下半部分是 npm run test:coverage 生成的 V8 覆盖率统计。黄色的 jsdom 内容是测试环境性能建议,不是测试错误,不影响测试结果。
最终测试结果如下:
| 测试项目 | 数量 | 结果 |
|---|---|---|
| 测试文件 | 5 | 全部通过 |
| 测试用例 | 34 | 全部通过 |
本次共运行 5 个测试文件、34 个测试用例,测试结果全部通过。
核心数据模块 js/data.js 的覆盖率如下:
| 指标 | 覆盖数量 | 覆盖率 |
|---|---|---|
| Statements | 354 / 403 | 87.84% |
| Branches | 255 / 370 | 68.91% |
| Functions | 70 / 77 | 90.90% |
| Lines | 304 / 327 | 92.96% |
覆盖率只统计 js/data.js。发布表单校验虽然也有单元测试,但页面脚本包含文件读取、DOM 事件和跳转等交互逻辑,把它们和数据层混合统计会使结果难以解释。页面完整操作另外通过浏览器手动回归测试验证。
以上测试文件、依赖和覆盖率报告均保存在仓库外的本地测试副本中,没有计入 GitHub 代码签入记录。
5.3 测试数据构造思路与异常情况
5.3.1 测试数据的构造思路
本次测试数据主要采用白盒测试、等价类划分和边界值分析相结合的方法进行构造。首先阅读 js/data.js 和 js/publish.js,找出函数中的判断条件、数据流向和异常处理分支,再针对每条主要执行路径设计测试数据。测试时不仅检查函数的返回值,还检查 localStorage 中的数据和相关状态是否发生了正确变化。
在具体构造过程中,我们先将输入划分为有效和无效等价类,再重点测试临界值。例如,手机号分为合法号码、错误号段、长度不足、长度超出和包含字母;图片数量覆盖 0 张、1 张、3 张和超过 3 张;日期覆盖当天、一周边界前后和未来日期。这样既能减少重复测试,也能覆盖程序最容易出错的位置。
白盒测试主要体现在以下几个方面:
- 语句覆盖:执行创建、读取、编辑、删除、收藏、浏览记录和个人资料同步等主要代码;
- 分支覆盖:同时测试合法与非法手机号、本人和非本人权限、存在与不存在的物品、有效与无效图片、正常与损坏的本地数据;
- 条件覆盖:针对
active、completed,lost、found以及不同日期范围分别构造输入; - 异常路径:主动写入错误 JSON、非法图片地址、未来日期和不存在的 ID,确认程序能够返回失败结果或使用默认数据,而不是直接崩溃。
5.3.2 对各种情况的考虑
为了尽可能覆盖系统在实际使用中可能遇到的情况,我们从正常输入、边界输入、异常输入、重复操作、用户权限和状态一致性六个方面设计测试。
- 正常情况:分别测试寻物和招领两种信息类型、
active和completed两种状态,以及合法的手机号、图片、分类和地点。图片覆盖 PNG、JPEG、WebP 三种允许类型,分类和地点既测试标准输入,也测试开发过程中遗留的数据格式,验证程序升级后仍能正确读取已有的本地数据。 - 边界情况:手机号测试 10 位、11 位、12 位、非法号段和字母混入;图片测试 0 张、1 张、3 张和超过 3 张;日期测试当天 00:01、最近一周起点、起点前一分钟和未来日期。
- 异常情况:向
localStorage写入无法解析的 JSON,使用不存在的物品 ID 调用更新、删除、收藏和浏览接口,并传入javascript:地址、外部图片 URL、空标题、空地点和空描述等非法数据。 - 重复操作:连续记录两次浏览量,验证同一浏览器只增加一次;连续点击两次收藏,验证第二次能够正确取消收藏,避免生成重复记录。
- 权限情况:尝试以其他用户身份修改当前用户的信息,也尝试修改不属于当前用户的帖子,确认权限检查能够拒绝越权操作。
- 状态一致性:删除帖子后不仅检查“我的发布”,还同时检查物品数据、收藏列表和浏览记录;修改个人资料后,既检查本人旧帖子是否同步更新,也检查其他用户的数据是否保持不变。
5.3.3 对测试人员“刁难”的应对
我们假设测试人员不会只按照正常流程操作,还可能故意输入空字符串、错误格式和超出限制的数据,或者进行重复点击、越权修改、访问不存在的信息以及破坏本地存储等操作。因此,测试中主动加入了非法手机号、未来日期、超过三张图片、错误 JSON、非法图片地址、不存在的物品 ID、重复收藏和非本人发布信息等数据。
对于这些“刁难”输入,我们不仅检查程序是否返回失败结果,还检查它是否会产生未处理异常、破坏原有数据或留下不一致的关联记录。例如,本地数据损坏时应自动使用默认数据;越权修改时应返回 null,并保证原信息不发生变化;图片超过上限时应只保留规定数量;删除帖子时应同时清理相关收藏和浏览记录。通过这些测试,即使测试人员故意采用极端输入或非正常操作,系统也能够保持稳定、保护原有数据并给出明确结果。
5.3.4 测试范围说明
本次单元测试主要验证数据校验、数据读写、权限控制、日期处理和状态变化等业务逻辑。覆盖率数据及测试运行结果已在 5.2.1 测试结果 中展示。
响应式布局、按钮位置、图片视觉裁剪和页面跳转等内容属于界面表现和端到端交互,难以仅通过数据层单元测试判断,因此我们另外使用浏览器进行人工回归测试。单元测试与人工测试相结合,可以更完整地验证系统功能。
六、协作过程
6.1 Github的代码签入
🔵一部分commit截图
🔵PR记录截图
6.2 遇到的问题与解决方法
在结对开发过程中,我们遇到的主要困难并不是某一个页面的样式问题,而是两个独立模块在接口和代码合并阶段无法顺利对接。以下两个问题对开发进度影响最大,也最能反映本次结对协作中的不足与改进过程。
6.2.1 模块间公共数据接口未提前统一
问题描述
项目开始时,我们先按照页面划分任务:我的队友主要负责首页、搜索结果、详情页和公共数据模块,我主要负责发布页、个人中心、我的发布和编辑页面。虽然页面分工比较明确,但双方没有在编码前完整约定公共数据结构和接口。
例如,我开发“我的发布”编辑功能时,需要修改已经存在的物品信息,因此编辑页需要调用:
CampusData.updateItem(id, values, publisherId)
但是我的队友负责的公共 js/data.js 中最初没有 updateItem() 接口。同时,图片字段是否统一使用 images、用户身份如何通过 publisherId 判断、个人资料修改后是否同步已有帖子,以及数据最终使用哪个 localStorage 键名,都没有在一开始确定。
这导致部分页面单独运行时没有问题,但合并后出现了以下情况:
- 编辑页能够修改输入框内容,但详情页仍读取旧数据;
- 发布页已经生成
images数组,但公共数据层不能完整保存; - 个人资料已经修改,详情页仍显示旧用户名和联系方式;
- 如果双方分别创建数据文件或存储键,首页、详情页和个人中心将无法共享同一条记录。
做过哪些尝试
发现问题后,我们没有继续在各自页面中直接操作 localStorage,而是重新明确了模块职责:
发布页和编辑页:负责收集、校验并提交数据
公共数据模块:负责创建、更新、删除和持久化
首页、详情页和个人中心:负责重新读取并展示数据
随后共同确认了以下接口和规则:
- 所有物品统一保存到
campus-lost-found-items; - 图片统一使用
images数组,最多保存三张 Base64 图片; - 本人发布的数据统一使用
publisherId: "current-user"; - 编辑功能通过
CampusData.updateItem()完成,不在编辑页单独写入数据; - 只允许修改本人发布的信息,不能修改其他用户的数据;
- 更新物品时保留
id、createdAt、views和发布者身份等不应改变的字段。
我的队友在公共 js/data.js 中补充 CampusData.updateItem(id, values, publisherId),我在编辑页中按照统一参数调用该接口。完成后又测试了文字修改、地点修改、图片替换、权限判断和刷新后的数据持久化。
是否解决
该问题已经解决。本人发布的信息能够通过公共接口进行更新,其他用户的信息不会被误改;首页、详情页、个人中心和编辑页也能够读取同一个存储键中的数据。
有何收获
我们认识到,结对开发不能只划分“谁负责哪个页面”,还必须先约定页面之间如何交换数据。公共字段、接口名称、参数格式、权限规则和存储位置都应该在编码前确定。以后进行类似协作时,应先写一份简短的接口说明,再分别开发页面,这样可以减少重复实现和合并后的功能失效。
6.2.2 共享文件重复修改导致 Pull Request 无法合并
问题描述
在分支开发阶段,我们认为双方负责不同模块,因此各自在自己的功能分支上持续修改。但是个人中心、个人资料同步和公共展示逻辑会同时涉及多个页面,最终双方都修改了部分共享文件。
实际提交 Pull Request 后,GitHub 显示:
This branch has conflicts that must be resolved
冲突文件包括:
css/profile.css
js/profile.js
此外,pages/profile.html 等文件也存在双方都需要调整的情况。虽然 Git 允许两个人修改同一个文件,但当两个分支修改了同一代码区域,GitHub 无法判断应该保留哪一份内容,因此 Merge 按钮会被禁用。
做过哪些尝试
我们先检查 Pull Request 中列出的冲突文件,再通过 git status、git diff 和提交记录确认双方分别修改了哪些内容。处理冲突时没有简单选择“全部保留自己的代码”或“全部采用队友代码”,而是逐段比较:
- 保留我完成的个人中心布局、发布卡片操作和个人资料入口;
- 保留我的队友完成的公共数据读取和需要与详情页同步的逻辑;
- 删除 Git 生成的
<<<<<<<、=======、>>>>>>>冲突标记; - 检查合并后的 HTML、CSS 和 JavaScript 是否仍然完整;
- 重新测试“我的发布—编辑—修改状态—删除—查看详情”的完整流程。
此后,我们在每次提交前增加了以下检查:
git status
git diff
git diff --staged
并且只对本次任务涉及的文件执行 git add。对于公共文件,修改前先说明原因和预计影响;Pull Request 描述中也写明“修改了哪些文件、没有修改哪些文件”,方便另一位成员审查。
是否解决
该问题已经解决。冲突代码被人工确认并合并,Pull Request 恢复为可合并状态。合并后,两名成员共同进行回归测试,重点检查页面跳转、公共数据、图片上传、状态修改、编辑删除和个人资料同步,确保各模块能够使用同一份数据正常运行。
有何收获
我们认识到,“分工不同”并不意味着不会发生冲突。只要两个分支修改同一个文件的相同区域,就可能导致 Pull Request 无法自动合并。后续协作应尽量明确共享文件的维护者,及时同步主分支,减少长时间并行修改;提交也应保持范围清晰、内容单一,从而降低代码审查和冲突处理的难度。
通过这次问题,我们形成了更明确的协作顺序:先统一接口和文件归属,再分别开发;提交前检查暂存范围;创建 Pull Request 后及时审查;合并完成后执行跨页面回归测试。
七、评价我的队友
7.1 值得学习的地方
本次结对开发整体比较顺利。我认为她值得学习的地方是对细节的重视,我的队友在页面设计和功能分析方面给了我很多帮助。她对界面细节比较敏感,在协作过程中会主动检查页面中的小问题,例如返回箭头与文字的对齐、按钮之间的间距、图片是否超出卡片范围、控件颜色是否统一等。很多问题虽然不影响基本功能,但经过她的检查和调整后,页面的完整性和视觉效果都有所改善 b( ̄▽ ̄)d。
她对网页整体风格也有比较清晰的想法。项目最初的首页较为空白,她提出加入校园主题背景和统一的视觉元素,使首页更符合校园失物招领的使用场景。在博客整理方面,她也给了我很多帮助。她不仅会检查内容是否完整,还会主动调整标题层级、表格、图片和代码块的排版。我之前以为博客园只能使用 Markdown 编辑,后来才知道博客园也支持直接嵌入 HTML,也是学到了小巧思,优点多多。
7.2 需要改进的地方
需要改进的地方是,我们在项目早期可以更早确定公共接口、文件分工和分支协作规则,这算是我们的缺点,应该在早期协商好。之前由于部分文件的修改范围没有提前记录清楚,出现过同一文件被多人修改、PR 操作无法直接合并的情况。当然,这次的经验也为后续提供了思路:后续可以在开发开始前先列出文件负责人、接口参数和提交规范,并在每次合并前进行一次简短的变更确认,这样可以减少重复修改和冲突。
八、项目开发总结
本次结对开发完成了校园失物招领 Web 系统,实现了信息发布、图片上传、搜索筛选、详情查看、收藏、浏览记录、状态修改、编辑、删除和个人资料同步等功能。“我的页面”整合了个人发布、收藏、浏览记录和归还记录。用户修改个人资料后,已有发布和后续新发布的信息都会同步更新。
开发过程中,我们遇到过公共数据接口不统一、详情页读取旧数据、编辑图片无法保存以及 GitHub Pull Request 合并冲突等问题。通过统一 CampusData 接口、明确 publisherId 和文件分工、检查分支变更范围,我们解决了这些问题,也加深了对 Git 命令、分支管理和 GitHub 协作流程的理解。
项目使用 Vitest、jsdom 和 V8 Coverage 进行本地单元测试,共包含 5 个测试文件和 34 个测试用例,全部通过。项目不需要后端服务,测试人员可以直接双击 index.html 运行,也可以使用 Python 启动本地服务器。
通过本次作业,我熟悉了静态网页开发、LocalStorage 数据管理、GitHub 协作和单元测试流程,也认识到接口约定、分支管理和测试验证在结对开发中的重要性。这次经历为之后的团队合作打下了基础,也让我更清楚地认识到自己在沟通、代码规范和协作流程方面的不足,并从队友身上学习了页面设计、细节检查和文档排版等优点。
国庆做软工燃尽了:
感谢观看!奖励你一只可爱的猫咪🐱:


浙公网安备 33010602011771号