| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601软件工程与软件工程实践 |
| 这个作业的要求在哪里 | 2026秋软件工程结对作业(第二次之程序实现) |
| 这个作业的目标 | 延续校园失物招领小程序项目,完成前端页面代码实现、单元测试,使用GitHub进行代码版本管理 |
| GitHub仓库链接 | 102402104-102402105 |
| 结对成员博客(102402105 刘翠华) | 102402105 刘翠华的博客 |
| 结对成员 | 102402104(王可人)、102402105(刘翠华) |
一、具体分工
| 成员 | 学号 | 主要工作 |
|---|---|---|
| 刘翠华 | 102402105 | 数据层设计(store.js / seed.js / utils.js)、发布页与“我的发布”页逻辑实现、单元测试编写、GitHub仓库创建与维护 |
| 王可人 | 102402104 | 首页、搜索页、详情页的改造与联调、UI细节优化、README与目录说明撰写、博客撰写与流程图绘制 |
二、PSP表格
预估耗时
| 阶段 | 预估耗时(分钟) |
|---|---|
| 需求分析 | 20 |
| 原型梳理与分工 | 15 |
| 数据层设计(store.js) | 40 |
| 首页改造 | 30 |
| 搜索页改造 | 30 |
| 详情页改造 | 30 |
| 发布页改造 | 40 |
| 我的发布页改造 | 40 |
| 链接批量替换与联调 | 30 |
| 单元测试 | 50 |
| README与博客撰写 | 60 |
| 合计 | 385 |
实际耗时
| 阶段 | 实际耗时(分钟) | 偏差说明 |
|---|---|---|
| 需求分析 | 25 | 讨论“功能闭环”边界花费较多时间 |
| 原型梳理与分工 | 15 | — |
| 数据层设计 | 55 | 初期未考虑时间戳id重复问题,额外调整 |
| 首页改造 | 35 | 卡片模板拼接与样式调试 |
| 搜索页改造 | 30 | — |
| 详情页改造 | 45 | 字段适配工作量超出预期 |
| 发布页改造 | 50 | 表单校验与数据写入联调 |
| 我的发布页改造 | 40 | — |
| 链接批量替换与联调 | 40 | 遗漏多处绝对链接,二次排查 |
| 单元测试 | 60 | 环境配置 + 16个测试用例编写 |
| README与博客撰写 | 70 | — |
| 合计 | 465 | 主要超时在数据层设计与多页面联调 |
三、解题思路描述与设计实现说明
3.1 代码实现思路
原型阶段5个页面各自独立,详情页内置静态DATA对象,首页、搜索页为硬编码HTML,发布页仅做前端弹窗无数据持久化。本次作业核心目标是打通“发布—浏览/搜索—查看详情—联系—更新状态”完整闭环,核心问题是如何让多页面共享同一份数据。 在不引入后端服务的前提下,选用浏览器localStorage作为共享存储,整体采用三层架构:
1.数据层(js/store.js):封装所有物品数据的增删改查为纯函数,暴露getAll / addItem / getById / markDone / search等接口,页面不直接操作localStorage
2.种子层(js/seed.js):首次访问时注入6条示例数据,保证页面初始加载即有内容可浏览
3. 视图层(5个HTML页面):每个页面仅负责“调用数据层函数获取数据 → 拼接HTML字符串 → 渲染到容器”,同时将静态跳转改为带参跳转detail.html?id=xxx。 通过该架构,详情页只需从URL读取id即可查询到用户最新发布的数据,完整功能闭环得以实现
3.2 整体数据流图


