项目 内容
这个作业属于哪个课程 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 整体数据流图

流程1

流程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 实现成果展示

1

2

3

五、目录说明和使用说明

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

posted on 2026-10-10 21:57  asakinza  阅读(7)  评论(0)    收藏  举报