软件工程第二次结对作业

校园失物招领 WEB —— 第二次结对作业:程序实现

项目 内容
这个作业属于哪个课程 202601 软件工程
这个作业要求在哪里 2026秋软件工程结对作业(第二次之程序实现)
这个作业的目标 将校园失物招领原型实现为可运行的 Web 应用,完成核心业务流程,并通过 GitHub 协作、PSP 和单元测试实践结对开发、版本管理与软件测试。
设计原型稿与在线演示 墨刀原型链接
GitHub 仓库 102401405-102401406
结对成员 102401405王怡婷、102401406陈婉凤
本作业博客链接 https://www.cnblogs.com/yitt/p/23243165
结对同学博客链接 https://www.cnblogs.com/hz-hf1/p/23243129


一、具体分工

成员 分工
王怡婷(学号102401405) 视图层 app.js 与样式实现、首页多条件筛选与美化、浏览器走查与 bug 修复、博客撰写
陈婉凤(学号102401406) 需求分析与原型回顾、数据层 core.js 编写、单元测试用例设计、README 文档 、bug 修复、博客撰写

两人共同确认功能边界、划分 commit 粒度,并在关键节点互相走查代码。通过 fork + Pull Request 的方式提交了滚动回顶、详情页图标、“我的发布”过滤自己等修复。


二、PSP 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 20 15
Estimate 估计这个任务需要多少时间 15 10
Development 开发 540 590
Analysis 需求分析(包括学习新技术) 40 30
Design Spec 生成设计文档 30 25
Design Review 设计复审 20 20
Coding Standard 代码规范(为目前的开发制定合适的规范) 15 20
Design 具体设计 45 40
Coding 具体编码 240 280
Code Review 代码复审 30 35
Test 测试(自我测试,修改代码,提交修改) 120 130
Reporting 报告 90 100
Test Report 测试报告 40 45
Size Measurement 计算工作量 10 10
Postmortem & Process Improvement Plan 事后总结,并提出过程改进计划 40 45
合计 665 715

偏差分析:实际总耗时比预估多约 50 分钟,主要超在 "具体编码" 和 "测试" 两个环节。编码阶段因为浏览器缓存导致修改后看不到效果、反复排查浪费了时间;测试阶段发现 "我的发布" 页把所有人的种子数据都显示出来了,需要新增归属过滤逻辑,属于设计时未预料到的边界情况。后续应在编码前更充分地考虑数据归属问题,并在 HTML 中给 JS 引用加版本号避免缓存干扰。

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

3.1 代码实现思路

延续第一次作业的墨刀原型,本次用原生 HTML + CSS + JavaScript 实现一个单页应用(SPA),不引入任何前端框架、不需要后端服务器、不依赖外部 CDN,所有数据存在浏览器 localStorage 里。测试人员下载全部文件后,用谷歌浏览器双击打开 index.html 就能直接运行,不需要装 Node、不需要配环境。

代码按职责分成三层,逻辑层与渲染层分离是本次设计的核心:

视图层 app.js
  ├── hash 路由(#/首页、#/搜索、#/发布、#/详情、#/我的发布)
  ├── 模板渲染(把数据拼成 HTML 字符串塞进页面)
  └── 事件委托(统一处理按钮点击、表单提交)
        ↓ 调用
逻辑层 core.js(全部是纯函数,不碰 DOM)
  ├── createItem      创建物品(自动生成 id、去空格、默认状态 open)
  ├── validatePublish 发布表单校验(逐字段返回错误)
  ├── filterItems     多条件联合筛选(类型/类别/地点/状态/关键词)
  ├── updateStatus    状态更新(open ↔ done ↔ draft)
  ├── calcStats       统计计算(全部/待解决/已解决/解决率)
  └── formatTime      时间格式化(今天 11:23 / 昨天 / 3天前)
        ↓ 读写
存储层 storage.js
  ├── localStorage 读写封装
  ├── 8 条种子演示数据
  └── 搜索历史记录

这样分层有三个好处:

  1. core.js 全是纯函数,不碰 DOM、不碰浏览器 API,浏览器加载它能跑,Node 跑单元测试也能直接 require,一套逻辑两端复用;
  2. 视图和逻辑解耦,改页面样式不会影响业务规则,调筛选逻辑也不用动 HTML 结构;
  3. 数据是唯一权威来源,所有页面每次渲染都从 storage.loadItems() 重读数据,天然一致,不会出现 A 页改了 B 页没同步的问题。

安全方面,所有用户输入都经过 esc() 转义后再插入页面,防止 XSS 注入。

3.2 关键流程图

系统数据流图:

a6e99b70b787f76e95b5fc6655a04510

发布信息流程:
21b6f666c34207ecaac5e6efb255e3d8

状态更新闭环:
02a4e740802898719c0178d1ed625891

3.3 重要代码片段

片段一:多条件联合筛选(core.js 的 filterItems)——这是首页查询核心,类型、类别、地点、状态、关键词五个条件 AND 叠加:

function filterItems(items, opts) {
  var o = opts || {};
  var keyword = String(o.keyword || '').trim().toLowerCase();
  var list = (items || []).filter(function (it) {
    if (o.type !== 'all' && it.type !== o.type) return false;
    if (o.category !== 'all' && it.category !== o.category) return false;
    if (o.location !== 'all' && it.location !== o.location) return false;
    if (o.status !== 'all' && it.status !== o.status) return false;
    return true;
  });
  if (keyword) {
    list = list.filter(function (it) {
      var hay = [it.title, it.location, it.place, it.contactName, it.desc]
        .filter(Boolean).join(' ').toLowerCase();
      return hay.indexOf(keyword) !== -1;
    });
  }
  return list;
}

解释:先按四个下拉筛选条件做硬过滤,再做关键词模糊匹配。关键词会同时匹配标题、地点、具体位置、发布者昵称和描述五个字段,转小写后忽略大小写。所有条件都是"不满足就排除",天然实现 AND 叠加。
片段二:状态更新(core.js 的 updateStatus)——返回新对象不修改原对象,保证所有页面读同一份数据自动同步:

function updateStatus(item, status) {
  if (!item || (status !== STATUS_OPEN && status !== STATUS_DONE && status !== STATUS_DRAFT)) return null;
  if (item.status === status) return item;
  return {
    id: item.id, type: item.type, category: item.category,
    title: item.title, desc: item.desc, location: item.location, place: item.place,
    happenedAt: item.happenedAt, contact: item.contact, contactName: item.contactName,
    image: item.image || '', mine: item.mine === true,
    status: status, createdAt: item.createdAt, updatedAt: Date.now()
  };
}

解释:先做参数校验(空对象、非法状态直接返回 null),如果状态没变就原样返回不浪费性能。关键是返回一个全新对象而不是改原对象——这样所有持有旧对象的地方不会意外被污染。
片段三:一键复制(app.js 的 copyText)——优先用 Clipboard API,失败降级 execCommand:

function copyText(text) {
  function fallback() {
    var ta = document.createElement('textarea');
    ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
    document.body.appendChild(ta); ta.select();
    var ok = false;
    try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
    document.body.removeChild(ta);
    return ok;
  }
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(text).then(
      function () { toast('已复制:' + text); },
      function () { toast(fallback() ? '已复制' : '复制失败,请手动复制'); }
    );
  } else {
    toast(fallback() ? '已复制' : '复制失败,请手动复制');
  }
}

解释:现代浏览器优先用 navigator.clipboard.writeText,但这个 API 需要 HTTPS 或 localhost 环境,我们是 file:// 打开的可能不支持,所以做了降级方案——创建一个隐藏的 textarea,把内容塞进去选中,用老的 execCommand('copy') 复制。两条路径最后都弹 Toast 告诉用户结果。

四、附加特点设计与展示

4.1 创意独到之处

在作业要求的基础功能(发布、浏览、搜索、详情、更新状态)之外,我们额外做了以下几个提升使用体验的设计:

1. 首页多条件联合筛选

  • 作业要求只提到"通过物品名称等关键词搜索",我们在首页加了类型(寻物/招领)、类别(证件卡类/电子设备/钥匙饰品/生活用品/其他)、地点(教学楼/宿舍区/食堂/图书馆/运动场/其他)、状态(未解决/已解决)四个筛选维度,和关键词搜索叠加,AND 联合过滤。
  • 意义:找东西的同学可以快速缩小范围,比如只想看"图书馆的寻物",不用一条条翻。

