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

校园失物招领 Web 系统 —— 第二次结对编程博客

项目 内容
这个作业属于哪个课程 https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering
这个作业要求在哪里 https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering/homework/16744
作业这个作业的目标 基于第一次结对作业的原型,实现"发布信息—浏览或搜索—查看详情—联系发布者—更新状态"的完整流程
结对成员 072408101(陈柏睿)、072308130(陈柯男)
我的博客 https://www.cnblogs.com/baiyi060404
结对同学博客 https://www.cnblogs.com/PramanixKBlue
GitHub 仓库 https://github.com/baiyi060404/072408101-072308130

一、具体分工

这次我们先通过聊天同步进度,确认从手机端改做网页端后,一起讨论导航栏放在顶部、寻物和招领分两列展示、物品找回后从首页移除并放入搜索、状态修改入口放在哪里等问题。

  • 072408101(陈柏睿):负责项目仓库与代码基线,先搭好数据层和动态渲染,让 index.html 能直接运行;实现发布、搜索、我的发布、收藏、编辑等数据链路;最后负责处理 pull request 合并冲突。
  • 072308130(陈柯男):负责网页端界面重设计与交互优化,参考同学反馈把主界面调整为“左图右信息”;完成搜索页接入真实数据、我的页与编辑资料页匹配、保存后返回、本地用户 ID 识别、删除模拟数据,并提交 pull request。

两人共同完成的部分:功能取舍与状态流转规则、数据模型与 localStorage key 设计、问题定位与交叉测试。

二、PSP 表格

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

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

1. 代码实现思路

这次的核心不是“把界面画出来”,而是把原型里的静态数据换成真正的数据层,并让多个页面共享同一份数据。我们选择了 Web 方案,做成单文件入口 + hash 路由的 SPA:

  • index.html 只负责页面结构和视图容器,不写业务数据。
  • js/data.js 是数据层,统一封装数据模型、localStorage 读写、种子数据,以及发布、查询、筛选、搜索、收藏、状态更新等函数,通过 window.LostFoundStore 暴露给 UI。
  • js/app.js 负责路由解析和动态渲染,任何页面都不再维护自己的列表副本,而是统一从 LostFoundStore 读取,保证“发布 → 首页/搜索/我的发布”数据一致。

关键决策是无登录情况下如何识别“我发布的记录”:我们没有做登录系统,而是在本地生成并持久化一个用户 ID,发布时写进记录的 ownerId,之后用 ownerId === getCurrentUserId() 过滤“我的寻物 / 我的招领”。这样既不引入后端,又能满足状态管理需求。

2. 数据流图

flowchart LR A[首次打开 index.html] --> B[data.js 初始化 localStorage] B --> C[首页 / 搜索页读取 LostFoundStore] D[发布表单 publishInfo] --> E[addItem 写入 localStorage] E --> B E --> F[发布成功页] G[详情页点击收藏] --> H[addFavorite / removeFavorite] H --> I[我的收藏动态渲染] J[我的寻物 / 我的招领] --> K[getItems 按 ownerId 过滤] K --> L[updateStatus / updateItem] L --> C

3. 关键代码片段与解释

下面是数据层的发布与读取。createItem 负责补全 id、ownerId、secret 等字段,addItem 写入 localStorage 后立刻重建 APP_DATA,因此发布成功返回首页时无需刷新页面就能看到新记录。

function createItem(input) {
  input = input || {};
  var type = TYPE_VALUES.indexOf(input.type) !== -1 ? input.type : TYPES.LOST;
  var category =
    CATEGORIES.indexOf(input.category) !== -1 ? input.category : "其他";
  var status =
    STATUS_VALUES.indexOf(input.status) !== -1
      ? input.status
      : STATUSES.ACTIVE;

  return {
    id: input.id || generateId(),
    ownerId: input.ownerId || getCurrentUserId(),
    type: type,
    name: String(input.name || "").trim(),
    category: category,
    location: String(input.location || "").trim(),
    time: String(input.time || "").trim(),
    description: String(input.description || "").trim(),
    contact: String(input.contact || "").trim(),
    image: input.image || "",
    status: status,
    secret: input.secret ? String(input.secret) : generateSecret(),
    createdAt: input.createdAt || new Date().toISOString(),
  };
}

function addItem(input) {
  seedIfNeeded();
  var items = readItems();
  var item = createItem(input);
  items.unshift(item);
  writeItems(items);
  buildAppData();
  return item;
}

搜索页的数据读取、过滤与排序。排序按 createdAt 倒序,保证最新发布的内容排在最前,同时时间筛选复用同一条数据链。

