软件工程个人作业(第四次)
校园失物招领 —— 结对编程(第二次作业 · Web 版)
单人完成版。基于第一次结对作业的需求分析与原型设计,实现「发布—浏览/搜索—查看详情—联系发布者—更新状态」的核心流程。
一、开篇信息
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 2026秋软件工程与软件工程实践 - 福州大学 |
| 这个作业要求在哪里 | 结对编程作业要求 |
| 这个作业的目标 | 完成校园失物招领小程序的 Web 版实现 |
| 学号 | 052401324 |
| GitHub 仓库 | https://github.com/12aa950/-Web- |
分工表
| 成员 | 学号 | 主要工作 |
|---|---|---|
| 刘昭杉 | 052401324 | 需求梳理、原型复现、全栈实现、单元测试、博客撰写 |
二、解题思路描述与设计实现说明(20 分)
2.1 题目核心要求
实现一个校园失物招领的 Web 应用,需完成:
- 发布寻物 / 招领信息;
- 集中浏览校园失物招领内容;
- 通过物品名称等关键词搜索;
- 查看物品详细信息及联系方式;
- 发布者将信息标记为「已找到」或「已归还」。
2.2 技术选型
| 维度 | 选择 | 理由 |
|---|---|---|
| 形态 | PC 端 Web 网页 | 双击 index.html 即可运行,零环境依赖 |
| 前端 | 原生 HTML + CSS + JavaScript | 不引入框架,便于阅读和讲解 |
| 持久化 | localStorage |
满足作业要求,无需后端 |
| 测试 | Jest + Node.js | 行业标准,覆盖核心纯函数逻辑 |
2.3 模块划分
校园失物招领/
├── index.html # 入口(含两个弹窗)
├── css/style.css # 样式
├── js/storage.js # localStorage IO
├── js/data.js # 数据操作(纯函数)
├── js/search.js # 搜索/排序(纯函数)
└── js/app.js # 渲染 + 事件
分层原则:纯逻辑与 DOM 完全分离,data.js 和 search.js 不引用 window/document,因此可以在 Node 环境用 Jest 直接测试。
2.4 数据模型
{
id: 'x1a2b3c', // 唯一 id
name: '校园卡', // 物品名称
desc: '尾号 5201', // 描述
place: '一区教学楼', // 地点
contact: 'wx_01', // 联系方式
type: 'lost' | 'found',
status: 'active' | 'done',
createdAt: '2026-09-30 14:20',
publisher: '张三'
}
status 用于把「已找回/已归还」从列表中沉底,避免新信息被旧信息淹没。
2.5 关键流程
发布 → 持久化
function createItem(input) {
const { name, desc, place, contact, type } = input;
if (!name.trim()) throw new Error('物品名称不能为空');
if (!place.trim()) throw new Error('地点不能为空');
if (!contact.trim()) throw new Error('联系方式不能为空');
if (type !== 'lost' && type !== 'found') throw new Error('type 必须是 lost 或 found');
return { id: uid(), name, desc, place, contact, type,
status: 'active', createdAt: nowStr() };
}
入口处做必填校验,失败立即抛错,由 app.js 的 try/catch 弹窗提示用户。
搜索(关键代码片段)
function search(items, keyword) {
const k = normalize(keyword);
if (!k) return items.slice();
return items.filter(it =>
['name', 'desc', 'place'].some(f => normalize(it[f]).includes(k))
);
}
- 同时在「名称 / 描述 / 地点」三字段做模糊匹配,符合「按物品名称等关键词搜索」的描述;
- 关键词为空时返回全部(避免误输入触发空结果);
- 使用
.toLowerCase()实现大小写无关(虽然中文数据不影响,但语义上更稳)。
状态标记(已找到 / 已归还)
function markDone(items, id) {
return items.map(it => it.id === id ? { ...it, status: 'done' } : it);
}
不可变更新,便于日后做撤销/历史记录;status === 'done' 的条目在排序中自动沉底。
三、代码实现思路(5 分)
关键流程图
发布信息 浏览/搜索 联系发布者 更新状态
│ │ │ │
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 填写表单 │──createItem─▶│localStorage│◀──search/filter─│ 详情页 │──markDone─▶│localStorage│
│ 校验必填 │ │ JSON │ │ 复制联系方式│ │ JSON │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│
▼
排序展示:active 在前
完成的沉底
用户操作流程
进入首页 → 浏览或搜索信息 → 查看物品详情 → 复制联系方式或标记完成
↓
点击卡片触发 openDetail(id)
↓
渲染弹窗 + 绑定「标记完成」/「复制」事件
四、关键代码片段(5 分)
片段 1:发布校验(重要 — 涉及用户输入安全)
function createItem(input) {
const { name, desc, place, contact, type } = input;
if (!name || !name.trim()) throw new Error('物品名称不能为空');
if (!place || !place.trim()) throw new Error('地点不能为空');
if (!contact || !contact.trim()) throw new Error('联系方式不能为空');
if (type !== 'lost' && type !== 'found') throw new Error('type 必须是 lost 或 found');
return {
id: uid(), name: name.trim(), desc: (desc || '').trim(),
place: place.trim(), contact: contact.trim(),
type, status: 'active', createdAt: nowStr(),
};
}
说明:把必填校验集中放在 createItem 这一处,前端弹窗只负责把用户输入拿到完整 UI(提示用户);这样后端(如果未来接入)也能复用同一段校验逻辑。
片段 2:搜索过滤(重要 — 性能与正确性)
function search(items, keyword, opts) {
const k = normalize(keyword);
if (!k) return items.slice();
const fields = (opts && opts.fields) || ['name', 'desc', 'place'];
return items.filter(it =>
fields.some(f => normalize(it[f]).includes(k))
);
}
说明:抽离 fields 参数,将来如果要做「仅搜索名称」可以传入 ['name'],避免重复代码。
片段 3:状态切换(重要 — 不可变更新)
function markDone(items, id) {
return items.map(it => it.id === id ? { ...it, status: 'done' } : it);
}
说明:用展开运算符返回新对象,而不是直接修改原数据,可以避免「标记完成」后 localStorage 写入和 UI 渲染不同步的问题。
片段 4:XSS 防御
function escapeHTML(s) {
return String(s || '').replace(/[&<>"']/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
说明:所有用户输入(物品名称、描述、地点)通过 innerHTML 渲染前都过一遍 escapeHTML,防止 XSS(虽然只是本地存储,但良好的习惯应从原型期养成)。
五、附加特点设计与展示(10 分)
特点 1:一键复制联系方式(创意 + 实现)
意义:用户在详情页看到失物信息时,传统做法需要手动切换到微信/QQ 去查找。本设计点一下「复制联系方式」按钮即可把联系方式放到剪贴板,少一次切换。
实现思路:使用浏览器 Clipboard API。
document.getElementById('d-copy').onclick = () => {
const it = getItems().find(x => x.id === state.selectedId);
navigator.clipboard?.writeText(it.contact).then(
() => alert('联系方式已复制'),
() => alert('复制失败,请手动复制')
);
};
同时做了降级处理:file:// 协议下 Clipboard API 在某些浏览器中不可用,提示用户手动复制。
特点 2:按类型筛选 + 已完成信息沉底
意义:当一个学期累积信息多了以后,「已完成」的信息(已找到 / 已归还)继续霸屏会让新信息难以被看到。本设计:
- 顶栏「全部 / 寻物 / 招领」三个 tab 一键切换;
- 默认排序把
active排前面、done排后面; - 已完成的卡片整体置灰(
.tag.done灰底)。
实现:见 Search.sortForDisplay 和 Data.filterByType。
3. 实现成果展示
首页(默认进入)

搜索页(输入「校园卡」)

详情页(含复制联系方式按钮)

我的发布页(按状态排序)

发布弹窗(含必填校验)

六、目录说明和使用说明(5 分)
目录组织(2.5 分)
校园失物招领/
├── index.html # 入口(包含发布/详情两个 modal)
├── css/style.css # 全局样式(单文件,便于阅读)
├── js/
│ ├── storage.js # localStorage 读写封装
│ ├── data.js # 业务数据操作(create/markDone/filterByType)
│ ├── search.js # 搜索与排序
│ └── app.js # 应用入口(渲染 + 事件)
├── __tests__/
│ └── data.test.js # Jest 单元测试(覆盖 Data 和 Search)
├── imgs/ # 博客用截图
├── package.json # 仅用于 npm test
├── make_screenshots.py # 截图生成脚本(可选)
└── README.md # 本说明
组织原则:
- 逻辑与 DOM 分离:
data.js/search.js是纯函数,可独立测试; - 职责单一:每个 JS 文件一个关注点(IO / 数据 / 搜索 / 渲染);
- 扁平:最多 2 层目录,避免嵌套过深。
测试人员如何运行(2.5 分)
最简单的方式(无需 Node):
- 下载整个项目;
- 用 Chrome 浏览器双击
index.html; - 即可看到首页(首次进入会有 4 条种子数据演示)。
运行单元测试(需 Node ≥ 16):
cd 校园失物招领
npm install
npm test
预期:Tests: 15 passed, 15 total。
本地起服务(推荐,避免 file:// 限制):
cd 校园失物招领
python -m http.server 8080
# 浏览器访问 http://localhost:8080
七、单元测试(10 分)
7.1 选用的测试工具与学习路径(4 分)
选用 Jest(v29)。学习路径:
- 看 Jest 官方文档 30 分钟:了解
describe / test / expect三件套; - 第一次写一个最简单的测试:
expect(1+1).toBe(2),跑通后信心增加; - 针对当前项目:因为业务逻辑都在纯函数里,所以直接
require然后断言返回值即可; - 小坑:Jest 默认用 jsdom 环境测试 DOM,但本项目不需要 DOM,所以
package.json里配置"testEnvironment": "node",跑得更快。
简易教程(自己总结):
// __tests__/demo.test.js
const { add } = require('../js/math'); // 引入被测模块
describe('add 函数', () => { // 一组测试
test('1+1=2', () => { // 单个用例
expect(add(1, 1)).toBe(2); // 断言
});
test('抛错', () => {
expect(() => add(null, 1)).toThrow(); // 断言抛错
});
});
跑:npm test。
7.2 项目单元测试代码与说明(3 分)
测试覆盖了 Data.createItem、Data.markDone、Data.removeDone、Data.filterByType、Search.search、Search.sortForDisplay 六个核心函数,15 个用例全部通过:
describe('Data.createItem', () => {
test('正常输入返回合法条目', () => { ... });
test('缺失必填项抛出明确错误', () => { ... });
test('type 非法时拒绝创建', () => { ... });
});
describe('Data.markDone / removeDone', () => { ... });
describe('Data.filterByType', () => { ... });
describe('Search.search', () => {
test('空关键词返回全部');
test('匹配名称(中文)');
test('匹配描述');
test('匹配地点');
test('不区分大小写');
test('匹配不到返回空数组');
});
describe('Search.sortForDisplay', () => {
test('active 在前;active 内部按时间倒序');
test('不修改原数组顺序');
});
完整代码见 __tests__/data.test.js。
7.3 测试数据构造思路(3 分)
核心思路:把测试用例当成「刁难测试人员」来设计。
| 情况 | 怎么刁难 | 期望 |
|---|---|---|
| 正常输入 | 最小合法数据 | 通过 |
| 边界必填 | 名称/地点/联系方式为空字符串 | 抛对应错误 |
| 类型非法 | type = 'xxx' | 抛错 |
| 关键词为空 | keyword = ''、null | 返回全部 |
| 关键词匹配 | 中英文分别 | 大小写无关 |
| 匹配不到 | 输入「不存在的物品xyz」 | 返回空数组 |
| 排序 | 混合 done 和 active | done 在后、active 按时间倒序 |
| 不可变 | 调用排序后 | 原数组不变 |
对将来测试人员刁难的预判:
- 输入包含
'、<、HTML 标签 → 已用escapeHTML防御(在app.js); - 输入超长字符串 → 暂未限制长度,但搜索按
.includes()仍能工作; - 同一秒创建多条 → 用
Date.now()后缀保证 id 唯一。
九、遇到的代码模块异常或结对困难及解决方法(4 分)
困难 1:file:// 协议下 Clipboard API 不可用
问题描述:Chrome 在 file:// 协议下对 Clipboard API 视为不可信来源,navigator.clipboard.writeText() 会 reject。
做过哪些尝试:起初直接 await navigator.clipboard.writeText(contact),本地双击打开时弹窗不显示复制成功提示。
是否解决:是。改成 ?. 链式调用 + .then(success, fail) 显式处理失败,失败时弹出降级提示「请手动复制」。
有何收获:浏览器权限 API 在不同协议、不同浏览器下行为不一致,做前端工具时必须考虑降级。
困难 2:状态标记后 UI 没更新
问题描述:点击「标记为已完成」后,弹窗关闭了但首页列表没刷新。
做过哪些尝试:起初 setItems(arr) 之后忘了调用 render()。
是否解决:是。在状态变更后强制 render() 重新渲染列表。
有何收获:把「数据变更」和「UI 刷新」分开写很容易遗漏,这种规模的项目可以接受,更大的项目应引入响应式框架。
困难 3:单元测试的运行环境
问题描述:Jest 默认 jsdom 环境会加载浏览器对象,但 data.js 没有引用任何浏览器 API,jsdom 是多余的。
是否解决:是。package.json 中加 "jest": { "testEnvironment": "node" },测试启动更快。
十、评价你的队友(2 分)
说明:本次为单人完成,无队友。
值得自我学习的地方:
- 业务逻辑与 DOM 分离的自觉性,从一开始就坚持
data.js写纯函数,为后续测试铺路; - 在写代码前先用 Python 脚本生成设计稿,避免在 HTML 上反复调整样式浪费时间;
- 提前列 PSP 表格,让实际开发时长与预估差距可控。
需要自我改进的地方:
- 原型设计稿完成后,没有先在浏览器里手动跑一遍原型就开始写代码,导致发布页「添加照片」区域与上一行标签重叠,最后花了 15 分钟调整;
localStorage的try/catch没有覆盖到JSON.parse失败的情形,需要在后续改进。
十一、PSP 表格(5 分)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 25 |
| Estimate | 估计这个任务需要多少时间 | 20 | 15 |
| Development | 开发 | — | — |
| Analysis | 需求分析(包括学习新技术) | 60 | 80 |
| Design Spec | 生成设计文档 | 30 | 25 |
| Design Review | 设计复审 | 15 | 10 |
| Coding Standard | 代码规范(为目前的开发制定合适的规范) | 10 | 8 |
| Design | 具体设计 | 60 | 50 |
| Coding | 具体编码 | 180 | 220 |
| Code Review | 代码复审 | 20 | 15 |
| Test | 测试(自我测试,修改代码,提交修改) | 90 | 110 |
| Reporting | 报告 | — | — |
| Test Report | 测试报告 | 30 | 25 |
| Size Measurement | 计算工作量 | 10 | 10 |
| Postmortet & Process Improvement Plan | 事后总结,并提出过程改进计划 | 30 | 35 |
| 合计 | 615 | 648 |
差异主要在「具体编码」和「测试」两项:实际写代码时多花了 40 分钟调整样式;测试阶段多花了 20 分钟补 Jest 配置(testEnvironment: node)。
十二、测试结果与代码组织小结
- 功能完成度:发布、浏览、搜索、详情、我的发布、状态标记、一键复制联系方式,全部完成;
- 代码组织:纯逻辑与 DOM 分离,4 个 JS 文件职责清晰;
- 测试覆盖:15 个 Jest 用例 100% 通过;
- 未做项:实名认证、即时聊天、地图定位(按作业要求免做)。
以上为本作业的完整博客正文。
index.html、css/、js/、__tests__/、imgs/、README.md、package.json全部随 GitHub 仓库公开。
浙公网安备 33010602011771号