2026 秋软件工程结对作业(第二次之程序实现)

从墨刀原型到真能跑的网页 —— 校园失物招领第二次结对作业

项目 内容
课程 2026-01 软件工程与软件工程实践班级博客
作业要求 2026 秋软件工程结对作业(第二次之程序实现)
作业目标 基于上次墨刀原型,结对实现失物招领 Web 程序。
学号 & 姓名 【102401139】张智文、【102401217】颜志航
结对同学博客链接 https://www.cnblogs.com/fzuyzh/p/23242437
本作业博客链接 https://www.cnblogs.com/platforzation/p/23230728
GitHub 项目地址 https://github.com/platforzation/102401139-102401217

一、具体分工

成员 承担内容 对应提交
张智文(platforzation) 项目初始化,搭建目录结构与 README 09bf83e
首页 UI 实现:列表卡片、渐变色块、底部导航,还原墨刀原型 0fcb231
核心功能:发布 / 搜索 / 详情 / 状态更新、localStorage 持久化、一键复制 8df8071
编写 19 个单元测试用例 1651aab
PR 复审合并 f1aa1ee
颜志航(Emrys-m) 搜索关键词高亮、无结果引导发布 4085556
修复搜索页返回后未重置状态的问题 61f9a9c
增加搜索历史记录与清空功能 5b834fc、1f91c4e
全面美化界面:渐变、阴影、圆角和动效 ee28f93
补充功能说明文档 64a7e66、bab8370
提交 Pull Request #1 PR #1

协作方式:我先搭好主干框架、数据层和首页 / 详情页面,把公共组件封装完毕;颜志航 Fork 主干后新建 feature/interaction-optimization 分支,专注做搜索体验优化(关键词高亮、搜索历史、无结果引导)和界面美化,写完提 PR;我做代码复审合并,合并后双方确认样式无冲突。

二、PSP 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 25
Estimate 估计这个任务需要多少时间 20 15
Development 开发 600 860
Analysis 需求分析(包括学习新技术) 40 70
Design Spec 生成设计文档 30 20
Design Review 设计复审 15 20
Coding Standard 代码规范(为目前的开发制定合适的规范) 10 10
Design 具体设计 50 80
Coding 具体编码 300 480
Code Review 代码复审(含 PR 合并与样式冲突修复) 30 60
Test 测试(自我测试,修改代码,提交修改) 60 110
Reporting 报告 60 80
Test Report 测试报告 20 30
Size Measurement 计算工作量 10 10
Postmortem & Process Improvement Plan 事后总结,并提出过程改进计划 25 30
合计 700 1050

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

3.1 代码实现思路

本项目采用 纯前端零依赖架构(原生 HTML + CSS + JavaScript,无框架、无构建工具),双击 index.html 即可在 Chrome 中运行,数据全部保存在浏览器 localStorage 中。

代码分为两层:

页面层 index.html:单页应用(SPA)风格,所有页面(首页 / 发布 / 搜索 / 详情 / 我的 / 发布成功)都在一个 HTML 里,通过 .screen 的 active 类切换显示,对应上次墨刀原型中的 6 个页面。

数据层 js/data.js:全项目唯一的数据入口,统一负责 localStorage 的读写。信息列表使用独立的存储 key(lost_found_items_v2),搜索历史使用另一个 key(lost_found_history_v1),通过版本号管理示例数据升级。所有业务函数(校验、搜索、筛选、状态更新、搜索历史增删)都写成纯函数,不依赖 DOM,方便单元测试。

交互层 js/app.js:负责页面路由、卡片渲染、表单提交、搜索、详情展示、状态更新、关键词高亮渲染、搜索历史管理等 UI 逻辑,通过 IIFE 封装避免全局污染。

3.2 流程图

image

3.3 有价值代码片段

3.3.1 数据校验(data.js)

发布表单有多个必填字段,校验函数集中收口,返回 {valid, msg} 结构,UI 层只需判断 valid 并 toast 错误信息:

function validateItem(d) {
  if (!d.title || !d.title.trim()) return { valid:false, msg:'请填写物品名称' };
  if (!d.place || !d.place.trim()) return { valid:false, msg:'请填写地点' };
  if (!d.time || !d.time.trim()) return { valid:false, msg:'请填写时间' };
  if (!d.contact || !d.contact.trim()) return { valid:false, msg:'请填写联系方式' };
  return { valid:true, msg:'' };
}

