跳到正文

软件工程第四次作业:第二次结对作业

这个作业属于哪个课程 H202601 软件工程与软件工程实践
这个作业要求在哪里 2026秋软件工程结对作业(第二次之程序实现)
这个作业的目标 将校园失物招领原型实现为可运行的 Web 应用,完成核心业务流程,并通过 GitHub 协作、PSP 和单元测试实践结对开发、版本管理与软件测试。
设计原型稿与在线演示 Figma 设计原型稿 Figma 在线演示
GitHub 仓库 112401237-102401533
结对成员 112401237 吴诗莹、102401533 丁晓炜
队友博客 吴诗莹博客

一、项目简介与结对分工

1.1 项目背景与目标

校园卡、钥匙、耳机和雨伞等物品丢失或被捡到后,学生通常通过班级群、宿舍群或朋友圈发布信息。这些信息容易分散、被新消息覆盖,失主和拾物者也可能不在同一个群聊中。

本项目设计校园失物招领系统,将寻物和招领信息集中展示,帮助用户完成信息发布、搜索、详情查看、联系方式获取、收藏、评论线索和状态修改等操作。项目同时关注手机端使用体验,通过统一的卡片布局、底部导航和状态颜色,让用户能够快速完成核心流程。

1.2 结对分工

🔵分工如下

阶段 吴诗莹 丁晓炜 合作方式
第 1 阶段:需求分析 分析首页浏览、搜索、筛选和详情页需求 分析发布、个人中心和状态管理需求 共同阅读作业要求,确定用户角色、核心页面和功能范围
第 2 阶段:数据与接口设计 确定首页、搜索和详情页需要读取的字段及公共接口 设计寻物、招领数据结构和localStorage存储方式 共同确定字段名称、图片数组、用户身份、更新权限和模块接口
第 3 阶段:核心页面开发 完成首页、搜索结果页、关键词搜索、类型/类别/地点/状态筛选和通用详情页 完成发布页、表单校验、图片上传、发布成功页、“我的”页面和“我的发布”页面 分别在功能分支开发,通过约定的公共数据结构连接页面
第 4 阶段:功能完善 完善详情页图片轮播、发布者信息、联系方式复制和响应式布局 完善个人资料、收藏、归还记录、浏览记录、状态修改、编辑和删除功能 共同完成页面跳转、公共样式、工具函数和模块联调
第 5 阶段:测试与修复 测试搜索、筛选、详情和联系方式复制流程 测试存储、发布、图片、个人中心和状态修改流程 共同使用 Google Chrome 测试,处理接口异常和跨页面数据同步问题
第 6 阶段:协作提交 提交浏览、搜索和详情模块代码,参与合并审查 提交发布、个人中心和状态管理模块代码,参与合并审查 共同完成 GitHub commit、Pull Request、冲突处理、README、PSP、博客和最终验收
说明: 我的队友主要负责浏览、搜索与详情模块,我主要负责发布、个人中心与状态管理模块。双方共同分析作业要求,确定数据结构、公共接口、图片字段和用户权限;开发过程中分别在功能分支完成模块代码,并通过 GitHub Pull Request 进行审查。遇到公共接口未统一或共享文件冲突时,先对照双方需求确认保留内容,再完成合并和浏览器回归测试。最终由两人共同检查页面跳转、数据持久化、图片展示、状态修改、编辑删除和个人资料同步。

1.3 PSP 表格

PSP2.1 阶段 中文说明 预估耗时(分钟) 实际耗时(分钟)
Planning计划3550
Estimate估计任务所需时间3545
Development开发阶段汇总(Analysis 至 Test)9401375
Analysis需求分析,包括学习图片上传、Base64 和本地存储等技术80110
Design Spec生成发布页、个人中心和状态管理设计说明5575
Design Review与队友复核字段、接口和页面跳转设计4060
Coding Standard制定 HTML、CSS、JavaScript 和 Git 提交规范2535
Design设计发布表单、三格图片槽位、个人中心卡片和操作按钮80115
Coding编写发布页、图片上传、个人中心、编辑、状态和删除功能420620
Code Review检查代码结构、文件范围和公共接口调用6090
Test自测发布、图片持久化、编辑、状态修改、删除和刷新流程180270
Reporting编写个人负责模块的报告内容90130
Test Report整理发布、图片和个人中心测试结果5075
Size Measurement统计负责文件、功能点和提交规模2030
Postmortem & Process Improvement Plan总结接口未统一、PR 冲突和提交范围问题,提出改进措施4060
合计12101765
注: 本次 PSP 记录中,预计总耗时为 1210 分钟,实际耗时 1765 分钟,整体比预估多 555 分钟。其中 Coding(+200 分钟)和 Test(+90 分钟)超时较明显,主要是实际开发中出现了图片上传、功能联调、问题排查和反复修改的情况;Analysis、Design 等前期工作也有一定低估。通过这次记录,我认识到自己对调试、返工和测试所需时间估计不足,后续会在前期需求分析和任务估算中预留更多调整与测试时间,使 PSP 真正发挥辅助规划和改进开发过程的作用。
# 二、解题思路描述与设计实现说明

2.1 代码实现思路

本项目采用原生 HTML、CSS 和 JavaScript 实现校园失物招领系统。系统没有使用复杂的后台服务,而是使用浏览器 localStorage 保存数据,从而保证用户发布、编辑和修改状态后,刷新页面数据仍然存在。
项目将数据处理和页面显示分离。js/data.js 作为公共数据模块,负责统一处理物品数据的读取、保存、格式化、校验、发布、编辑和状态更新。首页、搜索页、详情页、发布页和个人中心不直接维护数据,而是通过 window.CampusData 调用公共接口。
每条物品信息都使用统一的数据结构,包括物品名称、信息类型、物品分类、描述、地点、具体位置、联系方式、图片、发布时间和当前状态等字段。发布时间使用 createdAt 保存绝对时间,页面显示时再根据当前日期格式化为“10月6日”等文字,避免日期被永久写死。

