软件工程第四次作业
《校园失物招领》结对编程项目博客
作者:马天鸿(102402131)结对队友:李韩远(102402128)
课程:软件工程(2026 秋)第二次结对作业
1. 项目与链接
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601 软件工程与软件工程实践 |
| 这个作业要求在哪里 | 2026 秋软件工程结对作业(第二次) |
| 这个作业的目标 | 将校园失物招领原型实现为可运行的 Web 应用,完成核心业务流程,并通过 GitHub 协作、PSP 和单元测试实践结对开发、版本管理与软件测试 |
| 我的博客 / 本作业链接 | 102402131 |
| 结对队友的博客 / 本作业链接 | 102402128 |
| GitHub 仓库 | 102402128-102402130 |
2. 具体分工
| 成员 | 负责内容 |
|---|---|
| 李韩远 | 数据层设计与实现(data.js:CRUD、搜索、筛选、localStorage 持久化);首页列表与搜索筛选交互;单元测试编写与测试报告 |
| 马天鸿 | 页面结构搭建(index/detail/publish/my 四个 HTML 页面);CSS 样式与 UI 还原;详情页状态流转与 "我的发布" 管理;README 与博客整理 |
3. PSP 表格
| PSP 阶段 | 预估耗时(小时) | 实际耗时(小时) |
|---|---|---|
| 计划(需求分析、原型对照) | 2 | 1 |
| 开发・编码 | 6 | 7 |
| 开发・自测(走查与修 bug) | 2 | 2 |
| 文档(README、博客) | 1 | 1.5 |
| 总结与汇报 | 1 | 1 |
| 合计 | 12 | 12.5 |
4. 解题思路描述与设计实现说明
4.1 代码实现思路(文字描述)
需求分析:校园失物招领的核心场景是 "丢东西的人" 和 "捡到东西的人" 互相找不到对方。围绕这个痛点,应用必须覆盖四条业务闭环:发布(寻物 / 招领)→ 浏览筛选 → 搜索定位 → 联系闭环(复制联系方式 / 标记已解决)。作业明确要求不做复杂后台、实名认证、即时聊天和地图定位,因此我们把精力集中在信息的发布、检索、状态维护三个环节上。
技术选型:采用多页 HTML + 原生 CSS + 原生 JavaScript,数据通过浏览器 localStorage 持久化,不引入任何框架、CDN 或构建工具。理由有三:
-
助教拿到项目后双击 index.html 就能跑,不需要 Node 环境、不需要
npm install、不需要启动服务器,降低评测门槛; -
数据用
localStorage存储,刷新页面不丢失,天然满足 "无后端" 约束; -
原生实现迫使我们把数据操作和视图渲染分层 —— 数据层在
data.js,视图层在app.js—— 这也为后面单元测试的可测性打下了基础。
目录架构:与参考原型的单文件方案不同,我们选择把工程拆成四个 HTML 页面 + 独立 css/ 和 js/ 目录:
campus-lost-found/
├── index.html # 首页:列表 + 搜索 + Tab + 类别筛选
├── detail.html # 详情页:物品信息 + 联系方式 + 状态更新
├── publish.html # 发布页:表单
├── my.html # 我的发布:管理自己发布的信息
├── css/style.css # 全部样式
├── js/data.js # 数据层(localStorage CRUD)
└── js/app.js # 视图层(各页面渲染与交互)
数据模型:一条信息是一个对象,字段如下:
{
id: "item_1791459505642_4650", // 唯一标识(时间戳 + 随机数)
type: "found", // lost 寻物 / found 招领
title: "校园卡(尾号3847)", // 物品名称
category: "证件", // 物品分类(6 类)
location: "图书馆二楼自习室", // 遗失/拾取地点
description: "在图书馆二楼...", // 详细描述
contact: "QQ 123456789", // 联系方式
author: "热心同学", // 发布人
date: "2026-09-27", // 发布日期
status: "active" // active 进行中 / resolved 已解决
}
视图架构:四个页面通过 URL 参数(detail.html?id=xxx)传递物品 id,app.js 根据 body 标签上的 data-page 属性判断当前页面并调用对应的初始化函数。数据层所有函数都挂在 window.LostFound 命名空间下,视图层只负责取数据、拼 HTML、绑定事件,不直接读写 localStorage。
核心流程:发布时做必填校验 → 生成记录对象 → push 写入 localStorage → 跳转详情页;搜索与筛选都基于同一份 getAll() 返回数组做链式纯函数过滤(先按类型 filter、再按类别 filter、最后按关键词 search);详情页提供一键复制联系方式和状态更新按钮,形成业务闭环。
4.2 关键实现的流程图 / 数据流图
系统数据流(DFD):展示用户与发布、搜索筛选、详情、我的发布四个处理环节,以及本地数据(localStorage)之间的数据流向。