3.3.2 搜索与筛选(data.js)

搜索同时匹配标题、描述、地点三个字段,不区分大小写;类型筛选和搜索可以叠加使用:

function searchItems(items, kw) {
  if (!kw || !kw.trim()) return [];
  const k = kw.trim().toLowerCase();
  return items.filter(it =>
    it.title.toLowerCase().includes(k) ||
    (it.desc||'').toLowerCase().includes(k) ||
    it.place.toLowerCase().includes(k)
  );
}

function filterByType(items, type) {
  if (!type || type === 'all') return items.slice();
  return items.filter(it => it.type === type);
}

3.3.3 搜索关键词高亮(app.js)

搜索结果中命中的关键词用 <mark> 标签包起来高亮显示,让用户一眼看到匹配位置:

function highlight(text, kw) {
  if (!kw) return escapeHtml(text);
  const reg = new RegExp('(' + kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
  return escapeHtml(text).replace(reg, '<mark>$1</mark>');
}

先 escapeHtml 转义防止 XSS,再用正则替换命中的关键词,最后包上 <mark>。正则里对特殊字符做了转义,防止关键词带 .、* 等字符时报错。

3.3.4 搜索历史(data.js)

每次执行搜索就把关键词存进 localStorage,最多保留 10 条,新的排前面,重复的自动去重:

function addHistory(kw) {
  if (!kw || !kw.trim()) return;
  let h = getHistory().filter(x => x !== kw);
  h.unshift(kw);
  if (h.length > 10) h = h.slice(0, 10);
  localStorage.setItem(HISTORY_KEY, JSON.stringify(h));
}

搜索页顶部展示历史词云,点击直接搜索,旁边有 "清空" 按钮。

3.3.5 一键复制联系方式(app.js)

优先使用 Clipboard API,在 file:// 双击打开场景下自动降级为隐藏 textarea + execCommand,保证所有环境都能复制:

function copyContact() {
  const d = loadItems().find(x => x.id === currentDetailId);
  if (navigator.clipboard) {
    navigator.clipboard.writeText(d.contact).then(
      () => showToast('已复制联系方式'),
      () => fallbackCopy(d.contact)
    );
  } else fallbackCopy(d.contact);
}
function fallbackCopy(text) {
  const ta = document.createElement('textarea');
  ta.value = text; ta.style.position='fixed'; ta.style.opacity='0';
  document.body.appendChild(ta); ta.select();
  document.execCommand('copy');
  document.body.removeChild(ta);
}

四、附加特点设计与展示

4.1 用颜色传递语义,而不是只靠标签

我们没有给所有卡片用同一种颜色,而是给 "寻物" 和 "招领" 设计了两套渐变色块:

  • 寻物启事:红色渐变(#FCA5A5 → #EF4444)—— 红色本身带 "着急、寻找、紧急" 的情绪

  • 招领信息:绿色渐变(#6EE7B7 → #10B981)—— 绿色带 "希望、安心、物归原主" 的情绪

用户在列表里扫一眼,不用读文字就能凭颜色区分 "这是丢了东西的" 还是 "这是捡到东西的"。状态胶囊也用了同样的思路:进行中是黄色(#FEF3C7,等待中),已解决是灰色(#E5E7EB,事已了)。已解决的整张卡片还会自动降低透明度到 0.6,视觉上自动 "退到后面",不干扰有效信息。
image

4.2 单页应用:六个页面,零刷新

整个应用的六个页面(首页 / 发布 / 搜索 / 详情 / 我的 / 发布成功)都在一个 index.html 里,通过切换 .screen 的 active 类来显示。好处是:

  • 点 "发布" 再返回首页,列表数据还在,不用重新加载

  • 页面切换没有白屏,像真 App 一样流畅

  • 不需要路由库、不需要服务器,双击 HTML 就能跑

4.3 emoji 图标代替照片上传

原型里设计了 "上传物品照片" 的虚线框,但纯前端项目用 localStorage 存 base64 图片很快就会撑爆(一张图就几百 KB)。我们改成了12 个常用 emoji 图标选择器(💳🔑📱🎧☂️📚💧🧢🎒🪪🔌📦),发布时点一下就选好了。卡片上、详情页顶部都会显示对应的大图标,既轻量又直观,也避免了 "上传了一张无关照片" 的尴尬。
image

4.4 搜索体验三连:历史记录 + 关键词高亮 + 无结果引导

  • 搜索历史:每次搜索自动存词,最多 10 条,去重倒序排列。用户下次打开搜索页能直接点历史词云,不用重新打字。

  • 关键词高亮:搜索结果里命中的关键词用黄色 <mark> 标出来,用户扫一眼就知道为什么这条结果被匹配上了,不用自己在长描述里找。

  • 无结果引导发布:搜不到东西时不只是显示 "暂无结果",而是给一个 "我要发布这条信息" 的按钮,直接跳到发布页并自动把搜索词填进标题,把 "找不着" 的流量转化成 "我来发一条"。
    image


五、目录说明和使用说明

5.1 目录说明

lost-and-found/
├── index.html          # 主页面(单页应用,含首页/发布/搜索/详情/我的/成功页)
├── test.html           # 单元测试页(双击直接看测试报告)
├── README.md           # 项目说明
├── css/
│   └── style.css       # 全部样式(蓝色主题,移动端优先,最大宽度 420px 居中)
└── js/
    ├── data.js         # 数据层:localStorage 读写、校验、搜索、筛选、状态更新、搜索历史
    ├── app.js          # UI 交互:路由、卡片渲染、表单、搜索、详情、关键词高亮
    └── test.js         # 单元测试用例(19+ 个)

组织原则:数据与视图分离。data.js 只做数据操作,写成纯函数不依赖 DOM;app.js 只负责界面渲染和事件绑定。这样既符合 MVC 思想,也方便对 data.js 中的纯函数直接做单元测试。

5.2 测试人员如何运行

双击 index.html(用 Chrome 打开)
        ↓
  首页(自动加载 5 条示例数据)
        ↓
┌────────┬─────────┬──────────┐
↓        ↓         ↓          ↓
浏览列表  搜索     发布       查看详情
(tab切换  (点顶部  (点底部    (点任意卡片
 寻物/招领)搜索框) 中间+号)   可复制联系方式)
        ↓         ↓          ↓
              填写表单    我的发布
              选图标      (底部"我的")
              填信息        ↓
              提交成功    标记已找到/已归还

本项目为纯静态网页,进入 lost-and-found 目录,用 Google Chrome 双击 index.html 即可启动,无需安装任何环境。首次打开会自动预置 5 条示例数据,可直接浏览、搜索、发布。


六、单元测试

6.1 测试工具的选用

本项目为纯前端 JavaScript 项目(无后端、无构建工具),我们没有引入 Mocha/Jest 等重型框架,而是自己写了一个极简浏览器端测试框架:打开 test.html 自动执行所有用例,页面上直接显示通过 / 失败结果。

工具 作用 选择理由
原生断言 assert(condition, name) 零依赖,双击 HTML 就能跑,符合作业 "双击 HTML 即可运行" 的要求
浏览器直接运行 test.html 加载 data.js + test.js 不需要 Node.js、npm,测试同学打开就能看结果

学习过程:我们参考了廖雪峰的 JavaScript 教程和阮一峰的 Mocha 教程,理解了单元测试的基本思想(断言、用例组织、白盒覆盖)。考虑到本项目要求 "双击 HTML 就能跑",引入 Mocha 需要 Node 环境反而增加测试同学的负担,因此选择了零依赖方案。

6.2 测试用例展示

我们对 data.js 中的核心纯函数设计了 19 个测试用例,覆盖校验、搜索、筛选、状态更新等:

// validateItem:必填字段校验
eq(validateItem({title:'', place:'x', time:'t', contact:'c'}).valid, false, 'T1 空名称应报错');
eq(validateItem({title:'x', place:'', time:'t', contact:'c'}).valid, false, 'T2 空地点应报错');
eq(validateItem({title:'x', place:'x', time:'t', contact:''}).valid, false, 'T4 空联系方式应报错');
eq(validateItem({title:'x', place:'x', time:'t', contact:'c'}).valid, true,  'T5 合法数据应通过');

// searchItems:搜索
eq(searchItems(sample,'校园卡').length, 1, 'T6 按名称搜索');
eq(searchItems(sample,'airpods').length, 1, 'T7 大小写不敏感');
eq(searchItems(sample,'图书馆').length, 1, 'T8 按地点搜索');
eq(searchItems(sample,'不存在xyz').length, 0, 'T10 无匹配返回空');

// filterByType:类型筛选
eq(filterByType(sample,'lost').length, 2, 'T11 筛寻物');
eq(filterByType(sample,'found').length, 1, 'T12 筛招领');

// updateStatus:状态更新
assert(updateStatus(it.id,'done') === true, 'T17 updateStatus成功');
assert(updateStatus('notexist','done') === false, 'T19 不存在id返回false');

6.3 测试用例汇总与 "刁难" 考虑

编号 被测函数 用例类型 测试数据 预期结果
T1 validateItem 反例 名称为空 报错
T2 validateItem 反例 地点为空 报错
T3 validateItem 反例 时间为空 报错
T4 validateItem 反例 联系方式为空白 报错
T5 validateItem 正例 全部字段合法 通过
T6 searchItems 正例 "校园卡" 匹配 1 条
T7 searchItems 边界 "airpods"(小写) 匹配 AirPods
T8 searchItems 正例 "图书馆"(地点) 匹配 1 条
T9 searchItems 边界 空关键词 返回空
T10 searchItems 反例 不存在的词 返回空
T11 filterByType 正例 lost 筛出 2 条
T12 filterByType 正例 found 筛出 1 条
T13 filterByType 边界 all 返回全部
T14 genId 刁难 连续生成 100 个 全部唯一
T15 addItem 正例 新增一条 能读到
T16 addItem 正例 新增条目 状态为 open
T17 updateStatus 正例 存在的 id 返回 true
T18 updateStatus 正例 更新后 状态变 done
T19 updateStatus 刁难 不存在的 id 返回 false

如何应对测试人员的刁难:

  1. 空输入:名称 / 地点 / 联系方式为空时 toast 提示,不报错;

  2. 大小写:搜索 "airpods" 和 "AirPods" 都能匹配;

  3. 不存在的 id:直接调用 updateStatus 传不存在的 id,返回 false 不崩溃;

  4. 疯狂发布:连续发布多条,列表正确追加;

  5. 重复操作:状态可以在 open/done 之间来回切换,不会出错。


七、GitHub 签入记录

image


八、遇到的问题及解决方法

问题描述:

  1. 上次墨刀原型是在一个手机外壳里展示的高保真 demo,所有数据都是写死在 JS 里的假数据。这次要变成真能用的网页,需要把静态数据换成 localStorage 动态数据,但一开始不知道怎么把原型里的多个 "页面" 在一个 HTML 里切换。

  2. 队友提 PR 合并后,出现了样式冲突:队友在 style.css 里加了渐变和阴影效果,和主干已有的卡片样式重叠,导致首页列表卡片圆角不一致。

  3. 搜索页返回首页后,再点进搜索页,输入框还留着上次的关键词,搜索结果却被清空了,状态不一致。

做过哪些尝试:

  1. 一开始想做成多个 HTML 文件(index.html/publish.html/detail.html),但这样每个页面都要重复写头部、底部导航,代码冗余。后来参考原型里的 .screen 切换思路,改成单页应用,用 display: none/flex 控制显示哪个页面。

  2. 合并 PR 后双方走查首页,逐卡片对比圆角和阴影,定位到是队友新加的 .card 样式和主干原有的 .lost-card 选择器优先级冲突。把样式统一到一个类下,删掉重复定义。

  3. 在 showScreen('search') 函数里每次进入搜索页都重置输入框和结果列表,保证进入搜索页时状态干净。

是否解决:

都已解决。

有何收获:

理解了单页应用(SPA)的基本原理 —— 本质上就是所有页面都在一个 HTML 里,通过 CSS 控制显隐,JS 模拟路由。也体会到了代码复审的重要性:队友提 PR 时自己没注意样式冲突,合并后走查才发现,以后合并前要先在本地拉下来跑一遍。


九、评价你的队友

值得学习的地方:

队友对搜索体验的细节很敏感,主动加了关键词高亮、搜索历史、无结果引导发布这三个功能,还顺便把界面做了一轮美化(渐变、阴影、动效),比我只想着 "功能跑通就行" 想得更远。Git 操作也很熟练,自己建分支、提 PR、写 commit message,流程规范。

需要改进的地方:

合并 PR 后暴露出样式冲突,说明他在自己分支上改样式时没有对照主干最新的 CSS,以后合并前应该先 pull 主干最新代码跑一遍再提 PR。

posted @ 2026-10-09 15:32  platforzation  阅读(8)  评论(0)    收藏  举报