系统的主要处理流程如下:

  1. 页面加载时,CampusData.readItems() 从 localStorage 读取数据;
  2. 如果浏览器第一次运行,则加载项目预设的默认帖子;
  3. 首页根据信息类型和物品分类筛选帖子,并按照发布时间从新到旧排序;
  4. 搜索页根据关键词、时间、类型、状态和地点进行多条件筛选;
  5. 用户点击卡片进入详情页,查看描述、地点和联系方式;
  6. 发布者可以在“我的发布”中编辑内容或修改信息状态;
  7. 所有新增、编辑和状态修改操作都会重新写入 localStorage;
  8. 其他页面重新读取公共数据后,自动显示最新结果。
    因此,项目核心是建立一个统一的数据流,让所有页面使用同一份数据,保证发布、浏览、搜索、详情查看和状态维护之间能够连贯工作。

2.2 关键实现的流程图与数据流图

🔵系统核心数据处理与流转图

image

注: 本项目采用“页面脚本—公共数据模块—本地存储”的方式组织核心数据处理逻辑。用户通过点击、输入、选择、提交等操作产生用户操作信息、查询条件或表单数据,由对应的页面脚本进行处理,并统一调用 CampusData 公共数据模块完成数据的读取、创建、编辑、删除、收藏、浏览记录及状态更新等操作。CampusData 与 localStorage 之间采用双向数据流,一方面读取已有的物品信息、收藏记录、浏览记录和用户资料,另一方面将新增或修改后的数据进行本地持久化。操作完成后,相关页面根据操作结果重新读取数据,并重新渲染列表、详情、状态或个人中心等页面,使用户能够及时看到最新结果。通过这种方式,将用户操作、页面逻辑、数据处理、数据持久化和界面更新进行分层,避免各页面直接重复操作 localStorage,提高了代码的复用性和整体结构的清晰度。其中,CampusData 作为项目的数据访问层,将各页面与底层存储解耦,使发布、搜索、详情、编辑、状态管理和个人中心等功能能够共享统一的数据处理逻辑,也为后续功能扩展和维护提供了便利。

🔵核心功能流程图

拾物者视角
失主视角

2.3 重要代码片段

代码片段一:统一数据入口与首次运行初始化

function readItems() {
  if (memoryItems) return memoryItems.map(normalizeItem);

  try {
    const saved = window.localStorage.getItem(ITEMS_STORAGE_KEY);
    if (saved) {
      const storedItems = JSON.parse(saved).map(normalizeItem);
      const storedById = new Map(storedItems.map(item => [item.id, item]));
      const retainedItems = storedItems.filter(item =>
        !LEGACY_SEED_ITEM_IDS.has(item.id) && !SEED_ITEM_IDS.has(item.id)
      );
      const currentDefaultMyPostsVersion = readDefaultMyPostsVersion();
      const needsDefaultMyItems =
        currentDefaultMyPostsVersion < DEFAULT_MY_POSTS_VERSION;
      const missingDefaultMyItems = needsDefaultMyItems
        ? defaultMyItems
          .filter(item =>
            DEFAULT_MY_ITEM_VERSIONS[item.id] > currentDefaultMyPostsVersion &&
            !storedById.has(item.id)
          )
          .map(normalizeItem)
        : [];
      const refreshedSeeds = seedItems.map(seed => {
        const storedSeed = storedById.get(seed.id);
        return normalizeItem({
          ...seed,
          status: storedSeed ? storedSeed.status : seed.status,
          views: storedSeed ? storedSeed.views : seed.views
        });
      });

      const migratedItems = retainedItems
        .concat(missingDefaultMyItems, refreshedSeeds)
        .map(normalizeItem);
      if (needsDefaultMyItems) markDefaultMyPostsInitialized();
      writeItems(migratedItems);
      initializeDefaultActivity(migratedItems);
      return migratedItems;
    }
  } catch (error) {
    console.warn("无法读取本地数据,将使用示例数据。", error);
  }

  const initialItems = defaultMyItems.concat(seedItems).map(normalizeItem);
  writeItems(initialItems);
  return initialItems;
}

readItems() 是所有页面共享的数据入口。它先读取内存缓存,再读取 localStorage;如果是第一次运行或本地数据无法解析,则加载默认帖子并写回本地存储。读取已有数据时,程序保留用户自己的发布内容,只补充缺少的默认帖子,并保留示例帖子的状态和浏览量。这保证了首页、搜索页、详情页和个人中心始终使用同一份数据。

代码片段二:信息状态从“进行中”到“已找到/已归还”

function updateStatus(id, status) {
  const itemId = String(id || "");
  const nextStatus = String(status || "");
  if (!itemId || !["active", "completed"].includes(nextStatus)) {
    return null;
  }

  const items = readItems();
  const item = items.find(entry => entry.id === itemId);
  if (!item) return null;

  const updatedItems = items.map(entry => entry.id === itemId
    ? { ...entry, status: nextStatus }
    : entry
  );

  writeItems(updatedItems);
  return updatedItems.find(entry => entry.id === itemId) || null;
}

这段代码对应作业要求中的“更新信息状态”。用户在“我的发布”中选择新的状态后,页面调用 updateStatus(),公共数据模块先检查物品编号和状态值,再更新对应记录并写入 localStorage。详情页和首页重新读取数据后,会根据 type 和 status 显示“寻找中/已找到”或“待认领/已归还”,从而形成发布后的完整生命周期。

代码片段三:发布信息的完整处理与数据持久化

function writeItems(items) {
  memoryItems = items;

  try {
    window.localStorage.setItem(
      "campus-lost-found-items",
      JSON.stringify(items)
    );
  } catch (error) {
    // localStorage 不可用时保留内存数据
  }
}

function createItem(input) {
  const values = input || {};
  const name = String(values.name || "").trim();
  const contact = String(values.contact || "").trim();

  if (!name || !isValidPhone(contact)) {
    return null;
  }

  const item = {
    id: `item-${Date.now()}`,
    type: values.type === "found" ? "found" : "lost",
    name,
    category: normalizeCategory(values.category),
    description: String(values.description || "").trim(),
    location: normalizeLocation(values.location).name,
    locationDetail: String(values.locationDetail || "").trim(),
    contact,
    images: normalizeImages(values.images),
    status: "active",
    publisherId: "current-user",
    views: 0,
    createdAt: values.createdAt || new Date().toISOString()
  };

  writeItems([item, ...readItems()]);
  return item;
}