function renderSearchResults(showMessage) {
  var view = getView("search");
  if (!view) return 0;

  var store = window.LostFoundStore;
  if (!store || typeof store.getItems !== "function") return 0;

  var filters = getSearchFilters();
  var items = store.getItems().filter(function (item) {
    return searchMatches(item, filters);
  });

  items.sort(function (a, b) {
    var timeA = a.createdAt || "";
    var timeB = b.createdAt || "";
    if (timeA === timeB) return 0;
    return timeA > timeB ? -1 : 1;
  });

  // 渲染结果列表、结果计数与空状态
}

“我的寻物 / 我的招领”按 ownerId 过滤,只展示当前设备上自己发布的信息:

function renderMyLostList() {
  var view = getView("myLost");
  var store = window.LostFoundStore;
  if (!view || !store) return;

  var allItems = store.getItems();
  var currentUserId = store.getCurrentUserId();
  var items = allItems.filter(function (item) {
    return item.type === "寻物" && item.ownerId === currentUserId;
  });
  // 再按“全部 / 寻找中 / 已找到”筛选并渲染
}

四、附加特点设计与展示

1. 设计的创意与意义

  1. 本机用户识别:不引入登录,也能稳定区分“自己发布的记录”,让状态管理和编辑只对本人开放。
  2. 收藏持久化:详情页收藏后刷新不丢失,收藏页按寻物/招领筛选,并显示真实数量。
  3. 搜索时间维度的筛选与排序:支持“今天 / 近 3 天 / 近 7 天 / 近 30 天”,并按发布时间倒序展示。
  4. 编辑页状态管理:编辑信息的同时可直接调整物品状态,减少“先编辑再单独改状态”的重复操作。
  5. 图片自适应裁剪:上传的图片按比例 object-fit: cover 填满固定容器,避免拉伸变形。
  6. 响应式界面:手机框架与桌面端布局共存,不同分辨率下都能清晰显示。
  7. 评论交流:详情页支持按物品留言、自动带出昵称、300 字限制,以及评论作者或物品发布者的删除权限。

这些设计的意义是让“发布 → 浏览/搜索 → 详情 → 联系 → 更新状态”整条流程真正闭环,而不是只停留在可点击的原型。

2. 实现思路

  • 收藏:lost_found_favorites 单独存收藏的 id 数组,提供 addFavorite / removeFavorite / getFavorites / isFavorite,收藏页只存 id,渲染时再通过 getItemById 取完整记录。
  • 我的发布:用 campus_lost_found_user_id 记录本机用户 ID,发布时写入 ownerId。
  • 时间筛选:searchTimeMatch 根据当前日期计算时间窗口,time 字段参与筛选,createdAt 负责排序。
  • 编辑:编辑页复用发布页表单视觉,提交时调用 updateItem,只允许修改 ownerId 匹配的记录。
  • 评论:用 campus_lost_found_comments_v1 按物品 id 隔离存储,昵称自动读取个人信息,限制 300 字,作者或物品发布者可删除。

3. 重要代码片段

收藏数据层:

function addFavorite(id) {
  var favoriteId = String(id || "");
  if (!favoriteId) return getFavorites();

  var ids = readFavoriteIds();
  if (ids.indexOf(favoriteId) === -1) {
    ids.push(favoriteId);
    writeFavoriteIds(ids);
  }
  return getFavorites();
}

function isFavorite(id) {
  return readFavoriteIds().indexOf(String(id || "")) !== -1;
}

详情页收藏按钮状态由真实数据驱动:

window.toggleFavorite = function () {
  var view = getView("detail");
  var store = window.LostFoundStore;
  if (!view || !store || !currentDetailId) return;

  if (store.isFavorite(currentDetailId)) {
    store.removeFavorite(currentDetailId);
  } else {
    store.addFavorite(currentDetailId);
  }

  var button = $("#favoriteBtn", view);
  var icon = $("#favoriteIcon", view);
  var text = $("#favoriteText", view);

  updateFavoriteButton(button, icon, text, store.isFavorite(currentDetailId));
};

发表评论会把内容按当前物品保存,并记录评论作者的用户 ID:

allComments[currentDetailId].push({
  id: "comment_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8),
  userId: currentUserId,
  nickname: nickname.slice(0, 20),
  content: content,
  time: getCommentTimeText(new Date()),
});

saveAllDetailComments(allComments);
renderDetailComments(currentDetailId);

4. 实现成果展示

首页集中展示“进行中”的寻物和招领信息,顶部提供搜索入口:

首页展示

发布页可选择“我要寻物 / 我要招领”,填写物品类型、名称、地点、时间、描述和联系方式:

发布界面

搜索页支持关键词搜索,并按物品类型和时间范围筛选:

搜索界面

检索结果按发布时间倒序展示,最新发布的信息排在最前:

检索功能

详情页展示图片、描述、时间、地点和联系方式,并提供收藏、联系和评论功能:

详情页

编辑页可修改已发布信息,并通过“物品状态”下拉栏调整状态:

编辑界面

