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

结对编程作业:拾光 · 校园失物招领 Web 项目

项目 内容
这个作业属于哪个课程 202601 软件工程
这个作业要求在哪里 2026秋软件工程结对作业(第二次之程序实现)
这个作业的目标 基于第一次原型,实现校园失物招领的发布、浏览搜索、详情联系和状态维护,并进行自动化测试与 GitHub 协作
结对成员 王龙滨(112401430);吴佳辉(112401341)
本人博客 王龙滨
结对同学博客 吴佳辉
本作业博客 校园失物招领程序实现
GitHub 项目地址 112401430-112401341
前次需求分析 需求分析与原型设计

一、成员与具体分工

成员 具体工作 对应产出
王龙滨 提供并核对需求,组织初版程序集成、修复检查、仓库主线合并与交付说明整理 初版核心流程、测试与说明文件;主仓库及合并记录
吴佳辉 在 fork 分支重构前端与测试目录,增加地点筛选、联系方式扩展、配图及个人线索管理 PR #1–#4、分模块测试及新增功能

协作采用“主仓库 + fork + 功能分支 + Pull Request”:功能更新在队友分支完成,通过差异检查和测试后合入 main。本次按照 PR 创建时间,先合并 #3 测试结构整理,再合并 #4 界面与发布管理,保留原始提交历史。PR #3 不改运行代码;PR #4 合并后再同步 README 的联系方式、数量上限和测试数量,避免文档落后于程序。

二、PSP 开发时间记录

预估来自开发前的 PSP.md;实际耗时由本人按阶段回忆估算,单位为分钟。

PSP 阶段 工作内容 预估耗时 实际耗时
Analysis 阅读作业、核对原型与需求 45 45
Design 数据模型与交互设计 60 60
Coding 核心逻辑编码 120 120
Coding 页面与样式实现 180 180
Code Review / Test 自动化测试与浏览器走查 90 90
Reporting README 与交付检查 45 45
合计 540 540

预估与实际耗时均为 540 分钟,即 9 小时;其中编码阶段合计 300 分钟,测试走查 90 分钟。

三、解题思路与设计实现

1. 需求与原型衔接

校园失物信息分散在群聊中,容易被新消息覆盖。本次继续面向失主和拾得者,沿用第一次墨刀原型的六个页面:首页、发布信息、搜索、信息详情、发布成功、我的发布。选择 PC Web,使用原生 HTML、CSS、JavaScript,不引入需要构建的框架,下载完整项目即可打开。

原型在线链接:拾光 · 墨刀原型。实现时将手机页面调整为电脑布局,保留核心任务和信息字段;原型的“已找回”统一采用本次作业要求的“已找到”。

原需求 程序实现及入口
集中查看寻物、招领 首页查看最新线索;“发现信息”浏览全部记录
发布两种信息 发布页选择“我丢了物品/我捡到物品”,填写信息,提交后进入成功页
按名称等关键词查找 通用关键词匹配名称、类别、地点、描述,并可组合筛选
查看详情与联系 卡片进入详情,展示地点、日期、描述及邮箱/手机号,支持复制
发布者维护状态 “我的发布”或本人详情中标记已找到/已归还,其他列表与详情同步显示
明确失败情况 空表单、无结果、无效日期、记录不存在、存储异常均有提示

这是本地演示网页:数据保存在当前浏览器的 localStorage,不跨设备共享;owner: "local" 表示本机发布,不是账号认证。真实校园共享版本需要另行实现服务端存储和权限控制。

2. 模块与数据模型

程序分为业务层、应用层和页面层。业务层提供不依赖 DOM 的校验、筛选、状态变更函数;应用层负责存储、路由和事件;页面层把状态渲染为 HTML。浏览器按依赖顺序加载 19 个 defer 脚本,core.js 统一导出接口,同时支持浏览器和 Node.js 测试。

一条信息包括 id、type、title、category、place、date、description、contact、phone、status、owner、createdAt、imageNumber。其中 type 为 lost/found,status 为 open/closed。相同状态按类型显示不同文字:寻物对应“寻找中/已找到”,招领对应“招领中/已归还”。date 是遗失或拾取日期,createdAt 是发布时间,两者不混用。