这段代码实现了发布信息的完整处理流程。系统首先读取用户在表单中填写的物品名称、信息类型、分类、描述、地点、联系方式和图片等内容,然后对必填字段和手机号格式进行校验,并对分类、地点和图片数据进行统一标准化处理。校验通过后,系统会自动生成唯一的物品编号,将信息状态设置为 active,并记录真实的发布时间。新发布的帖子会被添加到现有数据的最前面,同时更新内存数据和 localStorage。因此,发布成功后,首页、搜索结果页、详情页和个人中心都可以读取并显示同一条最新信息。

三、特色设计与项目展示

特点一:搜索多维筛选与首页分类浏览

1️⃣设计创意与意义

校园失物招领场景中,无论是想找回失物的失主,还是希望核对失主的拾金者,其寻找习惯主要分为两类:无明确目标的“粗粒度浏览”与带着特定线索的“精确查找”。项目据此设计了两条互补路径:

  • 面向粗粒度浏览的首页分类:通过横向滚动的“全部 / 寻物 / 招领”类型切换,以及“证件、数码、钥匙”等常见类目,让用户无需输入关键词即可快速下钻。无论是失主想随便看看,还是拾金者想主动核对“有没有人在找我捡到的物品”,都能轻松降低查找门槛。
  • 面向精确查找的搜索多维筛选:在关键词搜索的基础上,提供时间(如一周内)、状态(进行中/已完成)、地点区域(教学区/餐厅等)及排序方式的组合筛选,满足高阶精准定位的需求。

该设计通过“轻量分类 + 深度筛选”的双轨并行路径,它让用户在不同阶段按照需求接触信息,大幅提升了信息流转效率与用户体验。

2️⃣实现思路

  • 首页分类浏览的实现
    首页使用 currentFilter 保存当前的信息类型,使用 currentCategory 保存当前的物品分类。用户点击按钮时更新这两个变量,然后调用 render() 重新筛选和显示卡片。所有物品都通过 CampusData.readItems() 读取,筛选完成后统一使用 CampusCard.itemCard() 生成信息卡片。这样首页的数据显示和其他页面保持一致。
  • 搜索多维筛选的实现
    搜索页先读取关键词,再计算每条信息与关键词的匹配程度。物品名称完全匹配时优先级最高,名称部分匹配和分类匹配的优先级依次降低。在得到关键词结果后,程序继续根据时间、类型、状态和地点进行过滤,最后按照用户选择的方式排序并显示结果。筛选条件都保存在 filters 对象中,点击“重置”时恢复默认值。

3️⃣关键代码片段及解释

  • 首页分类与类型的组合筛选
function render() {
  const items = CampusData.readItems()
    .map((item, index) => ({ item, index, day: CampusData.getItemDayTimestamp(item) }))
    .filter(entry => {
      const matchesType = currentFilter === "all" || entry.item.type === currentFilter;
      const matchesCategory = currentCategory === "all" || entry.item.category === currentCategory;
      return matchesType && matchesCategory;
    })
    .sort((first, second) => {
      if (Number.isFinite(first.day) && Number.isFinite(second.day) && first.day !== second.day) return second.day - first.day;
      if (Number.isFinite(first.day) && !Number.isFinite(second.day)) return -1;
      if (!Number.isFinite(first.day) && Number.isFinite(second.day)) return 1;
      return first.index - second.index;
    })
    .map(entry => entry.item);
  grid.innerHTML = items.map(CampusCard.itemCard).join("");
  empty.classList.toggle("hidden", items.length > 0);
  count.textContent = `${items.length} 条信息`;
}

注:通过独立维护类型与分类两个状态条件,支持“寻物 + 数码”等组合查询;结合时间戳排序与公共卡片渲染函数,保证列表顺序合理、样式统一。

  • 搜索结果多维过滤与排序
let items = CampusData.readItems()
  .map(item => ({ item, score: getKeywordScore(item, key) }))
  .filter(result => result.score > 0)
  .filter(result => timeMatches(result.item, filters.time))
  .filter(result => typeMatches(result.item, filters.type))
  .filter(result => statusMatches(result.item, filters.status))
  .filter(result => locationMatches(result.item, filters.location));
if (filters.sort === "newest") {
  items.sort((a, b) => getItemTime(b.item) - getItemTime(a.item) || b.score - a.score);
} else if (filters.sort === "views") {
  items.sort((a, b) => (b.item.views || 0) - (a.item.views || 0) || b.score - a.score);
} else {
  items.sort((a, b) => b.score - a.score || getItemTime(b.item) - getItemTime(a.item));
}

注:采用链式 filter 依次应用时间、类型、状态和地点条件,并将关键词匹配得分 score 作为排序依据或平局参考,确保结果既符合筛选条件,又保持较高的相关度。

4️⃣实现展示

🔵 演示动图如下





搜索多维筛选





首页分类浏览

特点二:“我的页面”一体化管理

1️⃣ 设计创意与意义

“我的页面”并不只是个人资料展示页,而是用户管理失物信息的集中入口。用户可以在这里查看自己的 收藏 → 浏览记录 → 归还记录,也可以对本人发布的信息执行 编辑 → 删除 → 修改状态,同时还能修改个人资料。这些原本分散在不同页面中的操作被集中到一个入口,用户不需要反复寻找功能。发布信息后,后续处理过程也能在这里完成:

发布信息 → 信息传播 → 找到/归还 → 更新状态 → 历史留存

其中,编辑功能是这个闭环中的重要环节。用户发布信息后,如果发现物品名称、特征描述、图片、地点或联系方式填写错误,可以直接进入编辑页面修改,不需要删除原帖后重新发布。修改后的内容会同步到首页、搜索结果、详情页和“我的发布”,从而保证不同页面读取到的数据保持一致。

2️⃣ 实现思路

系统使用统一的数据模块保存物品、收藏和浏览记录,并通过 publisherId 判断发布信息的归属。进入“我的页面”后,程序读取当前用户的发布记录,同时统计收藏、浏览和归还数据,再将结果显示到对应入口中。

本人发布的每张信息卡片都带有“修改状态”“编辑”和“删除”三个操作。点击编辑后,页面根据 URL 中的物品 ID 读取原数据,并自动填充类型、名称、分类、图片、描述、日期、地点和联系方式。编辑页面沿用发布页的布局与地点选择组件,用户不需要重新适应另一套操作方式。