核心业务流程图:以 "发布 → 校验 → 入库展示 → 详情 → 状态闭环" 为主线,展示必填校验失败回填、非本人发布者仅浏览等分支。

4.3 我认为重要且有价值的代码片段
片段一:数据层读取与容错(data.js)
function readStore() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
var arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch (e) {
return [];
}
}
解释
:localStorage 是 "不可信输入"—— 可能被用户清空、被写坏、JSON 格式不对。因此读取时用
try/catch包裹,并加一层
Array.isArray校验,任何异常都安全回退到空数组而不是白屏崩溃。
片段二:组合筛选(链式纯函数,data.js)
function filterByType(type, list) {
var src = list || getAll();
if (!type || type === 'all') return src;
return src.filter(function (it) { return it.type === type; });
}
function search(keyword, list) {
var src = list || getAll();
if (!keyword || !keyword.trim()) return src;
var kw = keyword.trim().toLowerCase();
return src.filter(function (it) {
return (
(it.title && it.title.toLowerCase().indexOf(kw) !== -1) ||
(it.description && it.description.toLowerCase().indexOf(kw) !== -1) ||
(it.location && it.location.toLowerCase().indexOf(kw) !== -1)
);
});
}
解释
:筛选逻辑是一组纯函数 —— 输入列表和条件,输出新列表,不修改原数据。首页渲染时把
filterByType、
filterByCategory、
search串成一条链调用。这种写法天然可测:测试时构造一个固定数组,传入参数,断言输出即可,不需要操作 DOM。
片段三:HTML 转义防 XSS(app.js)
function escapeHtml(s) {
if (!s) return '';
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
});
}
解释
:用户发布的标题、描述、联系方式最终都要拼接到 innerHTML 里渲染。如果不做转义,有人输入
<script>alert('xss')</script>就会被执行。所有从数据层取出的字符串在拼 HTML 前统一过一遍
escapeHtml(),把特殊字符转成实体,这是一道安全底线。
片段四:一键复制(剪贴板 API + 降级方案,app.js)
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
toast('已复制:' + text);
}).catch(function () {
fallbackCopy(text);
});
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); toast('已复制:' + text); }
catch (e) { toast('复制失败,请手动复制'); }
ta.remove();
}
解释
:
navigator.clipboard只在安全上下文(HTTPS 或
)下可用,我们的项目是
file://协议双击打开,这个 API 大概率拿不到。所以先做能力检测,不可用时降级到
execCommand('copy')+ 隐藏 textarea 的老方案,保证助教在本地双击打开也能复制联系方式。
片段五:发布校验与入库(data.js)
function add(data) {
if (!data.title || !data.title.trim()) {
throw new Error('物品名称不能为空');
}
var item = {
id: genId(),
type: data.type === 'lost' ? 'lost' : 'found',
title: data.title.trim(),
category: data.category || '其他',
location: (data.location || '').trim(),
description: (data.description || '').trim(),
contact: (data.contact || '').trim(),
author: (data.author || '').trim() || '匿名同学',
date: todayStr(),
status: 'active'
};
var list = readStore();
list.push(item);
writeStore(list);
markMine(item.id);
return item;
}
解释
:新增时先校验标题非空,空则抛异常由表单层 catch 后弹 Toast 提示。日期和状态自动填充默认值,发布人留空时显示 "匿名同学"。同时调用
markMine把 id 记到
myIds列表里,这样 "我的发布" 页才能区分哪些信息是自己发的。
5. 附加特点设计与展示
5.1 创意独到之处与意义
针对校园场景的真实痛点做了以下附加设计:
| # | 附加特点 | 解决的真实痛点 |
|---|---|---|
| 1 | 按物品类别筛选(证件 / 电子产品 / 钥匙 / 书籍 / 生活用品等) | 失物种类多,捡到校园卡的人不想翻到耳机信息,快速定位同类别 |
| 2 | 一键复制联系方式 | 联系方式手抄易错,复制后直接切微信 / QQ 粘贴,降低沟通成本 |
| 3 | 已解决信息灰色置灰标识 | 减少对已领走物品的无效询问,列表一眼区分 "还在找" 和 "已解决" |
| 4 | 我的发布管理页 | 发布者能集中看到自己发的信息,一键标记解决或删除 |
| 5 | 空状态友好提示 | 搜索无结果、没有发布记录时显示引导文案,而不是一片空白 |
| 6 | XSS 转义防护 | 用户输入的特殊字符不破坏页面结构,防止注入 |
5.2 实现思路
-
类别筛选:首页顶部横向排列 category chips,点击后更新
state.category,在渲染前链式调用filterByCategory(),选中态用绿色高亮。 -
一键复制:详情页联系方式旁放 "一键复制" 按钮,剪贴板 API 优先、
execCommand降级,成功后弹 Toast。 -
我的发布:data.js 用单独的 localStorage key 维护
myIds数组,发布时自动记录;getMine()从全量数据里筛出我发的,操作按钮(标记解决 / 重新打开 / 删除)直接调数据层接口后刷新页面。 -
空状态:渲染前先判断筛选结果长度,为 0 时插入空状态 DOM。
5.3 重要代码片段
我的发布列表渲染与操作(app.js):
function initMy() {
var items = LF.getMine();
if (items.length === 0) {
listEl.innerHTML = emptyHtml('你还没有发布过信息');
return;
}
listEl.innerHTML = items.map(function (it) {
var solveBtn = it.status === 'active'
? '<button class="mini-btn solve" data-act="solve" data-id="' + it.id + '">标记解决</button>'
: '<button class="mini-btn" data-act="reopen" data-id="' + it.id + '">重新打开</button>';
var delBtn = '<button class="mini-btn danger" data-act="del" data-id="' + it.id + '">删除</button>';
return cardHtml(it).replace('</a>',
'<div class="item-right">' + solveBtn + delBtn + '</div></a>');
}).join('');
}
解释
:我的发布页复用了首页的
cardHtml()卡片模板,通过
replace在卡片尾部插入操作按钮。事件委托用
data-act和
data-id标记,点击后调用对应数据层函数并刷新。
5.4 实现成果展示
首页(搜索栏、Tab、类别筛选、信息列表):

