软件工程第二次结对作业:校园失物招领的程序实现
| 这个作业属于哪个课程 | H202601软件工程与软件工程实践 |
|---|---|
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次) |
| 这个作业的目标 | 基于前期校园失物招领原型,采用结对编程完成 Web 前端开发,实现发布信息 — 浏览 / 搜索 — 查看详情 — 联系发布者 — 更新物品状态完整业务闭环;完善异常场景交互,记录开发工时与测试过程,完成项目博客撰写。 |
| GitHub项目地址 | 校园失物招领 |
| 结对同学博客链接 | 雷葭雨博客 |
| 参与人员1 | 102401607 梁倩 |
| 参与人员2 | 112400810雷葭雨 |
一、具体分工
结对编程分工表(校园失物招领 · 纯前端交互原型)
| 人员 | 负责模块 | 具体工作内容 |
|---|---|---|
| 雷葭雨 | 页面结构与交互逻辑 | 1. 搭建页面基础框架:首页列表页、发布信息页、物品详情页 2. 实现发布表单:区分【寻物启事】【招领启事】,录入物品名称、描述、联系方式等信息 3. 详情页交互:展示物品全部信息、联系方式;状态切换按钮(标记已找到/已归还) 4. 页面之间跳转逻辑,保证「发布→浏览→详情→修改状态」完整流程 |
| 梁倩 | 模拟数据、搜索功能、UI美化和原型测试 | 1. 使用JS数组在前端模拟临时数据 2. 实现关键词搜索功能:根据物品名称过滤展示列表 3. 页面UI美化,统一布局、配色,优化交互体验 4. 原型整体测试,检查流程bug,审查代码,提出优化意见 |
二、PSP表格
参照作业要求中示例一的小伙伴绘制既有数据也有图示的记录:

造成预估耗时与实际耗时差异的原因主要为
- 前期预估偏乐观,低估细节工作量:预估阶段只考虑了核心功能(发布、浏览、搜索、详情页状态修改),忽略很多细节交互:表单输入校验、页面样式对齐、列表渲染、状态标记切换的边界情况。这些细碎调试工作没有纳入预估,造成计划、分析、设计、编码与测试阶段时间超出预期。
- 需求理解与设计复审消耗额外时间:结对讨论时,两人对于页面交互功能逻辑存在一定分歧,在设计复审环节需要反复沟通、调整页面流转逻辑,讨论时间超出预估。同时在生成设计文档时,补充页面草图、数据结构描述,花费了更多时间。
- 调试排错时间不可预判:JS 模拟数组数据、关键词搜索过滤功能出现 bug,比如搜索关键词大小写、空输入异常、物品状态更新后列表没有同步刷新,定位与修复问题需要额外时间,测试阶段工时增加。
- 结对编程角色切换带来少量时间开销:两人交换优化后代码时,需要交接代码思路、互相讲解逻辑,该沟通成本前期预估不足;但结对的代码复审环节也减少了后期重大 bug,控制了整体超时幅度。
三、解题思路描述与设计实现说明
1.1 现实困扰
WEB解决的核心问题是:把分散、易丢失、难触达的寻物 / 招领信息集中到一个平台,并通过 "智能匹配" 主动让失主与拾获者 "相遇",而不是被动等待。
1.2 代码实现思路
我们先把系统拆成一条完整的用户闭环:
浏览现有信息 → 搜索或拍照查找 → 没找到则发布
→ 系统自动匹配 → 查看匹配依据 → 联系对方
→ 物品归还/找到 → 标记完成 → 感谢与积分
在此基础上再拆分为数据层、页面导航层和功能层:
- 数据层保存当前会话的物品、消息、积分和搜索历史。
- 导航层负责页面进入、返回、底部标签和通用卡片。
- 功能层分别处理发布、识图、匹配、搜索、详情、聊天和感谢信。
- 启动层只负责页面初始化、时间、网络和电池等状态。
这种拆分避免将全部代码继续堆在一个 HTML 文件中,也方便其他测试人员按功能定位问题。
2. 关键实现的流程图
图 1:系统总体架构与数据流
数据整体按“用户操作—业务处理—数据更新—页面渲染”的方向流动:用户操作经路由层进入业务逻辑层,逻辑层读写 DATA,再通过渲染函数把结果反映到 DOM,使视图始终由当前数据状态生成。
图 2:页面路由与跳转关系
pageStack 保存用户访问过的页面,navigate() 进入新页面,backPage() 返回上一层,switchTab() 负责底部主导航切换。消息、搜索结果和“我的”中的物品都复用同一个详情页。
图 3:发布信息数据流