保存前会再次检查日期和手机号。图片可以保留、替换、补充或删除,新增图片通过 FileReader 转为 Data URL 后与其他字段一起交给 CampusData.updateItem()。数据模块完成权限检查后,同时更新内存缓存和 campus-lost-found-items,因此首页、搜索结果、详情页和“我的发布”都能读取到修改后的内容。

删除操作带有二次确认。确认删除后,系统还会清理该物品对应的收藏和浏览记录,避免列表中留下已经失效的数据。

3️⃣ 关键代码片段及解释

第一段代码负责筛选当前用户发布的信息,并按日期从新到旧排列:

function getMyItems(publisherId) {
  const ownerId = String(publisherId || "current-user");

  return readItems()
    .filter(item => item.publisherId === ownerId)
    .map((item, index) => ({
      item,
      index,
      day: getItemDayTimestamp(item)
    }))
    .sort((first, second) => {
      if (Number.isFinite(first.day) && Number.isFinite(second.day) && first.day !== second.day) return second.day - first.day;
      if (Number.isFinite(first.day) && !Number.isFinite(second.day)) return -1;
      if (!Number.isFinite(first.day) && Number.isFinite(second.day)) return 1;
      return first.index - second.index;
    })
    .map(entry => entry.item);
}

publisherId 用来区分信息归属,只有当前用户自己的发布内容会进入“我的发布”。排序时先比较发布时间;日期相同则保留原有顺序,避免列表在刷新后无故跳动。

第二段代码是编辑接口的核心。它先检查用户身份和物品归属,再更新允许修改的字段,最后通过 writeItems() 保存:

function updateItem(id, input, publisherId) {
  const itemId = String(id || "");
  const ownerId = String(publisherId || "current-user").trim();
  if (!itemId || ownerId !== "current-user") return null;

  const values = input || {};
  const items = readItems();
  const current = items.find(item => item.id === itemId);
  if (!current || current.publisherId !== "current-user") return null;

  const has = key => Object.prototype.hasOwnProperty.call(values, key);
  const next = { ...current };

  if (has("name")) {
    const name = String(values.name || "").trim();
    if (name) next.name = name;
  }
  if (has("description")) {
    next.description = String(values.description || "").trim();
  }
  if (has("images")) {
    next.images = normalizeImages(values.images, DEFAULT_MY_ITEM_IDS.has(itemId));
  }

  const updatedItems = items.map(item => item.id === itemId ? next : item);
  writeItems(updatedItems);
  return next;
}

接口只接受当前用户的编辑请求,其他用户或示例信息都会被拒绝。writeItems() 同时刷新内存数据和 localStorage,所以保存后进入首页、详情页或“我的发布”都能看到最新内容,物品 ID、发布人和浏览量等字段则保持不变。

4️⃣ 实现展示

🔵 “我的页面”功能如下

编辑、删除和修改状态功能演示
编辑 → 删除 → 修改状态
收藏、浏览记录和归还记录功能演示
收藏 → 浏览记录 → 归还记录

两组动图分别展示了“我的页面”的信息管理与记录查看功能:

  1. 编辑 → 删除 → 修改状态:用户可以修改已发布信息中的文字、图片、地点和联系方式,也可以删除无效信息,或在物品找到、归还后更新信息状态;
  2. 收藏 → 浏览记录 → 归还记录:用户可以集中查看自己收藏过、浏览过以及已经完成归还的信息,便于后续查找和管理。

其他小设计

除了以上两个特点外,系统还针对实际使用过程进行了多处细节优化,使整体操作更加完整,细节优化如下:

  • “我的页面”选择地点时采用二级分类展开,使校园大量地点更加清晰
  • 首页下滑浏览了很多帖子后,可以双击“我的”回到顶部,减少页面较长时的返回操作
  • 对手机号进行严格的 11 位格式校验以保证数据规范,同时提供联系方式一键复制功能
  • 在搜索页提供历史搜索记录保存与热门搜索词推荐,方便用户快速获取高频或近期关注的关键词,减少重复输入成本
  • 可以在“我的”页面更改个人信息

四、项目结构与运行说明

4.1 项目目录结构

以下目录根据最新版代码整理。项目将页面、样式、脚本和图片素材分开管理,示例物品图片数量较多,因此不逐一展开:

112401237-102401533/
├── index.html                         # 首页
├── assets/
│   ├── campus-map.jpg                 # 校园地图
│   ├── default-profile-avatar.png     # 默认头像
│   ├── home-hero.png                  # 首页顶部图片
│   └── seed-items/                    # 示例物品图片
├── css/
│   ├── common.css                     # 公共布局与底部导航
│   ├── design-system.css              # 颜色、字体等设计变量
│   ├── detail.css                     # 详情页样式
│   ├── home.css                       # 首页样式
│   ├── profile.css                    # “我的”及关联页面样式
│   ├── publish.css                    # 发布、编辑及发布成功页样式
│   └── search.css                     # 搜索与筛选页样式
├── js/
│   ├── data.js                        # 公共数据、LocalStorage 与业务接口
│   ├── card.js                        # 通用物品卡片
│   ├── home.js                        # 首页分类与列表渲染
│   ├── search.js                      # 搜索、筛选与排序
│   ├── detail.js                      # 详情、收藏、复制及浏览记录
│   ├── publish.js                     # 发布表单、地点与图片处理
│   ├── profile.js                     # 个人中心、收藏、归还及删除
│   ├── edit-post.js                   # 本人发布信息编辑
│   ├── status.js                      # 信息状态修改
│   ├── contact.js                     # 个人资料编辑与同步
│   └── browse-history.js              # 浏览记录
├── pages/
│   ├── search.html                    # 搜索与筛选
│   ├── detail.html                    # 信息详情
│   ├── publish.html                   # 发布信息
│   ├── publish-success.html           # 发布成功
│   ├── profile.html                   # “我的”与“我的发布”
│   ├── edit-post.html                 # 编辑本人发布
│   ├── status.html                    # 修改信息状态
│   ├── favorites.html                 # 我的收藏
│   ├── return-history.html            # 归还记录
│   ├── browse-history.html            # 浏览记录
│   └── contact.html                   # 个人信息
├── README.md                          # 模块与项目说明
└── member-b-module-spec.md            # 成员 B 模块接口说明