3.3 核心代码片段与解释
数据层核心 —— 让 5 个页面共享一份数据(js/store.js)
点击查看代码
const STORE_KEY = 'campus_lost_found_items';
function getAll() {
try {
return JSON.parse(localStorage.getItem(STORE_KEY)) || [];
} catch (e) {
return []; // 数据被手动改坏时降级为空数组,避免页面白屏
}
}
function saveAll(list) {
localStorage.setItem(STORE_KEY, JSON.stringify(list));
}
function addItem(item) {
const list = getAll();
const record = Object.assign({
id: Date.now(),
no: genNo(),
status: item.type === 'lost' ? '寻找中' : '待认领',
createTime: new Date().toISOString()
}, item);
list.unshift(record); // 最新发布排最前
saveAll(list);
return record;
}
function markDone(id) {
const list = getAll();
const target = list.find(it => String(it.id) === String(id));
if (!target) return false;
target.status = target.type === 'lost' ? '已找回' : '已归还';
saveAll(list);
return true;
}
1.getAll 里加了 try/catch,因为 localStorage 里存的字符串是用户可控的(开发者工具能改),解析失败时不能直接崩页面
2.addItem 里 status 的初值由 type 决定:寻物是"寻找中",招领是"待认领"——这样发布时前端不用关心状态,状态语义由数据层保证
3.markDone 里用 String(id) 转一次类型再比较,因为 URL 里取出来的 id 是字符串,而数据里存的 id 是数字,不转类型会查不到
详情页按 id 读真实数据(detail.html)
点击查看代码
var params = new URLSearchParams(location.search);
var id = params.get('id');
var d = getById(id);
if (!d) {
location.href = 'index.html'; // id 非法时兜底跳回首页
throw new Error('item not found');
}
applyData(d);
URLSearchParams 是原生的,不用引入第三方库。if (!d) 这个兜底很重要:用户手动改 URL 里的 id 时,不能让他看到一个空白页面
搜索的模糊匹配(js/store.js)
点击查看代码
function search(keyword, type) {
const kw = (keyword || '').trim().toLowerCase();
return getAll().filter(it => {
const typeOk = !type || type === 'all' || it.type === type;
const text = (it.title + it.place + (it.desc || '')).toLowerCase();
const kwOk = !kw || text.includes(kw);
return typeOk && kwOk;
});
}
把标题、地点、描述拼成一个大字符串再 includes,避免写三个 || 条件;.toLowerCase() 让"雨伞"和"雨傘"以外的英文大小写都能匹配;(it.desc || '') 是因为描述是选填的,可能为 undefined
四、附加特点设计与展示
4.1 设计创意独到之处,这个设计的意义
1.编号自动生成 采用LF + 年月日 + 当日流水号格式(如 LF20261010001),线下交接时可仅凭编号核对物品,比标题更精准,也便于校园服务中心登记管理
2.状态双语义 寻物完结叫 "已找回",招领完结叫 "已归还"。不在视图层分散写大量判断逻辑,将状态逻辑收敛在数据层markDone函数中,视图直接读取 status 字段展示,消除用户对于 “已完成” 模糊词汇的理解歧义
3.浏览计数附加功能 在详情页加载时对本条数据 views 字段自增,发布者可以看到自己发布信息的浏览量,感知信息曝光程度
4.2 实现思路
- 编号生成:获取系统年月日拼接前缀,统计当天已存在的记录数量生成 3 位流水号,使用padStart补零保证格式统一
- 状态双语义:在 markDone 函数内部根据 type 设置不同状态文本,页面只负责展示,不做业务判断
- 浏览计数:打开详情页执行计数 + 1,借助 sessionStorage 防止反复刷新页面重复刷浏览量
4.3 重要代码片段及解释
点击查看代码
function genNo() {
const d = new Date();
const ymd = d.getFullYear()
+ String(d.getMonth() + 1).padStart(2, '0')
+ String(d.getDate()).padStart(2, '0');
const todayCount = getAll().filter(function(it) {
return it.no && it.no.indexOf('LF' + ymd) === 0;
}).length;
return 'LF' + ymd + String(todayCount + 1).padStart(3, '0');
}
-
padStart(2, '0') 让 9 月变成 09 而不是 9,保证编号长度一致
-
只统计当天已有的编号再 +1,而不是统计全部数据,这样跨天时流水号会重新从 001 开始,符合"日期 + 当日流水"的直觉
-
it.no && 这个判断是防御性的:万一某条历史数据没有 no 字段,不会因为 indexOf 报错而中断
4.4 实现成果展示