表单只有在类型、名称、类别、地点、时间和联系方式全部通过校验后才会生成数据。新信息写入 DATA 后立即与相反类型的信息匹配,匹配成功时双方记录都会保存关联,并生成消息提醒。
图 4:拍照识物数据流

OCR 用于发现卡号、书名、品牌等文字,Canvas 提供颜色和形状线索;拍照搜索还会使用 MobileNet 中间特征比较图片。模型不可用时退回本地图像指纹,避免主流程中断。
图 5:智能匹配打分流程

3. 重要代码片段与解释
片段 1:页面栈路由——实现原生 App 式前进与返回动画
let pageStack = ['page-home'];
function showPage(id, anim){
const page = $(id);
document.querySelectorAll('.page').forEach(p=>
p.classList.remove('active','slide-in','slide-back','fade-in')
);
page.classList.add('active');
if(anim==='slide') page.classList.add('slide-in');
else if(anim==='fade') page.classList.add('fade-in');
}
function navigate(id, anim){
pageStack.push(id);
showPage(id, anim||'slide');
}
function backPage(){
if(pageStack.length<=1){ switchTab('home'); return; }
const cur = pageStack.pop();
const target = pageStack[pageStack.length-1];
$(cur).classList.add('slide-back');
$(target).classList.add('active');
setTimeout(()=>$(cur).classList.remove('active','slide-back'),280);
}
function switchTab(name){
pageStack = ['page-'+name];
showPage('page-'+name,'fade');
}
pageStack 保存当前页面的访问路径。进入详情、搜索或发布页时,navigate() 把页面编号压入栈并播放向左滑入动画;点击返回时,backPage() 弹出栈顶页面,恢复前一页并播放向右退出动画。切换底部主标签时则重新建立栈,防止从“我的”返回到已经无效的搜索层级。整个路由不依赖第三方框架,却保留了接近原生 App 的前进、返回和淡入体验。
片段 2:列表卡片统一渲染——首页、搜索和“我的”复用
function cardHTML(item){
const isLost = item.type==='lost';
const done = item.status==='done';
const thumb = item.img
? `<div class="info-thumb ${done?'thumb-done':''}">
< img src="${item.img}" alt="">
</div>`
: `<div class="info-thumb ${isLost?'thumb-lost':'thumb-found'}
${done?'thumb-done':''}">${item.emoji}</div>`;
return `<div class="info-card" onclick="goDetail(${item.id})">
${thumb}
<div class="info-main">
<div class="info-tags">
<span class="tag-type ${isLost?'tag-lost':'tag-found'}">
${isLost?'寻物':'招领'}
</span>
${done?'<span class="tag-status done">已完成</span>'
:'<span class="tag-status">进行中</span>'}
${isLost&&item.reward>0
?'<span class="tag-reward">悬赏 ¥'+item.reward+'</span>':''}
</div>
<div class="info-name">${item.name}</div>
<div class="info-sub">${item.place} · ${item.pubTime}</div>
</div>
</div>`;
}
// 不同页面只负责筛选数据,卡片结构由同一个函数生成
// renderHome:list.map(cardHTML)
// doSearch:results.map(cardHTML)
// renderMine:list.map(cardHTML)
首页、普通搜索、拍照搜索和“我的”页面展示的都是同一种物品信息,因此统一使用 cardHTML()。函数根据图片、寻物/招领类型、完成状态和悬赏金额自动组合缩略图与标签。各页面只负责筛选数据,不再复制卡片 HTML;以后修改标签样式或增加字段时,只需要修改一个函数即可保证所有列表一致。
片段 3:关键词搜索——多字段匹配与结果高亮
function doSearch(){
const kw = $('search-input').value.trim();
if(!kw){ toast('请输入要搜索的内容'); return; }
searchHistory = [kw]
.concat(searchHistory.filter(h=>h!==kw))
.slice(0,8);
const results = DATA.filter(d=>
d.name.includes(kw) ||
d.category.includes(kw) ||
d.place.includes(kw) ||
d.desc.includes(kw)
);
$('result-list').innerHTML = results.length
? results.map(item=>{
let card = cardHTML(item);
return card.replace(
item.name,
`<span class="kw-hl">${item.name}</span>`
);
}).join('')
: emptyHTML('没有找到相关信息,换个关键词试试');
}
搜索不是只检查物品名称,而是同时匹配名称、分类、地点和详细描述。例如搜索“京元餐厅”可以找到名称中没有“餐厅”但地点符合的信息。搜索词会去重后加入最近历史,并只保留最近 8 条;有结果时继续复用 cardHTML(),再给物品名称包裹 kw-hl 高亮样式,让用户能够快速定位结果。无结果时显示统一空状态,而不是留下空白页面。
四、附加特点设计与展示
这一部分选择四个直接服务于“更快找回失物”和“鼓励校园互助”的特色功能进行展示。
4.1 拍照识物——上传图片自动识别物品类别
创意与意义
发布失物信息时,用户经常不知道应该选择“数码电子”“校园卡证件”还是“其他”。拍照识物允许用户直接上传照片,系统通过 OCR 读取卡面、书名、品牌等文字,再结合图片颜色和外观特征推断类别并自动填入表单。它减少了手动输入步骤,也使后续搜索和匹配获得更统一的数据。
实现思路
图片先经 FileReader 转为页面可处理的数据地址;runRecognize() 并行考虑 OCR 文字和图像基础特征;inferCategory() 使用关键词规则推断物品类别、建议名称和置信度。OCR 超时或没有识别到文字时,用户仍可手动选择类别,不会阻断发布。
async function runRecognize(file,dataUrl){
let ocrText='';
try{
const rec=await Promise.race([
Tesseract.recognize(file,'eng'),
new Promise((_,reject)=>
setTimeout(()=>reject(new Error('ocr-timeout')),15000))
]);
ocrText=rec.data.text.trim();
}catch(err){
ocrText='';
}
let feat={};
try{ feat=await analyzeImage(dataUrl); }catch(err){ feat={}; }
return inferCategory(ocrText,feat);
}
这段代码的价值在于加入了超时和异常分支。第三方 OCR 即使加载失败,程序仍会继续分析图片并允许用户完成发布。
效果展示

