软件工程作业4:一拾一得,同码同行

这个作业属于哪个课程 H202601 软件工程与软件工程实践
这个作业要求在哪里 2026秋软件工程结对作业(第二次之程序实现)
这个作业的目标 将校园失物招领原型实现为可运行的 Web 应用,完成核心业务流程,并通过 GitHub 协作、PSP 和单元测试实践结对开发、版本管理与软件测试。
设计原型稿与在线演示 Figma 设计原型稿 Figma 在线演示
GitHub 仓库 112401237-102401533
结对成员 112401237 吴诗莹、102401533 丁晓炜
队友博客 丁晓炜博客
写在最前: 博客撰写严格参照评分规则部分,有覆盖所有要点。演示gif录制鼠标点击有小黄光(用于让观看者看清鼠标点击位置)但实际web网页运行点击时无小黄光。

一、项目简介与结对分工

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 计划 35 50
Estimate 估计任务所需时间 35 45
Development 开发阶段汇总(Analysis 至 Test) 960 1390
Analysis 需求分析,包括 Figma 原型转网页、搜索和详情需求 80 110
Design Spec 生成首页、搜索、筛选和详情页设计说明 60 85
Design Review 与队友复核公共数据字段、图片字段和详情页读取规则 45 65
Coding Standard 制定页面结构、公共数据接口和 Git 提交规范 25 35
Design 设计首页卡片、搜索筛选、详情页信息和图片轮播 90 125
Coding 编写首页、搜索、筛选、详情、公共数据和日期处理功能 400 590
Code Review 复核公共接口、详情读取和合并后的代码 70 100
Test 自测浏览、搜索、筛选、详情、轮播和日期显示流程 190 280
Reporting 编写个人负责模块的报告内容 90 130
Test Report 整理搜索、详情和公共数据测试结果 50 75
Size Measurement 统计负责文件、功能点和提交规模 20 30
Postmortem & Process Improvement Plan 总结 Figma 运行、分支管理和接口协作问题,提出改进措施 40 60
合计 1230 1780
注: 本次 PSP 记录中,预计总耗时为 1230 分钟,实际耗时 1780 分钟,整体比预估多 550 分钟。其中 Coding(+190 分钟)和 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 后及时审查;合并完成后执行跨页面回归测试。

6.3 队友评价

值得学习的地方:

很有责任心和执行力,按照分工很好地完成发布、个人中心和状态管理等模块,并能够主动关注模块与其他页面之间的衔接,保证功能可以顺利整合到整个系统中。合并之后他也很细心去反复测试,发现了一些小bug,然后我们一起反复修改。遇到问题时,他愿意主动分析原因、查阅已有代码并与我沟通解决,而不是只完成表面功能,这种责任心和问题解决能力值得我学习👍🏻
此外,他也尊重我的一些关于功能上的修改意见,对于较为繁琐的修改需求保持耐心,高配合度👍🏻

需要改进的地方:

有时候没有及时上传他那边的最新更新版本,合并检查时没达到预期结果,然后就开始代码复查但其实只是因为忘记上传仓库sos,还有确实两个人都会有自己的想法,发生分歧的时候,还是多沟通,要客观理性耐心把自己想法讲清楚,然后也客观去理解对方的想法,高效对齐认知努力达成一致🤝

总的来说,他是一位值得信任、值得学习的队友。👍🏻

七、个人总结

这次项目,我主要负责首页浏览、搜索筛选、物品详情和公共数据模块,并参与了页面联调、代码合并和功能测试。
1️⃣个人收获方面:在实现过程中,我进一步掌握了原生 HTML、CSS、JavaScript 以及 localStorage 数据存储的使用,也认识到统一数据结构和公共接口对保证多页面数据同步非常重要。同时,PSP 记录和测试过程让我发现自己对调试、联调和测试时间估计不足。今后我会更加重视前期接口约定、任务估算、版本同步和异常情况测试,不断提高代码质量和项目协作效率。
通过 GitHub 分支协作、Pull Request 和冲突处理,我提高了团队协作和代码管理能力。
2️⃣结对协作方面:深刻感受到良好的沟通和明确的分工是项目顺利完成的重要保障。我们通过 GitHub 分支、Pull Request、代码审查和冲突处理共同完成了模块整合,也在接口不统一和共享文件冲突中积累了经验。结对协作不仅可以互相检查代码、发现问题,还能从队友的实现方式中学习。今后应在编码前提前约定数据字段、接口和文件归属,及时同步代码,并通过充分沟通减少重复修改和合并冲突。
非常感谢老师&助教们的浏览,如果可以的话可以拥有一个还不错的评价吗
有趣

posted @ 2026-10-06 20:46  ijustwannarun  阅读(30)  评论(2)    收藏  举报