五、目录说明和使用说明
5.1目录项目组织
校园失物招领/
├── index.html # 首页:浏览列表 + 类型筛选(全部/寻物/招领)
├── search.html # 搜索页:关键词搜索 + 类型筛选
├── detail.html # 详情页:完整描述、物品信息、发布者、联系方式弹窗
├── publish.html # 发布页:填写表单、校验、写入 localStorage
├── my-posts.html # 我的发布:状态筛选、标记已找回/已归还
│
├── css/
│ └── common.css # 公共样式:no-scrollbar、is-hidden、卡片动画等
│
├── js/
│ ├── store.js # 数据层(核心):localStorage 读写 + CRUD + 搜索
│ ├── seed.js # 初始种子数据(6 条示例)
│ └── utils.js # 工具函数:时间格式化、HTML 转义
│
├── images/ # 本地图片素材(雨伞、校园卡、耳机等配图)
├── tests/
│ └── store.test.js # Jest 单元测试(不上传仓库,仅供本地运行)
└── README.md # 目录说明 + 使用说明
组织原则:HTML 平铺在根目录(因为只有 5 个页面,不需要再套文件夹),js 按职责拆成数据层 / 种子 / 工具三个文件。store.js 是唯一一个所有页面都要引的脚本,放在 js/ 下统一管理
页面之间的依赖关系:
| 页面 | 依赖的脚本 | 读/写数据 |
|---|---|---|
| index.html | store.js, seed.js | 读 |
| search.html | store.js, seed.js | 读 |
| detail.html | store.js | 读(+ 写浏览计数) |
| publish.html | store.js | 写 |
| my-posts.html | store.js | 读 + 写(标记状态) |
5.2 测试人员运行网页
环境:Chrome 浏览器
1.将项目全部文件下载到本地文件夹
2.两种运行方式
方式 1:直接双击 index.html 使用 Chrome 打开,CDN 网络正常即可直接运行。
方式 2(CDN 加载异常),在项目文件夹打开终端,执行 python -m http.server 8080,浏览器访问http://localhost:8080
功能自测流程:
1.打开 index.html,加载 6 条示例数据
2. 切换寻物 / 招领标签,列表过滤、计数同步
3. 进入发布页面,不填必填项提交,出现红色错误提示
4. 填完整信息发布,弹出发布成功
5. 我的发布页面看到刚刚新增记录
6. 首页、搜索页都可以查询到这条记录
7. 点击进入详情查看全部内容
8. 回到我的发布标记状态,卡片状态更新
9. 回到首页,数据状态同步更新
重置数据:Chrome 开发者工具 Application→Local Storage,删除campus_lost_found_items,刷新页面恢复种子示例数据
六、单元测试
6.1 测试工具以及简易教程
选用Jest + jest-environment-jsdom
- Jest:单元测试框架,提供 test、expect、beforeEach 等 API
- jest-environment-jsdom:模拟浏览器环境,支持 localStorage
- Node.js:运行测试脚本
简易教程:
1.初始化项目
点击查看代码
npm init -y
npm install --save-dev jest jest-environment-jsdom
2.修改 package.json
点击查看代码
{
"scripts": {
"test": "jest"
},
"jest": {
"testEnvironment": "jsdom",
"testMatch": ["**/tests/**/*.test.js"]
}
}
3.store.js 末尾增加导出,兼容 node 测试环境
点击查看代码
if (typeof module !== 'undefined' && module.exports) {
module.exports = { getAll, saveAll, genNo, addItem, getById, markDone, search };
}
4.在 tests 目录编写 xxx.test.js 测试用例,执行命令
点击查看代码
npm test
关键要点:
- testEnvironment 设置为 jsdom,否则没有浏览器 localStorage 对象
- beforeEach 在每个用例执行前清空 localStorage,保证用例互不干扰
6.2 测试用例设计
采用白盒测试,覆盖所有函数分支,结合边界值、等价类划分、错误推测,一共 15 个用例
表格
| 编号 | 测试函数 | 用例描述 | 输入 | 预期输出 | 覆盖的方法 |
|---|---|---|---|---|---|
| TC01 | addItem | 新增寻物信息,状态初值应为 "寻找中" | type:'lost', title:' 雨伞 ' | 返回对象的 status === ' 寻找中 ' | 三目真分支 |
| TC02 | addItem | 新增招领信息,状态初值应为 "待认领" | type:'found', title:' 钥匙 ' | 返回对象的 status === ' 待认领 ' | 三目假分 |
| TC03 | getById | 通过有效 id 查询 | addItem (...) 后取返回的 id | 返回对象 title 一致 | 正常路径 |
| TC04 | getById | 通过不存在的 id 查询 | id = -1 | 返回 null | 未找到分支 |
| TC05 | markDone | 寻物标记后状态为 "已找回" | lost 类型,markDone | status === ' 已找回 ' | 类型判断真分支 |
| TC06 | markDone | 招领标记后状态为 "已归还" | found 类型,markDone | status === ' 已归还 ' | 类型判断假分支 |
| TC07 | markDone | 对不存在的 id 操作应返回 false | markDone (-1) | 返回 false | 兜底分支 |
| TC08 | search | 关键词命中标题 | 发布 "黑色雨伞",搜 "雨伞" | 返回 1 条 | includes 真分支 |
| TC09 | search | 空关键词返回全部 | search ('', 'all') | 返回全部条数 | !kw 真分支 |
| TC10 | search | 关键词无匹配返回空数组 | 搜 "不存在的物品" | [] | includes 假分支 |
| TC11 | search | 类型筛选只返回对应类型 | 1 条 lost + 1 条 found,search ('', 'lost') | 返回 1 条且 type 为 lost | 类型过滤分支 |
| TC12 | search | 大小写不敏感(英文关键词) | 发布 "iPhone",搜 "iphone" | 返回 1 条 | toLowerCase 分支 |
| TC13 | getAll | localStorage 数据被改坏时降级为空数组 | 手动塞入非法 JSON 字符串 | 返回 [] 而不抛异常 | try/catch 分支 |
| TC14 | genNo | 同一天新增两条,编号流水号递增 | 连续 addItem 两次 | 第二条编号后缀为 002 | padStart + 计数分支 |
| TC15 | search | 关键词两侧有空格应被 trim | 搜 "雨伞" | 返回 1 条 | trim 分支 |
6.3 单元测试代码 tests/store.test.js
点击查看代码
const {
getAll, saveAll, genNo, addItem, getById, markDone, search
} = require('../js/store');
beforeEach(() => {
localStorage.clear();
});
describe('addItem', () => {
test('TC01 寻物信息状态初值为「寻找中」', () => {
const r = addItem({ type: 'lost', title: '黑色雨伞', place: '图书馆' });
expect(r.status).toBe('寻找中');
expect(r.title).toBe('黑色雨伞');
});
test('TC02 招领信息状态初值为「待认领」', () => {
const r = addItem({ type: 'found', title: '一串钥匙', place: '快递柜' });
expect(r.status).toBe('待认领');
});
test('TC14 同一天连续新增两条,流水号递增', () => {
const a = addItem({ type: 'lost', title: 'A', place: 'X' });
const b = addItem({ type: 'lost', title: 'B', place: 'Y' });
expect(a.no.slice(-3)).toBe('001');
expect(b.no.slice(-3)).toBe('002');
});
});
describe('getById', () => {
test('TC03 有效 id 能查到对应记录', () => {
const r = addItem({ type: 'lost', title: '水杯', place: 'A305' });
expect(getById(r.id).title).toBe('水杯');
});
test('TC04 不存在的 id 返回 null', () => {
expect(getById(-1)).toBeNull();
});
test('TC04b 字符串 id 与数字 id 视为同一条', () => {
const r = addItem({ type: 'lost', title: '水杯', place: 'A305' });
expect(getById(String(r.id)).title).toBe('水杯');
});
});
describe('markDone', () => {
test('TC05 寻物标记后状态为「已找回」', () => {
const r = addItem({ type: 'lost', title: '书包', place: '操场' });
markDone(r.id);
expect(getById(r.id).status).toBe('已找回');
});
test('TC06 招领标记后状态为「已归还」', () => {
const r = addItem({ type: 'found', title: '耳机', place: '体育馆' });
markDone(r.id);
expect(getById(r.id).status).toBe('已归还');
});
test('TC07 对不存在的 id 调用返回 false', () => {
expect(markDone(99999)).toBe(false);
});
});
describe('search', () => {
beforeEach(() => {
addItem({ type: 'lost', title: '黑色雨伞', place: '图书馆', desc: '' });
addItem({ type: 'found', title: '校园一卡通', place: '第二食堂', desc: '' });
addItem({ type: 'found', title: 'iPhone 充电器', place: 'A305', desc: '' });
});
test('TC08 关键词命中标题', () => {
expect(search('雨伞', 'all').length).toBe(1);
});
test('TC09 空关键词返回全部', () => {
expect(search('', 'all').length).toBe(3);
});
test('TC10 无匹配关键词返回空数组', () => {
expect(search('外星人', 'all')).toEqual([]);
});
test('TC11 类型筛选只返回对应类型', () => {
const list = search('', 'lost');
expect(list.length).toBe(1);
expect(list[0].type).toBe('lost');
});
test('TC12 英文关键词大小写不敏感', () => {
expect(search('iphone', 'all').length).toBe(1);
});
test('TC15 关键词两侧空格应被 trim', () => {
expect(search(' 雨伞 ', 'all').length).toBe(1);
});
});
describe('容错', () => {
test('TC13 localStorage 存了非法 JSON 时返回空数组', () => {
localStorage.setItem('campus_lost_found_items', '{not-json}');
expect(getAll()).toEqual([]);
});
});
6.4 构造测试数据思路、应对刁难场景
1.正常数据:模拟用户正常填写表单提交,验证主业务流程
2.边界数据:空搜索词、带空格搜索词、id 数字 / 字符串两种形式
3.异常脏数据:localStorage 被篡改写入非法 JSON,id 传入不存在、负数
4.状态组合:lost/found 两种类型全部覆盖,保证分支全部跑通
刁难场景应对:
- 用户手动篡改 localStorage:getAll 内部 trycatch 捕获,返回空数组,页面不会崩溃
- URL 手动修改为不存在 id:详情页判断,自动跳转到首页
- 搜索输入全空格:search 内部 trim 处理
- id 类型数字字符串不一致:数据层统一 String () 做转换,上层页面不用处理
七、Github 代码签入记录截图