4.2 智能匹配——丢失和拾取信息自动碰撞
创意与意义
用户发布信息后不必每天重复搜索。系统会让“寻物”和“招领”信息自动碰撞,综合图片、类别、地点、时间和关键词计算匹配度;达到 60 分就生成候选并写入消息中心。这样新发布的信息可以主动唤醒已有线索,把“人找信息”变成“信息主动找人”。
实现思路
findMatches() 先排除自身和已完成信息,再调用 calcMatch();同为寻物或同为招领的数据会提前返回。候选达到门槛后,双方记录都会保存关联信息,同时生成包含最高匹配依据的提醒。
const matches=await findMatches(item);
if(matches.length){
item.matches=matches;
matches.forEach(m=>{
MESSAGES.unshift({
t:'智能匹配:你的「'+name+'」可能找到了',
d:'匹配度 '+m.score+'%,'+m.reasons[0]+',点击查看',
time:'刚刚',
itemId:item.id
});
});
}
提醒不仅给出百分比,还显示“图片相似度”“物品类别相同”或“地点相同”等原因,让用户知道系统为什么推荐该条信息。
效果展示
4.3 悬赏任务——提高急需物品的找回机会
创意与意义
校园卡、钥匙、耳机等物品可能急需找回。发布寻物时可以选择悬赏金额,首页卡片和详情页都会突出显示悬赏状态,使紧急信息更容易被注意,也能鼓励更多同学主动提供线索。悬赏只对寻物启事开放,招领信息不出现该入口,避免功能语义混乱。
实现思路
选择“寻物启事”后才显示悬赏选项,金额保存在 pubForm.reward。提交时再次按类型判断;首页可以单独筛选悬赏信息,详情页则根据完成状态显示“等待找回”或“悬赏已兑现”。
function selectPubType(type){
pubForm.type=type;
$('f-reward-row').style.display=type==='lost'?'flex':'none';
}
const item={
// 其他发布字段
reward:pubForm.type==='lost'?(pubForm.reward||0):0
};
if(homeSeg==='reward') return d.reward>0;
这里同时在界面层和数据层限制悬赏类型。即使表单状态异常,招领信息写入数据时悬赏金额仍会归零。
效果展示