3. 核心操作与数据流

下图包含正常路径、字段与数量限制、存储失败及状态维护。流程图用 diagrams.net 制作,保留可编辑的 .drawio 源文件,并导出高清 PNG。

校园失物招领核心业务流程

发布时先规范空白,再校验字段:名称 2–40 字符、地点 2–60 字符、描述 10–500 字符、有效且不晚于今天的日期。邮箱和中国大陆手机号至少填写一项,填写的每一项都必须有效。未通过时显示字段错误、定位首个错误,保留草稿;通过后生成唯一编号并更新列表,进入成功页。

数据从表单进入 validateDraft,通过后由 createRecord 生成记录,addLocalRecord 检查数量与编号,存储层写入缓存。浏览时,filterRecords 根据筛选条件产生列表;本人结束或撤销信息时,markClosed/markReopened 返回更新后的数组,再保存并重绘页面。

搜索条件写入 Hash 路由,详情链接带上 returnTo。从结果进入详情再返回,或直接刷新搜索页,关键词、地点、类型、类别和状态均可恢复。示例信息不能修改;结束后隐藏联系方式,撤销结束后重新显示,但本地缓存仍保存原联系方式。

4. 关键代码:校验、检索和状态维护

发布前校验

js/core/validation.js 集中处理业务规则,表单和记录创建共用同一套检查,避免只靠 HTML 必填属性。

function validateDraft(draft, today = localDate()) {
  const item = normalizeDraft(draft);
  const errors = {};
  if (typeof draft?.type !== "string" || !TYPES.includes(item.type)) errors.type = "请选择寻物或招领。";
  if (item.title.length < 2 || item.title.length > 40) errors.title = "物品名称需填写 2–40 个字。";
  if (!CATEGORIES.includes(item.category)) errors.category = "请选择物品类别。";
  if (item.place.length < 2 || item.place.length > 60) errors.place = "地点需填写 2–60 个字。";
  if (!validCalendarDate(item.date) || item.date > today) errors.date = "请选择不晚于今天的有效日期。";
  if (item.description.length < 10 || item.description.length > 500) errors.description = "描述需填写 10–500 个字。";
  Object.assign(errors, validateContacts(draft));
  return errors;
}

normalizeDraft 清理首尾空白;validCalendarDate 排除不存在的日期;validateContacts 分别检查邮箱和手机号,即使其中一项有效,也不能忽略另一项已填写但无效的内容。返回按字段组织的错误对象,页面可对应提示并保留输入。

组合检索

以下为 js/core/queries.js 的核心实现。地点仅匹配 place,通用关键词则匹配四个公开描述字段,避免把“描述里提到图书馆”误当成“在图书馆拾得”。

function filterRecords(records, filters = {}) {
  const keyword = clean(filters.keyword).toLocaleLowerCase("zh-CN");
  const place = clean(filters.place).toLocaleLowerCase("zh-CN");
  return records.filter(item => {
    if (filters.type && filters.type !== "all" && item.type !== filters.type) return false;
    if (filters.category && filters.category !== "all" && item.category !== filters.category) return false;
    if (filters.status && filters.status !== "all" && item.status !== filters.status) return false;
    if (place && !item.place.toLocaleLowerCase("zh-CN").includes(place)) return false;
    if (!keyword) return true;
    const haystack = [item.title, item.category, item.place, item.description]
      .join(" ").toLocaleLowerCase("zh-CN");
    return haystack.includes(keyword);
  }).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)
    || a.id.localeCompare(b.id));
}

五项条件是“同时满足”,不是任选其一。地点判断放在空关键词返回前,因此只填地点也有效;排序比较实际时间戳,时间相同再按编号排序。filter 生成新数组,不改变输入记录;联系方式不进入关键词搜索。

结束与撤销

js/core/records.js 将结束和撤销统一交给 changeStatus,先验证记录存在与归属,再判断重复操作。