2. 顶部统计面板实时联动

  • 首页顶部四个蓝色方块显示全部信息数、待解决数、已解决数、解决率,筛选条件一变数字跟着变。
  • 意义:一眼掌握校园失物整体情况,哪些地方丢东西多、哪些类型丢得频繁。

3. 一键复制联系方式

  • 详情页点一下按钮就复制发布者的手机号/QQ,自动降级兼容旧浏览器,复制成功弹出提示。
  • 意义:避免手动抄号码出错,比让用户自己记数字再打过去方便很多。

4. 搜索历史与热门搜索

  • 搜索页自动记录用户搜过的关键词,可一键清除;下面提供热门搜索词(校园卡、雨伞、钥匙等),点一下直接搜,不用打字。
  • 意义:丢了常见物品的同学直接点热门词就行,降低使用门槛。

5. 相对时间显示

  • 列表和详情页显示"今天 11:23""昨天 16:23""3 天前"这样的相对时间,而不是冷冰冰的日期数字。
  • 意义:用户一眼能判断这条信息是新是旧,不用自己算。

6. 存为草稿

  • 发布时可以先存草稿,只填物品名称就行,不用一次填完所有信息;草稿只有自己能看到,不会出现在首页和搜索里。
  • 意义:捡到东西时可能一时想不起具体地点,可以先记下来,回头补全再发布。

7. 物品图片上传(可选)

  • 发布时可以拍一张或选一张物品照片上传,有图的物品在列表左侧显示圆形缩略图,详情页显示大图;没上传的自动显示对应类别的 emoji 图标。
  • 意义:有图比文字描述准确得多,捡到相似物品时能快速辨认。

4.2 实现思路

  • 多条件筛选:filterItems 函数依次按类型、类别、地点、状态做硬过滤,关键词在标题、地点、描述、昵称里模糊匹配(忽略大小写),五个条件天然 AND 叠加;
  • 统计联动:首页渲染前先调 calcStats 算一遍当前筛选结果的统计数字,再渲染视图,筛选条件变了重新算就行;
  • 一键复制:优先用现代浏览器的 navigator.clipboard.writeText,file:// 环境下不可用时降级用 document.execCommand('copy'),两条路径都弹 Toast;
  • 搜索历史:每次搜索成功后把关键词存进 localStorage,最多保留 8 条,去重;热门搜索写死在代码里(校园卡、钥匙、雨伞、耳机这些高频物品);
  • 相对时间:formatTime 根据发布时间和今天零点的差值,判断显示"今天/昨天/N天前/几月几日";
  • 存草稿:草稿状态是 status: 'draft',首页和搜索过滤时默认排除 draft,只有"我的发布"显示;草稿发布时把 status 改成 open;
  • 图片上传:用 FileReader 读成 base64 存进 item.image,列表左侧和详情页用 <img> 显示;没图的显示对应类别的 emoji;

4.3 有价值的代码片段

代码一:相对时间格式化——把时间戳转成"今天/昨天/N天前"的友好格式:

function formatTime(ts) {
  var d = new Date(ts);
  var today = new Date(); today.setHours(0,0,0,0);
  var dayStart = new Date(d); dayStart.setHours(0,0,0,0);
  var days = Math.round((today - dayStart) / 86400000);
  var time = (d.getHours()<10?'0':'')+d.getHours() + ':' + (d.getMinutes()<10?'0':'')+d.getMinutes();
  if (days === 0) return '今天 ' + time;
  if (days === 1) return '昨天 ' + time;
  if (days < 7) return days + ' 天前';
  return (d.getMonth()+1) + '月' + d.getDate() + '日';
}

解释:把两个日期都归零到零点,再算天数差。同一天显示"今天 HH:MM",昨天显示"昨天 HH:MM",一周内显示"N天前",更早的显示几月几日。这样列表里的时间一眼就能看出新旧。

代码二:搜索历史记录——每次搜索存历史,去重,最多 8 条:

function pushHistory(keyword) {
  keyword = String(keyword||'').trim();
  if (!keyword) return;
  var arr = loadHistory();
  arr = arr.filter(function(k){ return k !== keyword; });  // 去重
  arr.unshift(keyword);
  if (arr.length > 8) arr = arr.slice(0, 8);                // 最多8条
  saveHistory(arr);
}

解释:每次搜索先把旧历史里相同的关键词删掉,再把新关键词插到最前面,超过 8 条就截断。这样历史记录永远是最近搜过的,最新的在最上面,不会重复。

代码三:统计计算——根据筛选结果算全部/待解决/已解决/解决率:

function calcStats(items) {
  var total = items.length;
  var open = items.filter(function(it){ return it.status === STATUS_OPEN; }).length;
  var done = items.filter(function(it){ return it.status === STATUS_DONE; }).length;
  var rate = total > 0 ? Math.round(done / total * 100) : 0;
  return { total: total, open: open, done: done, rate: rate + '%' };
}

解释:遍历一遍当前列表,数 open 和 done 的数量,解决率 = done / total × 100%。筛选条件一变,列表变了,统计数字跟着重算,实时联动。

4.4 实现成果展示

1.首页截图:多条件筛选+统计联动

2.详情页截图:一键复制+相对时间

3.搜索页截图:搜索历史

4.发布页截图:存草稿+图片上传

五、目录说明和使用说明

5.1 目录组织

校园失物招领WEB/
├── index.html          # 唯一入口,打开即可使用(SPA,hash 路由)
├── css/
│   └── style.css       # 全部样式
├── js/
│   ├── core.js         # 数据层核心纯函数(浏览器/Node 双端可用)
│   ├── storage.js      # localStorage 持久化 + 种子数据 + 搜索历史
│   └── app.js          # 视图渲染与交互(路由、五个页面、事件委托)
└── README.md           # 使用说明

5.2 测试人员如何运行

  1. 下载全部文件(或 git clone 仓库);
  2. 用谷歌浏览器双击打开 index.html,首次打开自动生成 8 条演示数据;
  3. 体验流程:
    • 首页浏览/筛选 → 点卡片看详情 → 一键复制联系方式;
    • 底部"发布"填表单 → 立即发布 或 存为草稿;
    • 搜索页输入关键词或点热门标签;
    • "我的发布"中标记已找到/已归还、重新开放、删除。
  4. 重置数据:F12 → Application → Local Storage → 删除 clf_items_v4 后刷新。

详细说明见项目内 README.md。


六、单元测试

6.1 测试工具与学习过程

选用 Node.js 内置 node:test + assert:零依赖、无需 npm install,装好 Node 一条命令就能跑。

学习方式:先看 Node 官方 test runner 文档,再对照廖雪峰和阮一峰的单元测试入门文章,理解"断言驱动"后上手。

简易教程:

# 1. 安装 Node.js(≥ 18)
# 2. 写测试:
const { test } = require('node:test');
const assert = require('node:assert/strict');
const C = require('../js/core.js');

test('用例名', () => {
  assert.equal(C.某函数(输入), 预期值);
});

# 3. 运行:node --test

6.2 部分测试代码

// 测试 createItem:字段正确、id 唯一、自动去空格
test('createItem:字段正确生成、id 唯一、文本去除首尾空格', () => {
  const a = C.createItem({ type: 'found', title: '  校园卡  ', location: ' 教学楼 ', contact: ' 13850001234 ' }, NOW);
  assert.equal(a.title, '校园卡');  // 首尾空格被去掉
  assert.equal(a.status, C.STATUS_OPEN);  // 新建默认进行中
  const b = C.createItem({ type: 'lost', title: 'x', location: 'y', contact: 'z' }, NOW);
  assert.notEqual(a.id, b.id);  // id 不重复
});

// 测试 filterItems:关键词搜索 + 多条件组合
test('filterItems:关键词匹配标题(忽略大小写)', () => {
  const items = [mkItem({ title: '校园卡' }), mkItem({ title: '雨伞' })];
  assert.equal(C.filterItems(items, { keyword: '校园卡' }).length, 1);
});