4.4 归还致谢——感谢信与爱心积分形成正向反馈
创意与意义
失物招领不应该在“物品已归还”处突然结束。完成后,失主可以发送自动生成且允许修改的感谢信;拾获者收到感谢后获得爱心积分。它将一次物品归还转化为可见的正向反馈,让帮助他人的行为得到回应,鼓励校园中的善意继续传递。
实现思路
markDone() 更新物品状态,并根据寻物或招领类型显示不同提示;thanksTpl() 根据物品名称生成感谢信初稿;submitThanks() 保存感谢记录并更新积分和页面。
function markDone(id){
const d=DATA.find(x=>x.id===id);
d.status='done';
if(d.type==='found') addPoints(20);
renderDetail(id);
renderHome();
renderMine();
}
function submitThanks(){
const d=DATA.find(x=>x.id===currentThanksId);
const text=$('th-text').value.trim();
if(thanksMode==='send'){
if(!text) return;
thankLetters.unshift({item:d.name,text,dir:'sent'});
}else{
thankLetters.unshift({item:d.name,text,dir:'received'});
addPoints(10);
}
}
状态更新后会同步刷新详情、首页和“我的”页面,避免不同页面显示不一致。感谢信和积分只保存在当前演示会话中,刷新后会恢复初始数据。
效果展示
五、目录结构与使用说明
1. 目录组织
campus-lost-found-web/
├── 校园失物招领小程序.html # 实际演示页
├── assets/ # 地图、头像等静态资源
├── css/
│ ├── style.css # CSS 统一入口
│ ├── base.css # 变量、手机外壳、通用导航
│ ├── home.css # 首页、卡片、消息和地图
│ ├── forms.css # 发布表单和搜索页
│ ├── detail.css # 详情、“我的”、弹窗和 Toast
│ ├── prototype.css # 桌面端需求说明面板
│ └── features.css # 聊天、识图、匹配、悬赏、感谢信
├── js/
│ ├── app.js # 初始化、时钟、天气、电池和网络状态
│ ├── core/
│ │ ├── data.js # 演示数据、分类图标和 ID
│ │ └── navigation.js # 路由、Toast、通用卡片和首页
│ └── features/
│ ├── publish.js # 发布表单、图片上传和选择弹层
│ ├── vision.js # OCR、MobileNet、图像指纹和向量相似度
│ ├── matching.js # 五维加权匹配与提交发布
│ ├── search.js # 普通文本搜索
│ ├── photo-search.js # 拍照搜物和图片相似度排序
│ ├── detail.js # 物品详情和匹配结果
│ ├── gratitude.js # 积分、完成状态和感谢信
│ ├── chat.js # 站内聊天
│ ├── profile.js # 我的页面
│ └── messages.js # 消息中心
├── docs/ # 架构和依赖说明
└── tests/ # 手工回归用例与测试素材说明
2. 如何运行
方式一:克隆后直接体验
先将远程仓库下载到电脑,再切换到本项目所在的子目录:
git clone https://github.com/KongCheng06/102401607-112400810.git
cd 102401607-112400810/campus-lost-found-web
找到该目录中的 校园失物招领小程序.html,选择 Chrome 或 Edge 打开。
这种方式不涉及 Node.js、数据库或包管理器。由于 OCR 和图片特征模型通过网络加载,体验识图功能时请保持联网。
方式二:通过静态服务访问
若直接打开 HTML 时出现资源无法加载或浏览器安全限制,可在 campus-lost-found-web 目录中执行:
python -m http.server 8000
服务启动后,在浏览器输入 http://localhost:8000。该方式需要电脑已安装 Python 3,适合进行识图或兼容性测试。
六、单元测试
6.1 工具选择与学习方法
我们使用 Node.js 内置的 node:test 和 node:assert/strict,不依赖第三方测试框架。学习过程是先阅读 Node.js Test Runner 官方文档,再学习邹欣老师的《单元测试与回归测试》,最后阅读项目源码,用语句覆盖、判定/分支覆盖、条件覆盖、主要路径覆盖和边界值分析设计测试。
本地运行:
npm test
查看覆盖率:
npm run test:coverage
6.2 白盒测试用例
目前共有 14 个自动化用例:
| 编号 | 被测路径 | 预期结果 |
|---|---|---|
| WB01 | 同义词循环命中与退出 | 同义词归一且不重复 |
| WB02 | 空值和未命中路径 | 返回空关键词数组 |
| WB03 | 区域循环命中/不命中 | 正确判断同一区域 |
| WB04 | 地点相同、包含、同区域、无关 | 返回对应状态 |
| WB05 | 地点为空的防御路径 | 不误判为地点相同 |
| WB06 | 所有时间桶及默认路径 | 返回对应时间键或空串 |
| WB07 | 同类型提前返回 | 返回 null |
| WB08 | 图片及其他条件全部命中 | 严格按 50/20/15/10/5 计分 |
| WB09 | 同区域分支 | 地点获得 10 分 |
| WB10 | 缺少图片分支 | 剩余可用权重正确归一化 |
| WB11 | 所有条件不命中 | 得 0 分且无匹配理由 |
| WB12 | 60 分边界 | 60 分保留,50 分排除 |
| WB13 | 自身、完成项、同类项过滤 | 只保留有效候选并降序排列 |
| WB14 | 无候选路径 | 返回空数组且不抛异常 |
6.3 部分单元测试代码
test('WB12:60分边界保留、低于边界排除', async () => {
const query = item();
const exactBoundary = item({
id: 2,
type: 'found',
name: '黑色物品',
desc: '无明显特征',
place: '第一田径场'
});
const belowBoundary = item({
id: 3,
type: 'found',
place: '第一田径场',
time: '昨天'
});
const data = [query, exactBoundary, belowBoundary];
const {calcMatch, findMatches} = loadMatching({visual:null, data});
assert.equal((await calcMatch(query, exactBoundary)).score, 60);
assert.equal((await calcMatch(query, belowBoundary)).score, 50);
assert.deepEqual((await findMatches(query)).map(x=>x.id), [2]);
});
calcMatch() 负责计算两个条目的匹配度,findMatches() 负责过滤候选并执行 60 分门槛。该用例直接从规则反推输入,使结果刚好位于边界两侧。
6.4 测试数据与“刁难测试”
我们使用对象工厂生成一条字段完整的默认数据,每个用例只修改当前分支需要的字段。数据同时包含:
- 正向数据:类别、地点、时间和关键词相同;
- 反向数据:跨区域、不同时间桶、无共同关键词;
- 异常数据:
null、空字符串、无图片、无候选; - 状态数据:自身、同类型、已完成和多个有效候选;
- 边界数据:精确构造 60 分、低于 60 分和最高 99 分。
针对测试人员可能使用的重复同义词、大小写混合英文、空地点、模型失败、同一记录匹配自己、已完成记录重新推荐、多条候选乱序等情况,测试中均有相应分支。白盒分析还实际发现并修复了“两地点均为空时被误判为相同”的问题。
6.5 自动化与测试评价
.github/workflows/unit-tests.yml 会在 push、Pull Request、手动触发及每天北京时间 08:00 运行测试。当前实测结果:
tests 14
pass 14
fail 0
matching.js 行覆盖率:62.96%
matching.js 分支覆盖率:96.67%
matching.js 函数覆盖率:81.25%
这 14 个用例可以满足匹配核心的单元测试要求,但不能单独证明整个网页完全正确。未覆盖部分主要是依赖 DOM 的发布和弹窗逻辑;MobileNet 加载、图片解码、文件选择器、视觉布局和浏览器兼容性还需要结合 tests/MANUAL_TEST_CASES.md 进行集成与手工回归。
更完整的测试设计见 docs/UNIT_TEST_GUIDE.md。
七、GitHub代码签入记录截图
八、遇到的代码模块异常或结对困难及解决方法
1. “我的”页面新增感谢信模块起初无法滚动及折叠,导致屏幕下方导航栏被遮挡无法继续测试,后续为感谢信模块新增滚动及折叠效果后成功解决。
2.会话逻辑异常。起初在他人发布的寻物启事点击会话联系后,演示效果为贴主发出第一句话,我回答(如下左图);后经修改改为由“我”主动联系,贴主进行回复(如下右图)
4.结对开发时,两人同时修改页面 JS 与样式文件,出现代码合并冲突。后使用 Git 查看冲突标记,手动合并冲突代码。
收获
1.前端页面布局要考虑内容溢出边界问题,长内容区域需要做好滚动、折叠等边界处理,避免遮挡页面基础组件。
2.业务逻辑要贴合真实使用场景,不能只完成基础页面渲染,要仔细核对交互流程、数据流向。
3.结对开发需要提前划分模块,做好沟通;Git 冲突需要读懂标记,谨慎合并代码,避免覆盖队友代码。
4.功能迭代时要做好前后效果对比,方便调试、留存项目文档用于报告撰写。
九、对队友的评价
对112400810【雷葭雨】的评价
1.值得学习的地方
- 业务理解清晰,能够独立完成首页、发布页、详情页的页面框架搭建,实现表单录入、页面跳转等核心交互,按时交付负责模块。
- 沟通积极性强,遇到交互逻辑问题会主动交流,代码结构整洁,方便后续联调,能够配合完成整体原型测试。
2.需要改进的地方
- 开发前期对异常场景考虑不够充分,部分边缘交互逻辑缺少判断,测试阶段才发现问题,增加了调试时间。
- 编写代码时注释偏少,部分复杂跳转逻辑没有说明,他人阅读和维护代码时理解成本较高。
十、总结
1.程序开发层面
本次校园失物招领系统开发,完整经历需求分析、设计、前端编码与联调测试。开发中体会到业务状态流转是难点,帖子和认领申请存在多种状态,容易出现逻辑漏洞。同时意识到不能只依赖前端校验,还要考虑图片上传、页面适配等用户体验细节,充分认识到前期设计对项目稳定性的重要作用。
2.结对合作层面
结对开发让我体会团队协作的意义。我们按能力分工,分别负责与前端开发和测试、文档整理,但开发时容易出现需求理解不一致、逻辑不合理等问题。及时沟通、定期同步是关键。自己写的代码很难发现问题,同伴可以快速找到隐藏 bug;同时需要统一代码规范,协调开发节奏,保障项目有序推进。
3.综合能力与工程思维感悟
项目不仅锻炼编码能力,更培养软件工程思维。开发面向校园用户的系统,除实现基础功能外,还需要考虑信息安全与用户隐私保护。规范的注释与项目文档,方便后期调试和撰写报告。软件开发是迭代完善的过程,真实项目大多需要团队协作,沟通能力和工程规范和代码能力同样重要。

浙公网安备 33010602011771号