八、遇到的代码模块异常或结对困难及解决方法
困难 1:5 个页面各自为政,数据根本不通
问题描述 原型的各个 HTML 页面互相独立,详情页写死 DATA,首页搜索页写死静态卡片。发布成功之后刷新首页看不到新增数据,功能闭环断裂
做过哪些尝试
1.postMessage 页面通信,刷新页面数据丢失
2.sessionStorage 存储,关闭浏览器数据消失
3.搭建后端,但是作业要求下载直接打开网页,不能依赖后台服务
是否解决:已解决 最终采用 localStorage 持久存储,把全部读写逻辑封装在 store.js,所有页面调用统一接口,禁止页面直接操作 localStorage
有何收获:数据层与视图层解耦非常重要,把公共逻辑抽离,一处修改全部页面生效;一定要做异常捕获,不能假设存储的数据一定合法
困难 2:URL 传参的 id 类型不一致,详情页查不到数据
问题描述 URL 拿到 id 是字符串,代码内部 Date.now 生成 id 是数字,===严格相等匹配不到,详情页空白
做过哪些尝试
1.在详情页单独 parseInt 转换,但是我的发布页面同样存在该问题,到处打补丁
2.将全部 id 存为字符串,每个新增都要转换,容易遗漏
是否解决:已解决 统一在getById、markDone函数内部执行String(id)强制类型转换,把类型兼容逻辑收敛在数据层。并且新增单元测试用例覆盖该场景
有何收获:同类问题不要到处写补丁,优先下沉到公共底层函数处理。单元测试可以把容易踩坑的类型问题固化为用例,防止后续改代码再次复现
困难 3:编号自动生成的重复问题
问题描述 最开始编号流水号直接用全部数据长度 + 1,如果删除记录,流水号就会重复,编号失去核对物品的意义
做过哪些尝试
1.使用 Date.now 拼接编号,编号数字太长不方便线下口头报号
2.在 localStorage 单独存一个全局最大流水号 key,容易和主数据不同步
是否解决:已解决 统计当天已存在的 LF 前缀记录数量 + 1 作为流水号,增加防御判断it.no &&防止脏数据报错,padStart 补零保证 3 位流水号。同时编写单元测试验证流水号递增。
有何收获:简单的生成逻辑,也要考虑删除、脏数据、跨天等边界情况
困难 4:结对协作中的链接冲突
问题描述 原型是魔刀的绝对网页链接,两个人同时改页面,都做链接替换,合并 PR 的时候大量冲突
做过哪些尝试
1.两个人同时修改,手动解决冲突,漏改链接
2.一人先做完链接替换,另一人等待
是否解决:已解决 约定链接替换作为独立 commit,由其中一人完成合并到主分支,另一个人拉取最新主干再开发,commit 信息写明只做链接替换,方便 review
有何收获:结对开发公共资源修改尽量单独提交,commit 粒度要小,描述清晰,减少合并冲突
九、评价你的队友
值得学习
1.细心,逐字段核对 UI 界面,找出字段命名不一致等细小 bug;
2.UI 审美较好,调整页面间距、样式,还原设计稿;
3.文档意识好,写代码会增加注释,博客、README 文档输出质量高。
需要改进
1.习惯浏览器调试工具改样式,忘记同步修改源码,会出现本地和仓库代码不一致
2.对 npm、jest 工具环境不熟悉,遇到报错第一反应重装,没有先阅读报错提示定位问题
结对收获:两个人能力互补,结对不是简单平分任务,互相审查代码可以发现很多单人开发忽视的 bug
浙公网安备 33010602011771号