“我的寻物 / 我的招领”只展示自己发布的信息,支持筛选、标记状态和编辑:

我的发布界面

“我的收藏”按寻物 / 招领筛选,并可取消收藏:

我的收藏界面

五、目录说明和使用说明

目录结构

项目根目录
├─ index.html      # 实际运行入口(单文件 SPA,hash 路由切换视图)
├─ css/
│  └─ style.css    # 全局样式:布局、卡片、弹窗、移动端与桌面端适配
├─ js/
│  ├─ data.js      # 数据层:模型、localStorage、种子数据、增删改查、收藏
│  └─ app.js       # UI 与路由:视图切换、动态渲染、表单与事件
└─ images/
   ├─ campus-card.jpg / keys.jpg / water-bottle.jpg
   ├─ umbrella.jpg / earphones.jpg / books.jpg / backpack.jpg
   └─ home_show_top.png

使用说明

  1. 下载或克隆整个仓库,保持目录结构不变。
  2. 用谷歌浏览器双击 index.html 打开,无需服务器或构建工具。
  3. 可测试以下流程:
    • 发布:底部“发布”选择寻物/招领,填写表单并提交。
    • 搜索:输入关键词,或按物品类型、发布时间筛选,结果按时间倒序展示;左上角可返回首页。
    • 详情:查看图片、描述、时间、地点和联系方式,可收藏、联系发布者,也可在评论区留言。
    • 我的发布:在“我的寻物 / 我的招领”中筛选、标记状态或进入编辑页。
    • 编辑:可修改物品信息,并通过“物品状态”下拉栏调整状态。
    • 收藏:在“我的收藏”中筛选、取消收藏。

数据保存在浏览器 localStorage,主要 key 为 lost_found_items、lost_found_favorites、campus_lost_found_user_id、campus_lost_found_comments_v1。

六、单元测试

1. 测试工具与学习过程

我们使用 Node.js 内置的 node:test 模块,不依赖任何构建工具,一条命令即可运行:

node --test test/

学习方式:先阅读 Node 官方文档的 node:test 示例,再结合邹欣老师《关于单元测试和回归测试》中的观点,把“可重复、自动化、失败能定位到函数”作为测试目标。因为 data.js 挂载在全局 window 上,测试时用一个轻量 vm 沙箱模拟 localStorage 即可。

我们的简易教程:新建 test/data.test.js,先写一个 createStore() 用 vm 沙箱加载 js/data.js 并返回 LostFoundStore,再用 test("用例名", () => {...}) 编写断言,最后在项目根目录执行 node --test test/ 即可一次性运行全部用例。

2. 部分单元测试代码

下面三个用例分别覆盖 addItem、updateStatus,以及收藏相关的 addFavorite / removeFavorite / isFavorite 函数。

const test = require("node:test");
const assert = require("node:assert");
const fs = require("node:fs");
const vm = require("node:vm");

function createStore() {
  const storage = {};
  const sandbox = {
    console, JSON, Date, Math,
    localStorage: {
      getItem: (k) => storage[k] || null,
      setItem: (k, v) => { storage[k] = String(v); },
      removeItem: (k) => { delete storage[k]; },
    },
  };
  sandbox.window = sandbox;
  vm.createContext(sandbox);
  vm.runInContext(fs.readFileSync("js/data.js", "utf8"), sandbox);
  return sandbox.LostFoundStore;
}

test("addItem 会生成 id 和 ownerId", () => {
  const store = createStore();
  const item = store.addItem({
    type: "寻物", name: "测试物品", category: "其他",
    location: "图书馆", time: "2026-10-10 10:00", contact: "138****0000",
  });
  assert.ok(item.id);
  assert.ok(item.ownerId);
  assert.equal(item.type, "寻物");
});

test("updateStatus 校验管理码", () => {
  const store = createStore();
  const item = store.addItem({
    type: "招领", name: "钥匙", category: "钥匙",
    location: "食堂", time: "2026-10-10 12:00", contact: "138****0000",
  });
  assert.equal(store.updateStatus(item.id, "000000", "已归还"), null);
  assert.equal(store.updateStatus(item.id, item.secret, "已归还").status, "已归还");
});

test("收藏去重与取消收藏", () => {
  const store = createStore();
  store.addFavorite("item1");
  store.addFavorite("item1");
  assert.deepEqual(store.getFavorites(), ["item1"]);
  store.removeFavorite("item1");
  assert.equal(store.isFavorite("item1"), false);
});

3. 测试数据构造思路

我们按“正常路径 + 边界 + 非法输入”来构造用例,并设想测试人员可能的“刁难”:

  • 正常新增:完整字段能正常入库。
  • 空关键词:搜索返回全部。
  • 无结果:不存在的物品返回空数组。
  • 错误管理码:updateStatus 返回 null,不修改状态。
  • 正确管理码:状态正确更新。
  • 重复收藏:id 不重复写入。
  • 取消收藏:删除后 isFavorite 为 false。
  • 空表单:必填字段缺失时发布失败。
  • 非法类别/状态:自动回退到“其他 / 进行中”。
  • 边界时间:近 3 天、近 7 天、近 30 天的边界值。

