软件工程个人作业(第四次)

校园失物招领 —— 结对编程(第二次作业 · Web 版)

单人完成版。基于第一次结对作业的需求分析与原型设计,实现「发布—浏览/搜索—查看详情—联系发布者—更新状态」的核心流程。

一、开篇信息

项目 内容
这个作业属于哪个课程 2026秋软件工程与软件工程实践 - 福州大学
这个作业要求在哪里 结对编程作业要求
这个作业的目标 完成校园失物招领小程序的 Web 版实现
学号 052401324
GitHub 仓库 https://github.com/12aa950/-Web-

分工表

成员 学号 主要工作
刘昭杉 052401324 需求梳理、原型复现、全栈实现、单元测试、博客撰写

二、解题思路描述与设计实现说明(20 分)

2.1 题目核心要求

实现一个校园失物招领的 Web 应用,需完成:

  1. 发布寻物 / 招领信息;
  2. 集中浏览校园失物招领内容;
  3. 通过物品名称等关键词搜索;
  4. 查看物品详细信息及联系方式;
  5. 发布者将信息标记为「已找到」或「已归还」。

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 =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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. 实现成果展示

首页(默认进入)

01-home

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

02-search

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

03-detail

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

04-mine

发布弹窗(含必填校验)

05-publish


六、目录说明和使用说明(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):

  1. 下载整个项目;
  2. 用 Chrome 浏览器双击 index.html;
  3. 即可看到首页(首次进入会有 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)。学习路径:

  1. 看 Jest 官方文档 30 分钟:了解 describe / test / expect 三件套;
  2. 第一次写一个最简单的测试:expect(1+1).toBe(2),跑通后信心增加;
  3. 针对当前项目:因为业务逻辑都在纯函数里,所以直接 require 然后断言返回值即可;
  4. 小坑: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 仓库公开。

posted @ 2026-10-09 09:22  jisl  阅读(6)  评论(0)    收藏  举报