// 测试 updateStatus:状态切换正确,原对象不被修改
test('updateStatus:open → done,原对象不变', () => {
  const it = mkItem({ status: 'open' });
  const updated = C.updateStatus(it, 'done');
  assert.equal(updated.status, 'done');
  assert.equal(it.status, 'open', '原对象不应被修改');
});

6.3 构造测试数据的思路

采用白盒测试,从核心函数每个分支和边界出发:

  • 正常输入:合法表单、正常搜索、类型/类别/状态过滤
  • 边界输入:名称正好 30 字 / 31 字、时间跨今天/昨天/N天前
  • 非法输入:空表单、非法类型、联系方式格式不对
  • 破坏性输入(防刁难):localStorage 被写入非法 JSON、值不是数组——程序要能回退到种子数据不崩溃
  • 不可变约定:updateStatus、removeItem 不能修改原对象,测试里显式断言

总共 29 个用例全部通过,覆盖核心函数主要分支和边界,满足本程序回归测试需求。


七、GitHub 签入记录

c9af75e119dd65199607cfc8b0aad1b6

d5441ad2185a82a8165a48c5a50c09fe


八、遇到的问题与解决

问题 1:搜索页输入时输入框失焦

  • 描述:最初每次输入都重建整个搜索页,输入框被重新创建,光标丢失,没法连续打字。
  • 尝试:调整渲染时机、加防抖,都没根治。
  • 解决:把搜索结果区和建议区独立成容器,输入事件只更新结果区,不重建整页。
  • 收获:视图更新要"最小化渲染",先想清楚哪些区域需要变再动手。

问题 2:updateStatus 丢失 mine 字段

  • 描述:标记为已完成的"我的发布"条目突然从列表消失了。排查发现 updateStatus 返回新对象时手动列字段,漏了 mine: true,导致已完成的条目被当成别人发的。
  • 尝试:一开始以为是数据问题,反复改种子数据没用。
  • 解决:在 updateStatus 返回对象里补上 mine: item.mine === true。
  • 收获:手动列举字段很容易漏,以后改数据结构时要全局搜一遍哪些地方在复制对象。

问题 3:队友 PR 合并冲突

  • 描述:队友 fork 后改了 app.js 的滚动逻辑,我同时改了筛选逻辑,pull 时冲突。
  • 尝试:先 pull 再改。
  • 解决:本地 merge,逐段对比保留两边的改动,然后 push。
  • 收获:两人协作要勤 pull,改大文件前先同步最新代码。

问题 4:删除确认弹窗显示 "null"

  • 描述:点击 "删除" 按钮后,确认弹窗中显示 "null" 删除后不可恢复,而不是物品标题。

  • 尝试:检查删除按钮的 HTML、 检查事件处理代码 —— 发现读取的是 data-value 属性、 属性名不匹配导致 val 为 null,拼接后显示 "null"

  • 解决:改为在处理函数中通过 id 调用 C.getItemById() 查回物品对象,取其 title 字段显示。这样不依赖 HTML 上的属性传递,更可靠。

  • 收获:通过 data-* 属性在 HTML 和 JS 之间传递数据时,属性名必须严格一致。更健壮的做法是只传 id,数据从存储层查取,避免 HTML 中冗余存储和转义问题。


九、评价队友

陈婉凤值得学习的地方

  1. 架构设计能力强:队友一开始就确定了 core/storage/app 三层分离的架构,并且让 core.js 支持 UMD 双端导出,为单元测试打好了基础。这种 "可测试性优先" 的设计意识值得学习。

  2. 代码规范意识好:队友的 commit message 统一使用 feat:/fix:/style: 标签,每次提交聚焦一个功能,review 起来很清晰。

  3. UI 审美在线:全局界面美化(统计卡片、筛选组件、列表排版)都是队友做的,蓝白配色简洁清爽,卡片阴影和圆角处理到位。

陈婉凤需要改进的地方

  1. 沟通可以更及时:有时候队友直接 push 了新功能没有提前说,导致我本地 fork 落后,合并时需要先 pull 再改。建议后续有大改动前先在群里同步一声。

  2. 测试覆盖可以更充分:队友开发的筛选和统计模块没有配套的单元测试,后续可以补充 filterItems 和统计函数的测试用例。

posted @ 2026-10-09 22:58  yitt  阅读(20)  评论(0)    收藏  举报