这些用例覆盖了数据层的主要分支,也覆盖了测试人员最容易构造的异常场景。

七、GitHub 代码签入记录

我们为每次改动单独提交,并让队友通过 pull request 合并:

代码签入记录

八、遇到的异常或困难及解决方法

  1. 问题:file:// 下使用 ES 模块会被 CORS 拦截。
    尝试:最初想用 import/export 拆分数据层。
    解决:改回普通 <script src> + 全局对象 window.LostFoundStore。
    收获:面向运行环境的架构决策要前置。

  2. 问题:发布页无论选寻物还是招领,首页都显示成寻物。
    尝试:检查表单 class 与类型判断逻辑。
    解决:found-mode 加在内部 #publishContent 上,而不是 #view-publish 上,修正读取对象。
    收获:DOM 层级和状态来源要单一、明确。

  3. 问题:详情页“···”里的编辑按钮点击无反应。
    尝试:检查内联 onclick 的变量作用域。
    解决:内联 HTML 无法访问 JS 闭包内的 currentDetailId,改为暴露 goToEditCurrentItem()。
    收获:内联事件应只调用全局函数,数据在函数内部解析。

  4. 问题:示例数据时间写死后,过几天筛选结果就不符合“近 3 天 / 近 7 天”。
    尝试:改成固定日期仍会过期。
    解决:种子时间按“相对今天”动态生成,并对已存在的旧示例记录做一次迁移,同时保留用户自建数据。
    收获:演示数据要考虑时间维度。

九、用户体验反馈

在这次结对编程的过程中,我们也考虑了上一次设计的需求分析缺少实际用户同学的体验,为此,我们在设计的过程中、完成了大部分功能后,都向身边的同学进行了访谈和咨询了实际使用体验,并针对这些反馈进行了对应的改进,以下是部分截图:

截图1

这是我们在设计过程中,向身边同学咨询他们希望在首页看到失物如何展示,在结合了我们的讨论过后,我们写入对应算法更新了失物排列展示的逻辑。

截图2

这是我们在项目大体完工后,将文件打包给身边同学进行实际测试,并根据他提供的建议改进了首页的样式,突出区分“招领”和“寻物”两列物品的不同。

十、评价队友

这次结对编程里,我们几乎每天都会同步进度。最开始决定从手机端改成网页端,队友就主动承担了网页版布局的重新设计,并且一直在思考“导航放顶部还是底部”“寻物和招领用两列还是别的形式”这些问题,还会去问身边同学的真实找东西习惯,把外部反馈带回来一起讨论。

开发过程中我们形成了比较明确的分工:我先把数据层和动态渲染搭起来,让项目可以先跑通;队友 fork 仓库后在另一端把界面和交互逐步补齐。他先后完成了搜索页改用真实数据、我的页与编辑资料页的信息匹配、保存后返回我的页,以及用本地用户 ID 识别当前设备发布的寻物和招领,并删掉了“我的招领 / 我的寻物”里原来的模拟数据。中途还主动提交了 pull request,遇到冲突时也会把问题说清楚,最后我们一起把冲突解决掉。

值得学习的地方:

队友的主动性很强,从网页端布局重设计、征求同学意见,到 fork 仓库、提交 pull request,很多推进都不是等我安排,而是他自己往前推。他的问题意识也很好,能发现“详情页返回会直接跳到搜索”“我的招领 / 我的寻物没法修改已发布信息”“个人资料显示和编辑是两套”这些直接关系到功能可用性的细节问题;同时沟通很透明,每改一版都会说明改了什么,冲突了也会讲清楚原因,方便双方一起定位和解决。

需要改进的地方:

协作还可以更仔细点,有次fork 的时间早于主仓最后一次提交,导致后面出现 pull request 冲突、需要覆盖文件再提交;后面我们可以约定“改之前先拉最新代码、按文件分工”,减少合并冲突和重复劳动。

整体来看,队友是一个愿意主动承担、能发现问题、也愿意沟通的合作者,这次能顺利跑通完整流程,他贡献很大。

十一、总结

这次结对编程把第一次的原型真正接上了数据层,完成了“发布 → 浏览/搜索 → 详情 → 联系 → 更新状态”的闭环。最深的体会是:好看的界面只是外壳,数据一致性和状态流转才是功能可用的关键;而把数据层拆出来、统一通过 LostFoundStore 访问,让后续加收藏、编辑、时间筛选都变得简单。

posted @ 2026-10-10 22:48  baiyi060404  阅读(6)  评论(0)    收藏  举报