function changeStatus(records, id, nextStatus, actor) {
  const index = records.findIndex(item => item.id === id);
  if (index < 0) throw errorWithCode("NOT_FOUND", "信息不存在。");
  const record = records[index];
  if (record.owner !== actor) throw errorWithCode("FORBIDDEN", "只能更新本机发布的信息。");
  if (record.status === nextStatus) {
    throw errorWithCode(nextStatus === "closed" ? "ALREADY_CLOSED" : "ALREADY_OPEN",
      nextStatus === "closed" ? "这条信息已结束。" : "这条信息已经在进行中。");
  }
  return records.map(item => item.id === id ? { ...item, status: nextStatus } : item);
}

公开接口 markClosed 和 markReopened 分别传入固定的 closed/open,默认操作标记为 local。函数返回新数组,不直接修改原记录;存储与页面反馈由应用层处理。

四、附加特点:设计、实现与效果

特点 设计意义与实现思路
地点与多条件筛选 缩小校园范围;独立地点字段与关键词、类型、类别、状态共同判断
自动草稿 填写中离开不会从头重写;字段变更时暂存,返回时恢复,发布后清除
一键复制联系方式 减少抄写错误;优先剪贴板 API,失败后兼容复制,再失败提示手动复制
撤销结束 修正误操作;保留记录内容,只将状态从 closed 恢复为 open
个人线索上限与配图编号 显示当前保留量;本机最多 10 条,像素角色编号辅助区分,注明并非物品实拍
已结束信息长按删除 降低单击误删;按住 1 秒才执行,松手、Esc、离开页面或失焦取消,存储失败回滚

数量上限只统计本机记录,结束的信息仍占名额;删除后释放名额。升级前超过 10 条的旧缓存不截断,避免升级导致数据丢失。

删除功能分两层:事件层负责长按计时及取消,业务层再次检查记录归属和结束状态。js/app/store.js 中的保存回滚代码如下:

function deleteRecord(id) {
  refreshLocalRecords();
  const previous = localRecords;
  localRecords = core.deleteRecord(localRecords, id);
  if (!saveLocalRecords()) {
    localRecords = previous;
    throw new Error("删除未能保存,记录已保留,请稍后重试。");
  }
}

写入前重新读取其他标签页的有效缓存,保存失败恢复原数组。这能减少过期页面覆盖新数据的风险,但 localStorage 没有数据库事务,仍可能发生并发写入冲突。

运行效果

首页展示最新线索与操作入口:

首页运行效果

发布页清楚区分寻物/招领,显示数量上限、必填字段及联系方式规则:

发布信息页面

发布有效寻物信息后进入成功页:

发布成功

详情展示物品属性、联系方式与本人状态操作:

信息详情

结束后显示“已找到”,隐藏联系方式,提供撤销和长按删除入口;普通单击只提示长按,记录仍保留:

结束状态及防误删提示

按“保温杯 + 图书馆 + 寻物”组合筛选,刷新后条件及结果保持:

组合搜索结果

五、目录组织与测试人员使用说明

112401430-112401341/
├─ index.html             页面入口、全局导航、脚本加载
├─ css/
│  ├─ styles.css          统一样式入口
│  ├─ base.css / layout.css / components.css / cards.css
│  ├─ home.css / search.css / publish.css / detail.css
│  └─ success.css / mine.css / feedback.css / desktop.css
├─ js/
│  ├─ core.js             浏览器与 Node.js 共用的业务接口
│  ├─ core/               校验、记录、缓存、查询、常量、工具、示例
│  ├─ app.js              启动与组装
│  ├─ app/                存储、路由、公共 UI、事件
│  └─ pages/              六个页面的渲染函数
├─ assets/images/         背景、图标、像素角色与素材许可证
├─ tests/
│  ├─ core.test.js        业务测试入口
│  ├─ app-smoke.test.js   页面交互测试入口
│  ├─ core/ / app/        按功能划分的 .cases.js 用例
│  └─ helpers/            固定测试数据、模拟浏览器工具
├─ PSP.md                 开发预估与实际记录
└─ README.md              目录、规则、使用方法与测试命令