目录组织说明如下:

  • index.html 和 pages/ 存放页面结构。根目录的 index.html 是首页入口,pages/ 存放搜索、详情、发布、编辑、状态、个人中心及记录页面;
  • css/ 按公共样式和页面模块拆分。common.css、design-system.css 负责全站通用样式,其余文件负责详情、首页、搜索、发布和个人中心等页面;
  • js/ 按数据层、页面脚本和公共卡片组件拆分。data.js 是各页面共用的数据入口,物品查询、发布、编辑、删除、状态修改、收藏和浏览记录等操作都通过 window.CampusData 完成;
  • assets/ 存放校园地图、首页图片、头像和示例物品图片等静态资源;
  • 项目使用原生 HTML、CSS 和 JavaScript,没有引入 Vue、React 等第三方前端框架,也不依赖后端数据库。项目说明、需求说明和模块接口说明放在根目录,方便测试人员了解数据约定。

页面不各自维护物品副本,而是共用 js/data.js 的数据接口,避免首页、详情页和“我的页面”出现数据不一致。

“我的发布”已经整合到 profile.html,最新版中不再使用单独的 my-posts.html。发布和编辑页面共用 publish.css 及地点、图片处理逻辑,详情和搜索页面共用卡片与数据接口。

4.2 项目运行说明

项目使用原生 HTML、CSS 和 JavaScript 编写,没有引入 Vue、React 等第三方前端框架,也没有 package.json,因此不需要执行 npm install。项目不需要后端服务、数据库或第三方账号。测试环境建议使用安装了 Python 3 和 Google Chrome 的电脑。若电脑已有其他静态 HTTP 服务器,也可以使用同等功能的工具。

项目提供两种运行方式。方案一不需要启动服务器,也不需要占用端口;方案二使用本地 HTTP 服务器,适合浏览器对 file:// 页面限制较严格的情况。

4.2.1 方案一:直接打开 index.html

  1. 解压项目压缩包;
  2. 打开解压后的 112401237-102401533-main 文件夹;
  3. 双击根目录中的 index.html;
  4. 浏览器打开首页后即可使用系统。

项目页面之间使用相对路径连接,图片、样式和 JavaScript 文件均使用项目内的相对路径,因此不需要额外安装依赖或配置环境。页面跳转、发布、编辑、收藏、浏览记录和个人资料等功能都可以通过这种方式操作。

4.2.2 方案二:使用本地端口运行

如果某些浏览器对 file:// 页面限制较严格,可以在包含 index.html 的项目根目录打开 PowerShell,使用本地服务器运行:

py -m http.server 4179

然后访问:

http://127.0.0.1:4179/index.html

如果系统无法识别 py,可以改用:

python -m http.server 4179

若选择服务器方式且 4179 端口已被占用,可以换成其他端口,例如:

py -m http.server 4180

此时访问地址也要同步改为:

http://127.0.0.1:4180/index.html

修改代码后,无论采用哪种方式,都可以按 Ctrl + F5 强制刷新。

4.2.3 测试人员操作说明

测试人员可以按以下流程复现主要功能:

  1. 打开首页,在“全部、寻物、招领”之间切换,并按证件、数码、钥匙、生活用品和书籍等分类浏览;
  2. 进入搜索页,输入物品名称,并组合测试时间、类型、状态、地点和排序条件;
  3. 打开任意物品详情,检查图片、具体位置、发布者、联系方式、复制按钮和收藏功能;
  4. 点击底部“发布”,选择“寻物”或“招领”,填写所有必填项;
  5. 在最多三个图片槽位中上传图片。已选择的图片可以删除并重新上传;不上传图片时,列表使用默认蓝色正方体图标;
  6. 选择日期和校内地点,填写具体位置及 11 位手机号后发布,确认系统进入发布成功页;
  7. 从发布成功页打开详情,检查刚发布的名称、图片、地点、联系方式和发布者信息;
  8. 进入“我的”,查看发布数量及进行中、已完成的统计,随后测试“修改状态”“编辑”和“删除”;
  9. 在个人信息页修改用户名、大学、学院、年级和联系方式,保存后检查个人中心及本人发布详情是否同步更新;
  10. 分别进入“我的收藏”“浏览记录”和“归还记录”,确认相应数据能够正常显示;
  11. 刷新浏览器并重新进入页面,确认发布、编辑、收藏和个人资料等数据仍然保留。

4.2.4 本地数据与重置说明

项目数据保存在浏览器当前站点的 localStorage 中,主要包括物品信息、个人资料、收藏、浏览记录和搜索历史。浏览器会把不同端口视为不同站点,因此从 4179 改到 4180 后,原端口保存的数据不会自动出现。如需恢复初始状态,可打开 Chrome 开发者工具,进入:

Application → Local Storage → http://127.0.0.1:4179 → Clear

清除后刷新页面,系统会重新加载默认示例数据。用户上传的图片以 Data URL 形式保存在浏览器中,测试时应选择体积较小的 JPG、PNG 或 WebP 图片,避免超过 localStorage 的容量限制。

五、单元测试

根据作业要求,单元测试文件不提交到 GitHub。本节测试在最终版本代码的本地副本中完成,测试对象与仓库中的业务代码一致。博客仅展示测试方法、代表性代码片段、测试数据设计和运行结果截图。

5.1 测试工具与学习过程

本项目使用 Vitest 作为单元测试框架,使用 jsdom 模拟浏览器环境,并通过 @vitest/coverage-v8 统计代码覆盖率。

选择这套工具主要考虑了项目的实际结构。项目由原生 HTML、CSS 和 JavaScript 编写,没有使用 Vue、React 等框架;核心业务集中在 js/data.js,其中又依赖 window 和 localStorage。Vitest 可以直接测试普通 JavaScript,jsdom 则提供测试所需的浏览器对象,不需要为了测试重写项目。

学习时先阅读了 Vitest Getting Started 和 Test Environment,了解 describe、it、expect、测试生命周期和 jsdom 环境;然后查阅 Coverage,加入 V8 覆盖率报告。实际编写时先测试 isValidPhone() 这类无状态函数,再处理 localStorage、模块缓存和日期边界。这样出现失败时比较容易判断是断言错误、测试数据污染,还是业务逻辑本身有问题。

