2026秋软件工程第二次结对作业——校园失物招领(程序实现)

🎒 2026秋软件工程第二次结对作业——校园失物招领(程序实现)

📌 校园里丢了校园卡、钥匙找不到人?捡到东西不知交给谁?我们做了一个集中式失物招领平台——发布、搜索、联系、标记状态一条龙,让失物早日回家 🏠。

项目 内容
这个作业属于哪个课程 https://edu.cnblogs.com/campus/fzu/2026-01SoftwareEngineeringandSoftwareEngineeringPractice
这个作业要求在哪里 https://edu.cnblogs.com/campus/fzu/2026-01SoftwareEngineeringandSoftwareEngineeringPractice/homework/16745
这个作业的目标 基于第一次结对作业的原型,完成"校园失物招领"核心功能(发布信息—浏览/搜索—查看详情—联系发布者—更新状态)的程序实现与单元测试
学号、姓名 102401302 陈果,102401304 向飞燕
结对同学博客链接 <【待填写:搭档的博客园链接】>
本作业博客链接 https://www.cnblogs.com/chengguoo/p/23204473
GitHub 仓库 https://github.com/Sakura-x000/102401302-102401304

✅ 已在班级群结对表与项目地址统计表中填写 GitHub 仓库地址。


👥 一、具体分工

成员 学号 主要负责
向飞燕 102401304 逻辑层(storage / validate / search / status)、单元测试、仓库与 PR 管理、README
陈果 102401302 页面与交互(index.html / css / app.js)、走查测试、博客撰写、截图整理

两人共同参与:需求讨论、接口约定、联调走查、PSP 填写、博客互审。


⏱️ 二、PSP 表格

预估为开发前两人讨论估算,实际为开发后回填。

PSP2.1 阶段 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 10 15
Estimate 估计任务时间 10 10
Development 开发
Analysis 需求分析(含学习新技术) 60 90
Design Spec 生成设计文档 30 30
Design Review 设计复审 20 20
Coding Standard 代码规范 10 10
Design 具体设计 40 45
Coding 具体编码 240 300
Code Review 代码复审 30 40
Test 测试(自测、修改、提交) 90 130
Reporting 报告
Test Report 测试报告 30 35
Size Measurement 计算工作量 10 10
Postmortem 事后总结 20 30
合计 600 765

💭 编码与测试两阶段实际耗时明显超出预估:编码超出约 1 小时,主要花在排查「浏览器原生 window.Storage 与自定义模块重名」上;测试超出约 40 分钟,主要花在 Node 环境下模拟 window / localStorage 上。事后总结阶段也比预估久,因为第一次结对协作需要磨合 PR 流程与接口约定。


💡 三、解题思路与设计实现说明

🔧 3.1 代码实现思路

我们选择 Web 网页 实现 🖥️,纯前端三件套(HTML + CSS + JavaScript),数据用浏览器 localStorage 持久化,不依赖后端。助教下载后用谷歌浏览器打开 index.html 即可运行 ✅。

整体采用分层解耦的结构 🏗️,逻辑层与页面层分离:

  • 🗄️ 存储层 storage.js:localStorage 读写、信息增改、ownerId 生成与持久化
  • ✅ 校验层 validate.js:发布表单必填与格式校验,返回 { valid, errors }
  • 🔎 搜索层 search.js:关键词 + 多条件过滤,按发布时间倒序
  • 🏷️ 状态层 status.js:统一"寻物/招领""进行中/已找到/已归还"文案
  • 🎨 页面层 app.js:DOM 渲染与事件绑定,串联完整流程

核心数据流 🔄:app.js → 调用 Validate 校验 → 通过后 Storage.saveItem 存入 localStorage → 列表刷新时 Storage.getAllItems 取出 → 经 Search.filterItems 过滤排序 → 渲染到页面。

模块之间通过约定的接口(挂载在 window 上)通信,两人并行开发互不阻塞:一人写逻辑层,一人写页面层,页面层先用临时 mock 跑通,逻辑层完成后替换为真实模块。

💡 关于"用户身份"的设计说明:本项目为纯前端、无后端无登录,数据存于浏览器 localStorage,因此以浏览器为单位区分用户:storage.js 为每个浏览器生成一个 ownerId,发布信息时携带该 id,据此判断是否为"我发布的"。需注意两点:① 同一浏览器内,示例数据已同时包含"本人发布"(带"我发布的"标记、可标记状态)与"他人发布"(无标记、无标记按钮)两类,可直接观察区分效果;② 不同浏览器或无痕窗口的 localStorage 相互独立、数据不互通,各自是独立的数据池。