测试人员可按以下步骤运行:

  1. 在 GitHub 点击 Code → Download ZIP,解压完整目录;不要只下载 index.html。
  2. 使用 Google Chrome 打开根目录的 index.html,保留 css/、js/、assets/ 的相对位置。运行应用不需要 Node.js、npm 安装或后端服务。
  3. 从首页进入“发现信息”,搜索示例物品;进入发布页填写有效信息,在成功页查看详情。
  4. 在“我的发布”标记已找到/已归还,再检查列表和详情状态;可撤销结束。删除不可恢复,测试长按时仅使用自行创建的测试记录。
  5. 刷新验证记录与草稿恢复。浏览器需允许本地存储;清理浏览器数据或更换设备不会共享原有记录。

README 已同步上述目录、联系方式规则和测试说明。

六、单元测试:工具、教程与测试数据

1. 工具与简易教程

采用 Node.js 自带的 node:test 和 node:assert/strict,无需安装测试框架。本次执行环境为 Node.js v24.19.0;业务层可单独导入,页面层通过 vm 和自建模拟 DOM、存储、计时器运行。

学习与扩展测试可以按“读接口 → 写最小断言 → 增加反例 → 接入回归”的顺序:

  1. 找到待测函数的输入、输出及错误分支,例如 validateDraft 返回字段错误对象,markClosed 返回更新后的数组。
  2. 在 tests/core/ 对应 .cases.js 中添加 test("说明", () => { ... }),用 equal/deepEqual 比较结果,预期异常用 throws 检查错误码。
  3. 从正确输入开始,每次只改变一个字段;加入边界值和错误类型,判断错误来自哪个分支。
  4. 若新增用例文件,在对应测试入口添加 require;已有文件直接增加用例即可。
  5. 在项目根目录运行完整测试,修改功能后再次运行。用例失败时先核对业务预期,不为“全绿”而删除反例。
# 全部测试
node --test tests/core.test.js tests/app-smoke.test.js

# 仅检查搜索模块
node --test tests/core/queries.cases.js

2. 部分测试代码

下列片段来自现有测试文件,draft() 和 localRecord() 由 tests/helpers/fixtures.js 提供,使用固定时间和虚构数据,避免结果随当天日期漂移。

const test = require("node:test");
const assert = require("node:assert/strict");
const core = require("../../js/core.js");
const { draft, localRecord } = require("../helpers/fixtures.js");

test("只填写手机号也可发布,邮箱允许留空", () => {
  assert.deepEqual(core.validateDraft({ ...draft(),
    contact: "", phone: "13800138000"
  }, "2026-09-29"), {});
});

test("只填写地点时可按地点包含匹配,无需通用关键词", () => {
  const records = [localRecord(), ...core.SAMPLE_RECORDS];
  const result = core.filterRecords(records, { place: "图书馆" });
  assert.deepEqual(result.map(item => item.id), ["local-1", "sample-card"]);
});

test("不能修改演示信息或其他发布者的信息", () => {
  assert.throws(() => core.markClosed([...core.SAMPLE_RECORDS],
    "sample-card"), { code: "FORBIDDEN" });
  assert.throws(() => core.markClosed([localRecord()],
    "local-1", "someone-else"), { code: "FORBIDDEN" });
});

三个用例分别检查联系方式的替代分支、空关键词下的地点分支、所有权拒绝分支。测试不仅看页面有没有文字,还比较结果编号和错误码。

3. 白盒设计与边界数据

围绕条件分支、状态转换与异常路径设计白盒测试。下表列出 16 组代表性场景,满足附录至少 10 个白盒用例的要求,完整用例在仓库中。

编号 待测函数/模块 构造数据与预期
1 validateDraft 完整合法字段 → 无错误;空表单 → 必填错误
2 日期校验 2026-02-30、未来日期 → 拒绝
3 名称长度 2 字符允许,41 字符拒绝
4 联系方式 仅邮箱、仅手机号、两者同时 → 有效时通过
5 联系方式 均为空、错误前缀、数组/数值类型 → 拒绝
6 filterRecords 只填地点且关键词为空 → 正确匹配
7 filterRecords 名称/描述含图书馆,实际地点为食堂 → 地点筛选不命中
8 filterRecords 五项条件逐一制造不匹配记录 → 只保留全匹配项
9 排序 不同时区时间戳、相同时间不同编号 → 稳定顺序
10 markClosed/markReopened 本机、示例、他人、缺失编号、重复状态 → 分别处理
11 缓存解析 损坏 JSON、重复编号、sample- 冒用、旧联系方式 → 清洗或兼容
12 addLocalRecord 第 10 条成功,第 11 条拒绝;结束记录仍占名额
13 旧缓存迁移 原有记录超过 10 条 → 全部保留,禁止继续新增
14 长按事件 普通点击、提前松手、Esc、失焦、换页 → 不删除
15 删除与存储 满 1 秒可删除已结束的本机记录;保存失败 → 回滚
16 页面与路由 草稿恢复、搜索刷新、详情返回、HTML 特殊字符 → 状态正确且转义