详情页(物品描述、联系方式、一键复制按钮):

发布页(类型选择、表单):

我的发布(管理操作按钮):

6. 目录说明和使用说明
6.1 目录组织
campus-lost-found/
├── index.html # 首页:物品列表 + 搜索 + Tab筛选 + 类别筛选
├── detail.html # 详情页:物品详细信息 + 联系方式 + 状态更新
├── publish.html # 发布页:填写寻物/招领信息表单
├── my.html # 我的发布:管理自己发布的信息
├── tests.html # 单元测试页面(浏览器打开即自动运行)
├── css/
│ └── style.css # 全部样式(移动端优先,桌面居中)
├── js/
│ ├── data.js # 数据层:localStorage CRUD + 搜索 + 筛选
│ └── app.js # 视图层:各页面渲染与交互
└── README.md # 项目说明
设计原则:页面结构、样式、数据逻辑三层分离;数据层不依赖 DOM,视图层不直接操作 localStorage。
6.2 测试人员如何运行
-
直接打开:用 Google Chrome 浏览器打开
index.html,无需安装环境、无需联网、无需启动服务器。 -
示例数据:首页自带 5 条示例数据(3 条招领、2 条寻物),可直接浏览、搜索、筛选。
-
发布与修改:点击右下角绿色 "+" 按钮发布信息;数据保存在浏览器 localStorage 中,刷新不丢失。
-
恢复初始数据:在浏览器控制台执行
localStorage.clear()后刷新页面,即可恢复示例数据。 -
运行单元测试:用 Chrome 打开
tests.html,页面自动执行 12 个测试用例并显示通过 / 失败结果。
7. 单元测试
7.1 测试工具与学习简易教程
我们没有引入 Mocha 或 Jest 等外部测试框架,而是手写了一个轻量测试运行器(tests.html)。原因是作业要求 "用 Chrome 打开 HTML 就能跑",引入 Node.js 框架会增加助教的运行门槛。
学习路径:
-
先明确测什么—— 数据层
data.js暴露的每个函数:getAll、getById、add、updateStatus、remove、search、filterByType、filterByCategory、getMine; -
每个测试用例结构固定:
test(用例名, function() { ...断言... }),断言失败时throw new Error(描述); -
关键技巧:每个用例前调用
LF._reset()清空 localStorage,保证用例之间完全独立,不互相污染; -
跑完后统计通过 / 失败数,渲染到页面顶部。
一个最小示例:
test('用例3:add() 成功新增一条寻物启事', function () {
var item = LF.add({ type: 'lost', title: '测试钱包', contact: 'QQ111' });
assert(item.id, '应生成id');
assert(item.type === 'lost', '类型应为lost');
assert(item.status === 'active', '新发布状态应为active');
});
7.2 项目单元测试代码与说明
共 12 个测试用例,全部通过:
| # | 测试内容 | 覆盖函数 |
|---|---|---|
| 1 | 初始数据为空 | getAll |
| 2 | seedIfEmpty 注入 5 条示例数据 | seedIfEmpty |
| 3 | add 成功新增一条寻物启事 | add |
| 4 | add 空标题抛异常 | add(异常分支) |
| 5 | getById 按 ID 查到正确物品 | getById |
| 6 | search 按关键词匹配标题 / 地点 | search |
| 7 | filterByType 筛选寻物 / 招领 | filterByType |
| 8 | updateStatus 标记为已解决 | updateStatus |
| 9 | updateStatus 非法状态值抛异常 | updateStatus(异常分支) |
| 10 | remove 删除物品后列表减少 | remove |
| 11 | getMine 只返回当前用户发布的 | getMine / isMine |
| 12 | filterByCategory 按类别筛选 | filterByCategory |