🗺️ 3.2 关键实现流程图 / 数据流图

📋 发布信息流程图:

流程:填写表单 → 点击发布 → validateItem 校验 → 不通过(提示 errors,停留表单)/ 通过(saveItem 存入 → 关闭表单 → 刷新列表 → 成功提示)

🔀 数据流向图:

localStorage ⇄ storage.js ⇄ search.js ⇄ app.js(渲染);validate.js 负责入口校验;status.js 提供文案。

📝 3.3 重要代码片段与解释

① 校验模块——把"不完整发布"挡在入口(validate.js) 🚧

function validateItem(data) {
  var errors = {};
  data = data || {};
  if (!data.title || !String(data.title).trim()) {
    errors.title = '物品名称不能为空';
  } else if (String(data.title).trim().length > 50) {
    errors.title = '物品名称不能超过 50 字';
  }
  if (!data.type) {
    errors.type = '请选择类型';
  } else if (data.type !== 'lost' && data.type !== 'found') {
    errors.type = '类型只能是寻物或招领';
  }
  // ... 地点、发布者、联系方式同理
  return { valid: Object.keys(errors).length === 0, errors: errors };
}

解释:所有必填校验集中在一处,返回结构化的 errors,页面层据此提示,避免脏数据进入存储。

② 搜索模块——多条件组合过滤 + 时间倒序(search.js) 🔎

function filterItems(items, filters) {
  filters = filters || {};
  if (!Array.isArray(items)) return [];
  var keyword = String(filters.keyword || '').trim().toLowerCase();
  var result = items.filter(function (it) {
    if (!it) return false;
    if (filters.type && it.type !== filters.type) return false;
    if (filters.category && it.category !== filters.category) return false;
    if (filters.status && it.status !== filters.status) return false;
    if (filters.ownerId && it.ownerId !== filters.ownerId) return false;
    if (keyword) {
      var haystack = [it.title, it.description, it.location, it.publisher, it.category]
        .filter(Boolean).join(' ').toLowerCase();
      if (haystack.indexOf(keyword) === -1) return false;
    }
    return true;
  });
  result.sort(function (a, b) { return (b.createdAt || 0) - (a.createdAt || 0); });
  return result;
}

解释:关键词在标题/描述/地点/发布者/类别中模糊匹配;各筛选项可任意组合;最后按 createdAt 倒序。对 items 非数组做了防御,返回空数组而非报错。

③ 状态模块——一处定义、全页面一致的文案(status.js) 🏷️

function getStatusText(item) {
  if (!item) return '';
  if (item.status !== 'resolved') return '进行中';
  return item.type === 'lost' ? '已找到' : '已归还';
}

解释:寻物结束叫"已找到"、招领结束叫"已归还",文案逻辑集中在这里,列表卡片和详情弹窗都调用它,避免各处硬编码不一致。
image

图中展示了“已找到”与“进行中”两种状态在卡片上的显示效果。

④ 标记状态更新(storage.js) ✏️

function updateItem(id, changes) {
  if (!id || !changes || typeof changes !== 'object') return null;
  var items = readItems();
  for (var i = 0; i < items.length; i++) {
    if (items[i].id === id) {
      items[i] = Object.assign({}, items[i], changes);
      writeItems(items);
      return items[i];
    }
  }
  return null; // 对不存在的 id 返回 null
}

解释:发布者在详情里点"标记为已找到/已归还",本质就是 updateItem(id, { status: 'resolved' }),更新后列表与详情同步刷新。


✨ 四、附加特点设计与展示

作业要求的功能不算附加特点,以下为我们额外做的人性化设计 💡。