测试数据按正常、异常、边界、历史兼容四类组织。除了防“输错”,还考虑修改缓存、伪造编号、跨标签页改状态、连续事件等情况。基于代码分支选择反例,并用不变性断言检查旧数组未被修改。

完整回归结果:113 项通过,0 项失败,其中业务测试 52 项、页面交互测试 61 项;19 个脚本语法检查通过,12 个 CSS 导入和 4 个背景资源引用均存在。

tests 113
pass 113
fail 0
cancelled 0
skipped 0

页面走查使用内置浏览器和本机 HTTP 地址,检查了缺少联系方式时阻止发布、成功页跳转、详情联系、结束后隐藏联系、撤销、单击不删除,以及组合搜索刷新和详情返回。Node 模拟 DOM 测试验证事件与状态,页面走查则检查实际点击与跳转,两者关注不同层面。

七、GitHub 代码签入与结对协作

按功能组织提交,包括前端模块整理、地点筛选、测试拆分、联系方式扩展、数量限制与删除管理。合并保留队友的提交,文档修订独立提交,方便定位变更。

GitHub 近期代码签入记录

查看完整提交历史。PR #3 合并提交为 1d462ac,PR #4 为 ae8c829,使用说明同步为 f818105。首次上传采用已有本地模块的导入提交,后续按功能提交更新。

八、遇到的问题、尝试与收获

问题或困难 排查与尝试 结果与收获
类型校验把 toString 等继承属性当作有效类型 检查类型判断,加入字符串和白名单验证,同时测试创建与缓存读取 已修复;不能只验证表单选项,还要验证底层输入
详情返回丢失筛选、离开发布页丢失未完成内容 将查询写入路由并携带返回入口;输入时保存草稿,验证离开和刷新 已修复;页面之间必须传递状态,而非只做跳转
误结束无法恢复、损坏缓存可能被覆盖 增加撤销结束;新写入前保存异常缓存恢复副本 已修复;成功流程之外也要设计恢复路径
新功能与旧记录、其他标签页发生冲突 保留旧联系方式与超额记录,写入前读取最新缓存,删除失败回滚,加入计时与异常用例 相应回归用例通过;兼容与取消事件应和新增功能一起测试
PR 扩展后 README 仍写邮箱必填、共 68 项测试 对照源码和实际输出逐项更新规则、资源目录和测试数量 已同步;代码、测试、文档需要共同复审

九、队友评价

值得学习:吴佳辉把业务、页面与测试按功能拆分,并通过 fork 和 PR 逐步推进地点筛选、联系方式及发布管理。新增功能同时补充边界、旧数据和取消事件测试,这种把维护性与功能一起考虑的习惯值得学习。

需要改进:部分 PR 没有填写变更说明,新增规则后 README 未同步。后续可以统一 PR 模板,写清改动目的、测试命令、兼容影响和素材出处,减少合并时的追溯成本。

十、总结

项目已经串联起“发布—浏览或搜索—查看详情—联系发布者—更新状态”的本地流程,并增加草稿恢复、地点筛选、撤销结束和防误删操作。113 项自动化测试覆盖业务与页面事件,需求和原型中的核心任务都有对应入口。

这次实现带来的主要认识是:页面跳转之外,还要明确数据如何流动、状态如何恢复、失败后如何保留输入。模块拆分让业务函数容易独立测试,PR 则让功能变化和文档修订能够逐项追溯。后续可结合试用反馈,进一步优化发布和检索流程,并评估多人共享的需求。

posted @ 2026-10-09 14:22  王龙滨  阅读(12)  评论(0)    收藏  举报