5.1.1 简易教程

环境要求:已安装 Node.js 和 npm。本项目测试时使用 Node.js 24.18.0。为了不影响仓库签入记录,我们先从 GitHub 下载最终版本代码,在仓库外复制出一份本地测试副本,再把测试配置和测试文件放入该副本。

  1. 进入本地测试副本的根目录,安装测试依赖:
npm install
  1. 运行全部测试:
npm test
  1. 修改测试时使用监听模式:
npm run test:watch
  1. 生成覆盖率报告:
npm run test:coverage
  1. 浏览器打开本地生成的 coverage/index.html,可以查看每个函数和分支的覆盖情况。node_modules/、coverage/、tests/ 和测试配置均只保存在本机,不执行 git add、git commit 或 git push。

一个最小的 Vitest 用例如下:


import { describe, expect, it } from "vitest";

describe("手机号校验", () => {
  it("接受合法的 11 位手机号", () => {
    expect(CampusData.isValidPhone("13800138000")).toBe(true);
  });
});

项目的数据模块会把数据缓存在闭包变量中,所以仅调用 localStorage.clear() 还不够。测试辅助函数会先执行 vi.resetModules(),再重新加载 js/data.js:


export async function loadCampusData() {
  vi.resetModules();
  delete window.CampusData;
  delete globalThis.CampusData;
  await import("../../js/data.js");
  globalThis.CampusData = window.CampusData;
  return window.CampusData;
}

这样每个测试都从独立状态开始,不会因为前一个用例创建或删除了物品而产生偶发失败。

5.2 单元测试代码与测试对象

本地测试副本中的测试代码放在 tests/ 目录中,该目录未提交到 GitHub:

tests/
├── helpers/
│   └── load-campus-data.js
├── setup.js
├── data-validation.test.js
├── data-api.test.js
├── item-crud.test.js
├── date-activity.test.js
└── publish-validation.test.js

总共34个用例,各测试文件的职责如下:

测试文件 测试对象 用例数
data-validation.test.js 手机号、必填字段、分类和地点规范化、图片过滤、北京时间转换、损坏存储数据降级 11
data-api.test.js 公共查询接口、状态文字、图片路径、编辑字段、旧日期兼容、半年筛选和失败分支 9
item-crud.test.js 发布信息创建、读取、编辑、状态修改、删除、权限和个人资料同步 6
date-activity.test.js 自然日筛选、日期显示、收藏切换、浏览去重和关联数据清理 5
publish-validation.test.js 发布表单必填项、未来日期和手机号校验 3

下面是编辑权限测试的节选:


it("拒绝其他用户修改本人信息,也拒绝修改其他人的信息", () => {
  const created = data.createItem(validItem({ id: "item-unit-owner" }));

  expect(data.updateItem(
    created.id,
    { name: "越权修改" },
    "other-user"
  )).toBeNull();

  expect(data.updateItem(
    "seed-id-card-found",
    { name: "修改示例信息" },
    "current-user"
  )).toBeNull();
});

这个用例测试 updateItem(id, values, publisherId) 的权限边界。第一条断言模拟其他用户修改当前用户的帖子;第二条断言模拟当前用户修改系统示例帖子。两种操作都应返回 null,原数据不能变化。

下面是日期边界测试:


it("按照北京时间自然日判断当天和最近一周", () => {
  const now = "2026-10-06T12:00:00+08:00";
  const today = { createdAt: "2026-10-06T00:01:00+08:00" };
  const weekStart = { createdAt: "2026-09-30T23:59:00+08:00" };
  const tooOld = { createdAt: "2026-09-29T23:59:00+08:00" };

  expect(data.matchesTimeRange(today, "day", now)).toBe(true);
  expect(data.matchesTimeRange(weekStart, "week", now)).toBe(true);
  expect(data.matchesTimeRange(tooOld, "week", now)).toBe(false);
});

这里没有用“距离现在不超过 24 小时”代替自然日,而是把时间固定在北京时间,并分别测试当天零点后、最近一周起点和起点前一分钟。

5.2.1 测试结果

在本地测试副本根目录依次执行普通测试和覆盖率测试:

npm test
npm run test:coverage

运行结果和覆盖率统计如下图所示:

70c1823098264e3af2f1e4063bc2147d

图中上半部分是 npm test 的运行结果,下半部分是 npm run test:coverage 生成的 V8 覆盖率统计。黄色的 jsdom 内容是测试环境性能建议,不是测试错误,不影响测试结果。

最终测试结果如下:

测试项目 数量 结果
测试文件 5 全部通过
测试用例 34 全部通过

本次共运行 5 个测试文件、34 个测试用例,测试结果全部通过。

核心数据模块 js/data.js 的覆盖率如下:

指标 覆盖数量 覆盖率
Statements 354 / 403 87.84%
Branches 255 / 370 68.91%
Functions 70 / 77 90.90%
Lines 304 / 327 92.96%

覆盖率只统计 js/data.js。发布表单校验虽然也有单元测试,但页面脚本包含文件读取、DOM 事件和跳转等交互逻辑,把它们和数据层混合统计会使结果难以解释。页面完整操作另外通过浏览器手动回归测试验证。

以上测试文件、依赖和覆盖率报告均保存在仓库外的本地测试副本中,没有计入 GitHub 代码签入记录。

5.3 测试数据构造思路与异常情况

5.3.1 测试数据的构造思路

本次测试数据主要采用白盒测试、等价类划分和边界值分析相结合的方法进行构造。首先阅读 js/data.js 和 js/publish.js,找出函数中的判断条件、数据流向和异常处理分支,再针对每条主要执行路径设计测试数据。测试时不仅检查函数的返回值,还检查 localStorage 中的数据和相关状态是否发生了正确变化。

在具体构造过程中,我们先将输入划分为有效和无效等价类,再重点测试临界值。例如,手机号分为合法号码、错误号段、长度不足、长度超出和包含字母;图片数量覆盖 0 张、1 张、3 张和超过 3 张;日期覆盖当天、一周边界前后和未来日期。这样既能减少重复测试,也能覆盖程序最容易出错的位置。