特点一:📋 一键复制联系方式

  • 创意与意义:失物招领的关键一步是"联系发布者"。手动选中复制微信号/手机号易出错,一键复制降低联系门槛。
  • 实现思路:详情弹窗中放"📋 复制联系方式"按钮,调用剪贴板 API 复制并弹 Toast 提示;对非安全上下文(如 file:// 打开)做了降级方案。
  • 代码(app.js):
$('btnCopy').addEventListener('click', () => {
  const text = it.contact;
  if (navigator.clipboard && window.isSecureContext) {
    navigator.clipboard.writeText(text)
      .then(() => showToast('联系方式已复制'))
      .catch(() => fallbackCopy(text));
  } else {
    fallbackCopy(text);   // 非安全上下文降级方案
  }
});
  • 成果展示:
    imageimage

特点二:🔍 多维筛选 + 筛选状态提示

  • 创意与意义:信息多了只靠关键词不够。加了类型、类别、状态、"我的发布"四种筛选,顶部紫色提示条实时显示"当前筛选:…"并带"清除筛选",让用户清楚自己的过滤状态。
  • 实现思路:下拉框 change 即触发刷新;本人发布信息带绿色"我发布的"标记。
  • 代码(app.js):
function updateFilterHint() {
  const parts = [];
  if (f.keyword)  parts.push('关键词「' + f.keyword + '」');
  if (f.type)     parts.push(f.type === 'lost' ? '寻物' : '招领');
  if (f.category) parts.push(f.category);
  if (f.status)   parts.push(f.status === 'active' ? '进行中' : '已结束');
  if (f.ownerId)  parts.push('我的发布');
  // 无筛选则隐藏提示条;否则显示"当前筛选:…" + 清除按钮
}
  • 成果展示:
    imageimage

特点三:🌱 首次访问自动填充示例数据

  • 创意与意义:新用户第一次打开若是空白页,无法直观感受功能。localStorage 为空时自动生成几条覆盖各种情况的示例,打开即有内容可看可点。
  • 实现思路:初始化时判断列表是否为空,为空则写入示例(含寻物/招领、已结束状态、本人发布),用 length > 0 判断避免重复填充。
  • 代码(app.js):
function seedIfEmpty() {
  if (Storage.getAllItems().length > 0) return;   // 已有数据则不重复填充
  const ownerId = Storage.getOwnerId();
  const samples = [ /* 寻物/招领、含已解决状态、含本人发布的示例 */ ];
  samples.forEach(s => Storage.saveItem(Object.assign({ id, status: 'active' }, s)));
}
  • 成果展示:
    49b27c019e3274a3535f3fcad179e9f8

特点四:📱 响应式适配

  • 创意与意义:失物招领更多在手机上使用。页面在电脑和手机宽度下都能正常布局,卡片自适应。
  • 实现思路:用媒体查询,窗口宽度小于 640px 时切换为单列纵向布局。
  • 代码(style.css):
@media (max-width: 640px) {
  .search-box { flex-direction: column; }
  .filter-row { flex-direction: column; }
  .filter-row select, .filter-row .btn { width: 100%; }
  .list { grid-template-columns: 1fr; }   /* 卡片由多列变单列 */
}
  • 成果展示: imageimage

📂 五、目录说明与使用说明

5.1 目录组织 🗂️

102401302-102401304/
├── index.html          # 页面结构(首页、发布表单、详情弹窗)
├── css/
│   └── style.css       # 样式
├── js/
│   ├── storage.js      # 数据存储(localStorage)
│   ├── validate.js     # 发布信息校验
│   ├── search.js       # 搜索与筛选
│   ├── status.js       # 状态与类型文案
│   └── app.js          # 页面渲染与交互(调用上述模块)
├── test/
│   └── unit.test.js    # 单元测试(Mocha + Chai)
├── package.json        # 依赖与测试脚本
├── .gitignore
└── README.md           # 项目说明、目录与使用说明

逻辑层四个模块各自独立、挂在 window 上,页面层 app.js 只做渲染和调用,职责清晰、便于两人并行开发与测试。
imagee46030bb-87cc-45d1-803f-523b3a3bec5f

上图为README 中的目录说明。

5.2 如何运行 ▶️

  1. 将整个项目下载到本地;
  2. 用谷歌浏览器直接打开 index.html 即可使用,无需安装任何环境、无需启动服务器 🚀;
  3. (可选)运行单元测试:需安装 Node.js,在项目目录执行 npm install 后 npm test。
  4. 可用F12模拟手机页面,也可直接缩放网页宽度查看手机端展示效果。

image


🧪 六、单元测试

6.1 测试工具与学习、简易教程 📚

我们选用 Mocha(测试框架)+ Chai(断言库),理由是轻量、对原生 JS 友好、npm test 一条命令即可自动化运行。

学习过程:参考作业附录推荐的"廖雪峰的 JavaScript"与"Mocha 实例教程",先理解 describe / it / expect 的基本写法,再针对四个模块逐个设计用例。

简易教程(我们自己的小结) 📝:

  1. npm init 初始化项目,npm i -D mocha chai 安装;
  2. package.json 里设 "scripts": { "test": "mocha" };
  3. 测试文件用 import { expect } from 'chai',用 describe('模块名', () => { it('用例名', () => { expect(...).to... }) }) 组织;
  4. 命令行 npm test 运行,看到 ✓ 和 passing 即通过。

6.2 测试代码与被测函数 💻

测试覆盖四个模块共 29 个用例,全部通过 ✅。以下为部分代码:

// 模拟浏览器环境(Node 里没有 window / localStorage)
global.window = {};
const storageMap = new Map();
global.localStorage = {
  getItem: (k) => (storageMap.has(k) ? storageMap.get(k) : null),
  setItem: (k, v) => storageMap.set(k, String(v)),
  removeItem: (k) => storageMap.delete(k),
  clear: () => storageMap.clear()
};
// 加载四个模块,它们会自动挂到 window 上
['storage.js', 'validate.js', 'search.js', 'status.js'].forEach((m) => {
  eval(fs.readFileSync(path.join(rootDir, 'js', m), 'utf8'));
});

describe('Validate 模块', () => {
  it('完全空的表单校验不通过', () => {
    const r = Validate.validateItem({});
    expect(r.valid).to.be.false;
    expect(r.errors).to.have.keys(['title', 'type', 'location', 'publisher', 'contact']);
  });
  it('联系方式少于 3 字符时提示', () => {
    const r = Validate.validateItem({
      type: 'lost', title: '校园卡', location: '食堂',
      publisher: '张三', contact: 'ab'
    });
    expect(r.valid).to.be.false;
    expect(r.errors.contact).to.equal('联系方式至少 3 个字符');
  });
});

describe('Search 模块', () => {
  it('关键词无匹配时返回空数组', () => {
    const r = Search.filterItems(sample, { keyword: '不存在的物品' });
    expect(r).to.be.an('array').that.is.empty;
  });
  it('组合筛选:type + status', () => {
    const r = Search.filterItems(sample, { type: 'lost', status: 'active' });
    expect(r).to.have.lengthOf(1);
  });
});

被测函数:Storage.getAllItems/saveItem/updateItem/getOwnerId、Validate.validateItem、Search.filterItems、Status.getStatusText/getTypeText。

image
199e580a2b663dc4a20c0a6c8a484126

终端npmtest通过画面

6.3 测试数据构造思路与"刁难"考虑 🧠

采用白盒思路,针对每个函数的分支设计数据:

  • ✅ 正常路径:合法表单校验通过、关键词命中、筛选返回正确条数;
  • ⚠️ 空/边界:空 localStorage、空表单、联系方式过短、名称超长;
  • 🧨 异常输入:type 传非法值、items 传 null/undefined、updateItem 传不存在的 id、getStatusText 传 null——确保函数不崩溃、返回约定值;
  • 🔀 组合条件:类型+状态组合筛选,验证多条件交集正确;
  • ↕️ 排序:验证无筛选时按 createdAt 倒序。

考虑到将来测试人员可能传入各种奇怪输入,我们对每个模块的入口都做了类型判断与兜底(如 Array.isArray、data || {}),这也是测试能覆盖到的健壮性设计。


🌿 七、GitHub 代码签入记录

我们按功能划分提交,每完成一个模块 commit 一次,commit message 采用 类型: 描述 的规范格式。
image

签入记录(示例):

  • feat: 实现 localStorage 数据存储模块
  • feat: 实现发布信息校验模块
  • feat: 实现搜索与筛选模块
  • feat: 实现状态与类型文案模块
  • feat: 完成页面交互、示例数据与筛选提示
  • test: 添加 29 个单元测试用例
  • refactor: 移除临时 mock,改为直接引用逻辑层模块

🐛 八、遇到的代码模块异常或结对困难及解决方法

本次开发中我们遇到了两个比较典型的问题,一个出在代码层,一个出在协作流程层,分别记录如下。

问题一:浏览器原生 window.Storage 与自定义模块重名冲突 💥

  • 问题描述:开发初期,页面层用 window.Storage || mock 来判断逻辑层是否就位。但浏览器本身自带全局 window.Storage(即 localStorage 的构造函数),导致判断误判、页面报 getAllItems is not a function,整个列表渲染瘫痪。
  • 做过哪些尝试:起初以为是模块没加载,反复检查 <script> 引入顺序;后来在控制台打印 window.Storage,才发现拿到的是浏览器原生对象,而不是我们自定义的模块。
  • 是否解决:✅ 解决。改为检测具体方法是否存在(window.Storage && typeof window.Storage.getAllItems === 'function'),而非直接判断对象真值;逻辑层就位后,用 window.Storage = {...} 直接覆盖原生对象。
  • 有何收获:在浏览器全局命名空间挂载自定义模块时,要警惕与原生全局对象(如 Storage、History、Location)重名;判断“模块是否加载”应检测具体方法,而不是判断对象本身是否存在。

问题二:文本文件编码不一致导致中文乱码 🔤

  • 问题描述:用 Windows 记事本编辑 index.html、js/app.js 时,文件被默认保存为 GBK 编码,而 HTML 头部声明的是 UTF-8。结果浏览器和 GitHub 上所有中文全部显示为 鏍″洯... 这类乱码。
  • 做过哪些尝试:一开始怀疑是队友的代码写错了,在 CMD 里用 type 命令直接读取文件内容,确认文件本身就是乱码;改用 VS Code 打开后,看右下角状态栏显示编码是 GBK,通过 Reopen with Encoding → UTF-8 重新读取后,中文恢复正常。
  • 是否解决:✅ 解决。所有文本文件统一用 VS Code 保存为 UTF-8,并在 VS Code 设置里把默认编码固定为 UTF-8,从源头杜绝。
  • 有何收获:多人协作时,文件编码要提前统一。记事本等工具在不同系统下默认编码不同,极易在提交、拉取时引入乱码;使用现代编辑器(如 VS Code)并统一默认编码,能有效避免这类问题。

💬 小结
两个问题看似不相关,其实都指向同一个教训:协作开发中,“约定”比“各自能跑”更重要。接口要约定、编码要约定、命名规范也要约定。把这些前期约定做扎实,能省掉后面大量返工时间。


🤝 九、评价队友

向飞燕 评价 陈果(页面层)

值得学习的地方:

  1. 页面实现效率高。 index.html / css/style.css / app.js 一次性写完并自测通过,PR 描述清晰(接口约定、数据字段、注意事项全都列出来了),我这边接逻辑层几乎零沟通成本。
  2. 细节考虑周到。 app.js 里用「检测方法是否存在」而不是「判断对象真值」来判断逻辑层是否就位,避开了浏览器原生 window.Storage 的坑,这个设计帮我省了不少排查时间。
  3. 博客初稿写得很完整。 结构清晰、有 emoji 点缀、还有流程图配图,我只需补充少量内容。

需要改进的地方:

  1. 提交 PR 之前如果能在本地也跑一遍单元测试或做一次编码检查,会减少合并后的返工。
  2. 部分交互细节(如「标记为已找到」按钮出现的时机)可以在 PR 描述里写得更明确,方便我这边对照测试。

陈果 评价 向飞燕(逻辑层)

值得学习的地方:

搭档在逻辑层的接口设计上考虑得很周到——校验返回结构化的 { valid, errors }、搜索对异常输入(null、非法值)都做了兜底,这让我写页面层时几乎不用操心边界情况。而且她写的 29 个单元测试覆盖很全,commit message 也一直保持 feat / refactor / test 的规范格式,代码提交记录清晰合理,这些都很值得我学习。

需要改进的地方:

建议以后在模块刚写完时,可以更早同步一版可运行的逻辑文件,而不是等全部完成再一起给,这样页面层能更早接入真实模块做联调,减少最后阶段集中修改的压力。另外接口如果有调整,及时在群里同步一声,能避免两边对不上。(这一点我们这次配合其实已经不错,只是个可以更好的小建议。)


🎬 十、小结

本次结对作业,我们从原型走到了可运行的代码,完成了“发布信息 → 浏览/搜索 → 查看详情 → 联系发布者 → 更新状态”的完整闭环,并通过 29 个单元测试与浏览器走查验证了功能 ✅。

我负责页面与交互部分(index.html、css、app.js),搭档负责逻辑层和单元测试。最大的收获是体会到了 “分层解耦 + 接口先行”对两人并行开发的价值:我们先约定好四个模块挂在 window 上的接口,我就能用临时 mock 先把页面跑通,搭档写完逻辑层后直接替换,互不阻塞,最后一次联调就通过了。

过程中也踩了一个印象很深的坑:浏览器原生自带全局 window.Storage,和我们的模块重名,导致一开始列表整个渲染不出来。排查后才意识到“判断模块是否加载不能只看对象在不在,得看具体方法”。这个教训让我对浏览器全局命名空间有了更具体的认识。另外,这次把 29 个单元测试跑通、再用浏览器逐条走查,让我真切感受到“测试不是走形式,而是真能提前发现问题”。如果下次再做,我会更早地和搭档对齐数据字段,减少后期联调的小摩擦。

过程中体会到分层解耦让两人并行开发成为可能,也认识到 PSP 记录与充分走查对发现返工点的价值。愿每一件失物都能早日回家 🏠。

posted @ 2026-10-08 17:46  XXXiris  阅读(5)  评论(0)    收藏  举报