软件工程第四次作业

《校园失物招领》结对编程项目博客

作者:马天鸿(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 或构建工具。理由有三:

  1. 助教拿到项目后双击 index.html 就能跑,不需要 Node 环境、不需要 npm install、不需要启动服务器,降低评测门槛;

  2. 数据用 localStorage 存储,刷新页面不丢失,天然满足 "无后端" 约束;

  3. 原生实现迫使我们把数据操作和视图渲染分层 —— 数据层在 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)之间的数据流向。

image

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

image

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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 或

localhost

)下可用,我们的项目是

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、类别筛选、信息列表):

image

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

image

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

image

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

image

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 测试人员如何运行

  1. 直接打开:用 Google Chrome 浏览器打开 index.html,无需安装环境、无需联网、无需启动服务器。

  2. 示例数据:首页自带 5 条示例数据(3 条招领、2 条寻物),可直接浏览、搜索、筛选。

  3. 发布与修改:点击右下角绿色 "+" 按钮发布信息;数据保存在浏览器 localStorage 中,刷新不丢失。

  4. 恢复初始数据:在浏览器控制台执行 localStorage.clear() 后刷新页面,即可恢复示例数据。

  5. 运行单元测试:用 Chrome 打开 tests.html,页面自动执行 12 个测试用例并显示通过 / 失败结果。

7. 单元测试

7.1 测试工具与学习简易教程

我们没有引入 Mocha 或 Jest 等外部测试框架,而是手写了一个轻量测试运行器(tests.html)。原因是作业要求 "用 Chrome 打开 HTML 就能跑",引入 Node.js 框架会增加助教的运行门槛。

学习路径:

  1. 先明确测什么—— 数据层 data.js 暴露的每个函数:getAll、getById、add、updateStatus、remove、search、filterByType、filterByCategory、getMine;

  2. 每个测试用例结构固定:test(用例名, function() { ...断言... }),断言失败时 throw new Error(描述);

  3. 关键技巧:每个用例前调用 LF._reset() 清空 localStorage,保证用例之间完全独立,不互相污染;

  4. 跑完后统计通过 / 失败数,渲染到页面顶部。

一个最小示例:

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

image

7.3 构造测试数据的思路

  1. 正常数据:每条 CRUD 操作都有对应用例,验证返回值字段正确、localStorage 状态一致;

  2. 边界数据:空关键词搜索应返回全部、不存在的 ID 应返回 null、空标题应抛异常;

  3. 刁难数据(想象测试人员会怎么折腾):

  • 输入空标题、纯空格标题,验证必填校验不通过;

  • 传入非法状态值(如 "bad_status"),验证状态更新抛异常;

  • 查询不存在的 ID,验证返回 null 而不是崩溃;

  • 删除一条不存在的记录,验证不报错(filter 自然过滤)。

8. GitHub 代码签入记录

本地 Git 提交记录(commit message 遵循 feat: 前缀 + 中文功能描述):

432019fe6014202e5bbafc452511ed7b_720

9. 遇到的代码模块异常或结对困难及解决方法

问题一:localStorage 在 file:// 协议下读取异常导致白屏

  • 问题描述:最初写 readStore() 时没有 try/catch,某次调试时 localStorage 里残留了一段损坏的 JSON,页面一打开就抛 JSON.parse 异常,整个首页白屏。

  • 做过哪些尝试:先在控制台手动 localStorage.clear() 恢复,但这只是治标。改为给 readStore() 加 try/catch + Array.isArray 双重校验,任何异常都安全回退空数组。

  • 是否解决:已解决。

  • 有何收获:localStorage 是 "不可信输入",读取必须容错;对持久化数据的访问永远不能裸调 JSON.parse。

问题二:详情页 "标记解决" 按钮只在发布者自己可见

  • 问题描述:最初所有人进详情页都能看到 "标记为已解决" 按钮,这不合理 —— 捡到东西的人不应该有权把别人发的寻物启事标成已找到。

  • 做过哪些尝试:在 data.js 里加了一个单独的 myIds 数组(localStorage key campus_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 衔接。

posted @ 2026-10-09 12:34  102402131马天鸿  阅读(4)  评论(0)    收藏  举报