白盒测试主要体现在以下几个方面:

  1. 语句覆盖:执行创建、读取、编辑、删除、收藏、浏览记录和个人资料同步等主要代码;
  2. 分支覆盖:同时测试合法与非法手机号、本人和非本人权限、存在与不存在的物品、有效与无效图片、正常与损坏的本地数据;
  3. 条件覆盖:针对 active、completed,lost、found 以及不同日期范围分别构造输入;
  4. 异常路径:主动写入错误 JSON、非法图片地址、未来日期和不存在的 ID,确认程序能够返回失败结果或使用默认数据,而不是直接崩溃。

5.3.2 对各种情况的考虑

为了尽可能覆盖系统在实际使用中可能遇到的情况,我们从正常输入、边界输入、异常输入、重复操作、用户权限和状态一致性六个方面设计测试。

  1. 正常情况:分别测试寻物和招领两种信息类型、active 和 completed 两种状态,以及合法的手机号、图片、分类和地点。图片覆盖 PNG、JPEG、WebP 三种允许类型,分类和地点既测试标准输入,也测试开发过程中遗留的数据格式,验证程序升级后仍能正确读取已有的本地数据。
  2. 边界情况:手机号测试 10 位、11 位、12 位、非法号段和字母混入;图片测试 0 张、1 张、3 张和超过 3 张;日期测试当天 00:01、最近一周起点、起点前一分钟和未来日期。
  3. 异常情况:向 localStorage 写入无法解析的 JSON,使用不存在的物品 ID 调用更新、删除、收藏和浏览接口,并传入 javascript: 地址、外部图片 URL、空标题、空地点和空描述等非法数据。
  4. 重复操作:连续记录两次浏览量,验证同一浏览器只增加一次;连续点击两次收藏,验证第二次能够正确取消收藏,避免生成重复记录。
  5. 权限情况:尝试以其他用户身份修改当前用户的信息,也尝试修改不属于当前用户的帖子,确认权限检查能够拒绝越权操作。
  6. 状态一致性:删除帖子后不仅检查“我的发布”,还同时检查物品数据、收藏列表和浏览记录;修改个人资料后,既检查本人旧帖子是否同步更新,也检查其他用户的数据是否保持不变。

5.3.3 对测试人员“刁难”的应对

我们假设测试人员不会只按照正常流程操作,还可能故意输入空字符串、错误格式和超出限制的数据,或者进行重复点击、越权修改、访问不存在的信息以及破坏本地存储等操作。因此,测试中主动加入了非法手机号、未来日期、超过三张图片、错误 JSON、非法图片地址、不存在的物品 ID、重复收藏和非本人发布信息等数据。

对于这些“刁难”输入,我们不仅检查程序是否返回失败结果,还检查它是否会产生未处理异常、破坏原有数据或留下不一致的关联记录。例如,本地数据损坏时应自动使用默认数据;越权修改时应返回 null,并保证原信息不发生变化;图片超过上限时应只保留规定数量;删除帖子时应同时清理相关收藏和浏览记录。通过这些测试,即使测试人员故意采用极端输入或非正常操作,系统也能够保持稳定、保护原有数据并给出明确结果。

5.3.4 测试范围说明

本次单元测试主要验证数据校验、数据读写、权限控制、日期处理和状态变化等业务逻辑。覆盖率数据及测试运行结果已在 5.2.1 测试结果 中展示。
响应式布局、按钮位置、图片视觉裁剪和页面跳转等内容属于界面表现和端到端交互,难以仅通过数据层单元测试判断,因此我们另外使用浏览器进行人工回归测试。单元测试与人工测试相结合,可以更完整地验证系统功能。

六、协作过程

6.1 Github的代码签入

🔵一部分commit截图

🔵PR记录截图

6.2 遇到的问题与解决方法

在结对开发过程中,我们遇到的主要困难并不是某一个页面的样式问题,而是两个独立模块在接口和代码合并阶段无法顺利对接。以下两个问题对开发进度影响最大,也最能反映本次结对协作中的不足与改进过程。

6.2.1 模块间公共数据接口未提前统一

问题描述

项目开始时,我们先按照页面划分任务:我的队友主要负责首页、搜索结果、详情页和公共数据模块,我主要负责发布页、个人中心、我的发布和编辑页面。虽然页面分工比较明确,但双方没有在编码前完整约定公共数据结构和接口。

例如,我开发“我的发布”编辑功能时,需要修改已经存在的物品信息,因此编辑页需要调用:

CampusData.updateItem(id, values, publisherId)

但是我的队友负责的公共 js/data.js 中最初没有 updateItem() 接口。同时,图片字段是否统一使用 images、用户身份如何通过 publisherId 判断、个人资料修改后是否同步已有帖子,以及数据最终使用哪个 localStorage 键名,都没有在一开始确定。

这导致部分页面单独运行时没有问题,但合并后出现了以下情况:

  • 编辑页能够修改输入框内容,但详情页仍读取旧数据;
  • 发布页已经生成 images 数组,但公共数据层不能完整保存;
  • 个人资料已经修改,详情页仍显示旧用户名和联系方式;
  • 如果双方分别创建数据文件或存储键,首页、详情页和个人中心将无法共享同一条记录。

做过哪些尝试

发现问题后,我们没有继续在各自页面中直接操作 localStorage,而是重新明确了模块职责:

发布页和编辑页:负责收集、校验并提交数据
公共数据模块:负责创建、更新、删除和持久化
首页、详情页和个人中心:负责重新读取并展示数据

随后共同确认了以下接口和规则:

  • 所有物品统一保存到 campus-lost-found-items;
  • 图片统一使用 images 数组,最多保存三张 Base64 图片;
  • 本人发布的数据统一使用 publisherId: "current-user";
  • 编辑功能通过 CampusData.updateItem() 完成,不在编辑页单独写入数据;
  • 只允许修改本人发布的信息,不能修改其他用户的数据;
  • 更新物品时保留 id、createdAt、views 和发布者身份等不应改变的字段。

我的队友在公共 js/data.js 中补充 CampusData.updateItem(id, values, publisherId),我在编辑页中按照统一参数调用该接口。完成后又测试了文字修改、地点修改、图片替换、权限判断和刷新后的数据持久化。