7.3 构造测试数据的思路
-
正常数据:每条 CRUD 操作都有对应用例,验证返回值字段正确、localStorage 状态一致;
-
边界数据:空关键词搜索应返回全部、不存在的 ID 应返回 null、空标题应抛异常;
-
刁难数据(想象测试人员会怎么折腾):
-
输入空标题、纯空格标题,验证必填校验不通过;
-
传入非法状态值(如
"bad_status"),验证状态更新抛异常; -
查询不存在的 ID,验证返回 null 而不是崩溃;
-
删除一条不存在的记录,验证不报错(filter 自然过滤)。
8. GitHub 代码签入记录
本地 Git 提交记录(commit message 遵循 feat: 前缀 + 中文功能描述):

9. 遇到的代码模块异常或结对困难及解决方法
问题一:localStorage 在 file:// 协议下读取异常导致白屏
-
问题描述:最初写
readStore()时没有 try/catch,某次调试时 localStorage 里残留了一段损坏的 JSON,页面一打开就抛JSON.parse异常,整个首页白屏。 -
做过哪些尝试:先在控制台手动
localStorage.clear()恢复,但这只是治标。改为给readStore()加try/catch+Array.isArray双重校验,任何异常都安全回退空数组。 -
是否解决:已解决。
-
有何收获:localStorage 是 "不可信输入",读取必须容错;对持久化数据的访问永远不能裸调
JSON.parse。
问题二:详情页 "标记解决" 按钮只在发布者自己可见
-
问题描述:最初所有人进详情页都能看到 "标记为已解决" 按钮,这不合理 —— 捡到东西的人不应该有权把别人发的寻物启事标成已找到。
-
做过哪些尝试:在
data.js里加了一个单独的myIds数组(localStorage keycampus_lost_found_mine),发布时自动记录;详情页渲染前调LF.isMine(item.id)判断,不是自己发的就不渲染底部操作栏。 -
是否解决:已解决。
-
有何收获:权限控制不一定要靠登录态,在纯前端场景下用一个 "我发布过哪些 id" 的白名单就能解决基础的所有权判断。
问题三:手机端和桌面端宽度不一致导致布局错乱
-
问题描述:用 Chrome 打开时在桌面宽屏下内容会撑满整个屏幕,卡片拉得很长,体验不好。
-
做过哪些尝试:在 CSS 里给
body加max-width: 480px; margin: 0 auto;,桌面浏览器下内容居中显示为手机宽度,移动端则全屏。 -
是否解决:已解决。
-
有何收获:移动端优先的 Web 应用在桌面端居中限制最大宽度,比做两套响应式断点更简单。
10. 评价你的队友
优点:执行力强,按时高质量完成发布页、详情页、我的发布页及全局样式的开发;文档能力出色,README和PSP表格规范完整;Code Review细致,帮助发现多处潜在bug。
协作表现:沟通主动及时,需求对齐效率高,遇到分歧能以数据和时间成本为依据理性决策。
改进建议:可在项目早期更早介入数据结构设计讨论,促进前后端接口定义的 smoother 衔接。
浙公网安备 33010602011771号