是否解决

该问题已经解决。本人发布的信息能够通过公共接口进行更新,其他用户的信息不会被误改;首页、详情页、个人中心和编辑页也能够读取同一个存储键中的数据。

有何收获

我们认识到,结对开发不能只划分“谁负责哪个页面”,还必须先约定页面之间如何交换数据。公共字段、接口名称、参数格式、权限规则和存储位置都应该在编码前确定。以后进行类似协作时,应先写一份简短的接口说明,再分别开发页面,这样可以减少重复实现和合并后的功能失效。

6.2.2 共享文件重复修改导致 Pull Request 无法合并

问题描述

在分支开发阶段,我们认为双方负责不同模块,因此各自在自己的功能分支上持续修改。但是个人中心、个人资料同步和公共展示逻辑会同时涉及多个页面,最终双方都修改了部分共享文件。

实际提交 Pull Request 后,GitHub 显示:

This branch has conflicts that must be resolved

冲突文件包括:

css/profile.css
js/profile.js

此外,pages/profile.html 等文件也存在双方都需要调整的情况。虽然 Git 允许两个人修改同一个文件,但当两个分支修改了同一代码区域,GitHub 无法判断应该保留哪一份内容,因此 Merge 按钮会被禁用。

做过哪些尝试

我们先检查 Pull Request 中列出的冲突文件,再通过 git status、git diff 和提交记录确认双方分别修改了哪些内容。处理冲突时没有简单选择“全部保留自己的代码”或“全部采用队友代码”,而是逐段比较:

  • 保留我完成的个人中心布局、发布卡片操作和个人资料入口;
  • 保留我的队友完成的公共数据读取和需要与详情页同步的逻辑;
  • 删除 Git 生成的 <<<<<<<、=======、>>>>>>> 冲突标记;
  • 检查合并后的 HTML、CSS 和 JavaScript 是否仍然完整;
  • 重新测试“我的发布—编辑—修改状态—删除—查看详情”的完整流程。

此后,我们在每次提交前增加了以下检查:

git status
git diff
git diff --staged

并且只对本次任务涉及的文件执行 git add。对于公共文件,修改前先说明原因和预计影响;Pull Request 描述中也写明“修改了哪些文件、没有修改哪些文件”,方便另一位成员审查。

是否解决

该问题已经解决。冲突代码被人工确认并合并,Pull Request 恢复为可合并状态。合并后,两名成员共同进行回归测试,重点检查页面跳转、公共数据、图片上传、状态修改、编辑删除和个人资料同步,确保各模块能够使用同一份数据正常运行。

有何收获

我们认识到,“分工不同”并不意味着不会发生冲突。只要两个分支修改同一个文件的相同区域,就可能导致 Pull Request 无法自动合并。后续协作应尽量明确共享文件的维护者,及时同步主分支,减少长时间并行修改;提交也应保持范围清晰、内容单一,从而降低代码审查和冲突处理的难度。

通过这次问题,我们形成了更明确的协作顺序:先统一接口和文件归属,再分别开发;提交前检查暂存范围;创建 Pull Request 后及时审查;合并完成后执行跨页面回归测试。

七、评价我的队友

7.1 值得学习的地方

本次结对开发整体比较顺利。我认为她值得学习的地方是对细节的重视,我的队友在页面设计和功能分析方面给了我很多帮助。她对界面细节比较敏感,在协作过程中会主动检查页面中的小问题,例如返回箭头与文字的对齐、按钮之间的间距、图片是否超出卡片范围、控件颜色是否统一等。很多问题虽然不影响基本功能,但经过她的检查和调整后,页面的完整性和视觉效果都有所改善 b( ̄▽ ̄)d。

她对网页整体风格也有比较清晰的想法。项目最初的首页较为空白,她提出加入校园主题背景和统一的视觉元素,使首页更符合校园失物招领的使用场景。在博客整理方面,她也给了我很多帮助。她不仅会检查内容是否完整,还会主动调整标题层级、表格、图片和代码块的排版。我之前以为博客园只能使用 Markdown 编辑,后来才知道博客园也支持直接嵌入 HTML,也是学到了小巧思,优点多多。

7.2 需要改进的地方

需要改进的地方是,我们在项目早期可以更早确定公共接口、文件分工和分支协作规则,这算是我们的缺点,应该在早期协商好。之前由于部分文件的修改范围没有提前记录清楚,出现过同一文件被多人修改、PR 操作无法直接合并的情况。当然,这次的经验也为后续提供了思路:后续可以在开发开始前先列出文件负责人、接口参数和提交规范,并在每次合并前进行一次简短的变更确认,这样可以减少重复修改和冲突。

八、项目开发总结

本次结对开发完成了校园失物招领 Web 系统,实现了信息发布、图片上传、搜索筛选、详情查看、收藏、浏览记录、状态修改、编辑、删除和个人资料同步等功能。“我的页面”整合了个人发布、收藏、浏览记录和归还记录。用户修改个人资料后,已有发布和后续新发布的信息都会同步更新。

开发过程中,我们遇到过公共数据接口不统一、详情页读取旧数据、编辑图片无法保存以及 GitHub Pull Request 合并冲突等问题。通过统一 CampusData 接口、明确 publisherId 和文件分工、检查分支变更范围,我们解决了这些问题,也加深了对 Git 命令、分支管理和 GitHub 协作流程的理解。

项目使用 Vitest、jsdom 和 V8 Coverage 进行本地单元测试,共包含 5 个测试文件和 34 个测试用例,全部通过。项目不需要后端服务,测试人员可以直接双击 index.html 运行,也可以使用 Python 启动本地服务器。

通过本次作业,我熟悉了静态网页开发、LocalStorage 数据管理、GitHub 协作和单元测试流程,也认识到接口约定、分支管理和测试验证在结对开发中的重要性。这次经历为之后的团队合作打下了基础,也让我更清楚地认识到自己在沟通、代码规范和协作流程方面的不足,并从队友身上学习了页面设计、细节检查和文档排版等优点。

国庆做软工燃尽了:
image

感谢观看!奖励你一只可爱的猫咪🐱:

结尾猫咪
posted @ 2026-10-07 15:20  ddddal  阅读(45)  评论(3)    收藏  举报
ddddal 的头像