软件工程结对作业(第二次之程序实现)

FZU校园失物招领平台——结对编程博客

一、链接与分工

项目内容 内容
这个作业属于哪个课程 H202601 软件工程与软件工程实践
这个作业要求在哪里 2026秋软件工程结对作业(第二次之程序实现)
这个作业的目标 完成校园失物招领小程序的开发
学号 10240205-102401204
GitHub 仓库 https://github.com/chen-yeling/102401205-102401204

具体分工

成员 学号 负责内容
陈烨婷 102401205 首页、发布页、详情页、代码整合、视图切换、表单校验、图片压缩、单元测试
张楠 102401204 我的页面,精细加工其他页面(如类别筛选、一键复制),单元测试、README

二、PSP表格

PSP阶段 预估耗时(分钟) 实际耗时(分钟)
计划 30 40
需求分析 40 50
设计页面与数据结构 60 80
编写HTML骨架 90 120
编写CSS样式 180 240
编写JS核心逻辑 240 320
单元测试 90 120
解决Git冲突与合并 60 100
README与博客撰写 120 150
总计 910 1220

三、解题思路与设计实现

3.1 代码实现思路

本项目采用单页面多视图(SPA)架构,所有功能都在一个 index.html 中完成,通过切换 .view 的 active 类来展示不同页面,避免页面跳转带来的状态丢失。

四个视图

1. 首页 #homeView

功能:搜索、类型筛选、类别筛选、卡片列表


2. 发布页 #publishView

功能:发布表单、图片上传、表单校验


3. 详情页 #detailView

功能:显示完整信息、一键复制联系方式


4. 我的发布 #mineView

功能:管理自己发布的信息、标记“已找到/已归还”、删除

模块划分:

  • storage.js:只负责 localStorage 读写,不碰页面
  • core.js:纯逻辑函数(搜索、筛选、状态更新、删除),不碰 DOM,方便单元测试
  • ui.js:渲染列表、切换视图、绑定事件,是连接数据和页面的桥梁
  • app.js:入口文件,DOMContentLoaded 时启动

3.2 关键数据流图

flowchart LR User[用户] -->|点击/输入| Event[事件监听器<br/>bindEvents] Event -->|调用| Logic[纯逻辑函数<br/>core.js] Logic -->|读取/写入| Storage[localStorage] Logic -->|返回结果| Render[render 渲染] Render -->|innerHTML| DOM[页面展示] DOM -->|用户看到| User style Logic fill:#ffe0e0,stroke:#c62828 style Storage fill:#e0f0ff,stroke:#2b6cb0

3.3 用户操作主流程图

flowchart TD Start([用户打开网页]) --> Home[首页显示<br/>默认寻找物品] Home --> Choice{用户操作} Choice -->|点击寻找物品/寻找失主| SwitchTab[switchTab 切换类型] SwitchTab --> Render1[render 重新渲染] Render1 --> Home Choice -->|输入搜索词| Search[searchItems 按关键词过滤] Search --> Render2[render 重新渲染] Render2 --> Home Choice -->|点击类别标签| Cat[filterByCategory 按类别过滤] Cat --> Render3[render 重新渲染] Render3 --> Home Choice -->|点击底部 +| Publish[打开发布页] Publish --> Form[填写表单] Form --> Validate{校验通过?} Validate -->|否| ShowErr[显示红字提示] ShowErr --> Form Validate -->|是| Save[保存到 localStorage] Save --> Toast[提示发布成功] Toast --> Home Choice -->|点击卡片| Detail[打开详情页] Detail --> ShowInfo[显示完整信息] ShowInfo --> Copy{是否复制?} Copy -->|是| DoCopy[复制联系方式] Copy -->|否| Back1[返回] DoCopy --> Back1 Back1 --> Home Choice -->|点击底部 我的| Mine[打开我的发布] Mine --> MineOps{操作} MineOps -->|标记状态| Update[updateItemStatus] Update --> Mine MineOps -->|删除| Confirm{确认删除?} Confirm -->|是| Del[deleteItem 删除] Confirm -->|否| Mine Del --> Mine MineOps -->|返回| Home

3.4 重要代码片段

片段一:搜索 + 类型 + 类别三重筛选

function render() {
  const allItems = loadItems();
  allItems.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));

  let items = searchItems(allItems, currentKeyword);
  items = filterItems(items, currentType);
  items = filterByCategory(items, currentCategory);
  // ...
}

这段代码把三种筛选条件串联使用,每一层都在上一层的输出上继续过滤,实现了“搜索词 + 类型 + 类别”的组合筛选。

片段二:图片压缩存储

function compressImage(file, callback) {
  const reader = new FileReader();
  reader.onload = function (e) {
    const img = new Image();
    img.onload = function () {
      const canvas = document.createElement("canvas");
      let w = img.width, h = img.height;
      const maxW = 600;
      if (w > maxW) { h = h * maxW / w; w = maxW; }
      canvas.width = w;
      canvas.height = h;
      canvas.getContext("2d").drawImage(img, 0, 0, w, h);
      callback(canvas.toDataURL("image/jpeg", 0.7));
    };
    img.src = e.target.result;
  };
  reader.readAsDataURL(file);
}

由于 localStorage 只有约 5MB 容量,直接存原图会爆掉。通过 canvas 将图片缩放到最大 600px 宽,并以 JPEG 0.7 质量压缩,一张图约 30~80KB,可存十几张。

片段三:删除按钮的事件委托

mineList.addEventListener("click", (e) => {
  const deleteBtn = e.target.closest(".delete-btn");
  if (deleteBtn) {
    e.stopPropagation();  // 阻止冒泡,避免触发卡片点击
    const id = deleteBtn.dataset.id;
    if (!confirm(`确定删除「${name}」吗?`)) return;
    saveItems(deleteItem(loadItems(), id));
    renderMine();
    render();
    return;
  }
  // ... 处理标记、点击卡片
});

因为“我的发布”列表是 JS 动态生成的,每次删除后 DOM 会重新渲染,所以用事件委托在父容器 #mineList 上监听点击,避免重复绑定。


四、附加特点设计

4.1一键复制联系方式

创意独到之处:详情页联系方式旁增加“复制”按钮,点击即可将联系方式复制到剪贴板,并弹出 Toast 提示。

意义:用户无需长按选中或手动抄写联系方式,一步即可复制,方便后续联系发布者。

实现思路:优先使用现代 navigator.clipboard.writeText API,不支持时降级为 document.execCommand("copy")。

function fallbackCopy(text) {
  const input = document.createElement("textarea");
  input.value = text;
  input.style.position = "fixed";
  input.style.opacity = "0";
  document.body.appendChild(input);
  input.select();
  try {
    const ok = document.execCommand("copy");
    showToast(ok ? "已复制联系方式:" + text : "复制失败,请手动复制");
  } catch (e) {
    showToast("复制失败,请手动复制");
  }
  document.body.removeChild(input);
}

成果展示:见3.1的#detailView视图

4.2 物品类别筛选

创意独到之处:发布时选择“证件卡类 / 电子产品 / 书籍资料 / 衣物饰品 / 钥匙 / 其他”,首页可通过横向滚动的胶囊按钮按类别筛选,缩小查找范围。

意义:用户不必在大量信息中逐条翻找,选择类别后列表立刻过滤,提升查找效率。

实现思路:

  • 发布表单增加 <select name="category"> 并设为必填。
  • core.js 新增 filterByCategory 函数。
  • 首页增加类别按钮栏,点击后更新 currentCategory 并重新渲染。
  • 类别栏下方使用灰色可拖动的横向滚动条,方便触摸和鼠标操作。
function filterByCategory(items, category) {
  if (!category || category === "all") return items;
  return items.filter(item => item.category === category);
}

function switchCategory(category) {
  currentCategory = category;
  document.querySelectorAll("#categoryBar .cat-btn").forEach(btn => {
    btn.classList.toggle("active", btn.dataset.category === category);
  });
  render();
}

成果展示:见3.1的#homeView视图

五、目录说明与使用说明

5.1 目录组织

102401205-102401204/
├── index.html              # 单页面,包含4个视图
├── css/
│   └── style.css           # 所有样式
├── js/
│   ├── storage.js          # 数据读写层
│   ├── core.js             # 纯逻辑函数
│   ├── ui.js               # 渲染 + 事件绑定
│   └── app.js              # 入口
├── test/
│   ├── test.html           # 单元测试运行页面
│   └── test.js             # 27 个断言,覆盖 6 个核心逻辑函数
└── README.md               # 说明文档

分层原则:数据层(storage)→ 逻辑层(core)→ 视图层(ui)→ 入口(app),层与层之间单向依赖,便于维护和测试。

5.2 测试人员如何运行

  1. 下载所有文件到本地文件夹
  2. 用 谷歌浏览器 打开 index.html
    • 若直接双击后页面异常,请安装 VS Code 的 Live Server 插件,右键 index.html → Open with Live Server
  3. 打开后默认在首页,点击底部 + 可以发布第一条信息
  4. 点卡片看详情,点“我的”管理发布

六、单元测试

6.1 测试工具与学习教程

选用工具:原生 JavaScript + 自定义极简断言函数。没用 Jest/Mocha,因为项目的核心逻辑(createItem、searchItems、filterItems、filterByCategory、updateItemStatus、getStatusText)都是纯函数,不依赖 DOM,用几行代码就能完整验证,也方便零基础同学理解。

写给零基础同学的简易教程:

单元测试的核心只有一件事——给函数一个输入,断言它的输出符合预期。

let passed = 0, total = 0;

function assert(condition, message) {
  total++;
  if (condition) {
    passed++;
    console.log("✅ 通过:" + message);
  } else {
    console.error("❌ 失败:" + message);
  }
}
  • condition:要验证的条件,如 item.type === "lost"
  • message:用例名,方便定位失败项

三步跑起来:

  1. 准备假数据(mockData)
  2. 给每个函数写 assert(...)
  3. 浏览器打开 test/test.html,F12 看 Console

passed === total 时页面显示绿色,说明全部通过。


6.2 测试代码展示

test/test.html(运行页面):

<script src="../js/core.js"></script>
<script src="test.js"></script>
<div id="result">测试运行中...</div>

test/test.js(仅展示代表性用例,完整 27 个断言见 GitHub):

// ---- 测试框架 ----
let passed = 0, total = 0;
function assert(cond, msg) {
  total++;
  if (cond) { passed++; console.log("✅ " + msg); }
  else { console.error("❌ " + msg); }
}

// ---- 假数据 ----
const mockData = [
  { id: "1", type: "lost",  title: "校园卡",  category: "证件卡类",
    place: "图书馆三楼", desc: "在图书馆丢失", status: "active" },
  { id: "2", type: "found", title: "一串钥匙", category: "钥匙",
    place: "第二食堂",   desc: "在食堂捡到",   status: "active" }
];

// ---- createItem:默认值与字段 ----
const item = createItem({ type: "lost", title: "蓝牙耳机", category: "电子产品" });
assert(item.status === "active",       "createItem:默认状态 active");
assert(typeof item.id === "string",    "createItem:自动生成 id");

// ---- searchItems:命中、无结果、空搜索 ----
assert(searchItems(mockData, "校园卡").length === 1, "searchItems:按名称命中");
assert(searchItems(mockData, "手机").length === 0,   "searchItems:无结果返回空");
assert(searchItems(mockData, "  ").length === 2,     "searchItems:空搜索返回全部");

// ---- filterItems:三种类型 ----
assert(filterItems(mockData, "lost").length === 1,  "filterItems:筛选寻物");
assert(filterItems(mockData, "all").length === 2,   "filterItems:all 返回全部");

// ---- filterByCategory:类别筛选 ----
assert(filterByCategory(mockData, "证件卡类").length === 1, "filterByCategory:命中");
assert(filterByCategory(mockData, "").length === 2,         "filterByCategory:空值返回全部");

// ---- updateItemStatus:更新 + 不变性 ----
const updated = updateItemStatus(mockData, "1", "found");
assert(updated[0].status === "found",  "updateItemStatus:状态更新");
assert(updated[1].status === "active", "updateItemStatus:其他项不受影响");

// ---- getStatusText:状态码转中文 ----
assert(getStatusText({ status: "found" }) === "已找到", "getStatusText:found→已找到");

// ---- 输出结果 ----
document.getElementById("result").textContent =
  `测试完成:通过 ${passed} / ${total} 个用例`;

完整版共 27 个断言,覆盖 6 个核心函数,见 GitHub 仓库 test/test.js。


6.3 测试函数说明

编号 被测试函数 测试重点
1 createItem 字段完整性、默认值、自动生成 id
2 searchItems 名称/地点/描述匹配、空搜索、无结果
3 filterItems lost / found / all 三种筛选
4 filterByCategory 类别筛选、空字符串兼容
5 updateItemStatus 状态更新、其他项不受影响(不变性)
6 getStatusText 状态码转中文文案

6.4 构造测试数据的思路

三类场景全覆盖:

类型 说明 举例
正常数据 主流路径,必须有 搜索"校园卡"返回 1 条
边界数据 临界情况,最容易出 bug 空搜索、空类别、无结果
不变性验证 防止函数有副作用 更新一条时其他条不能变

预演"测试人员的刁难":

刁难 我们的应对
搜索词带空格 searchItems 内部 trim()
全大写搜索 toLowerCase() 做不敏感匹配
不传类别 createItem 默认 ""
快速点两次发布 submitBtn.disabled = true 防重复
输入 <script> 注入 渲染时 escapeHtml 转义

6.5 运行方式

  1. 用 Chrome 打开 test/test.html
  2. 页面顶部显示结果,F12 看每条用例
  3. 全绿 → 说明核心逻辑无误

预期结果:

失败时:Console 会显示红色 ❌ 和用例名,去 js/core.js 改对应函数,刷新重跑。


6.6 与作业要求的对应

作业要求"至少 5 个测试用例",我们实际写了 27 个断言,覆盖 6 个核心函数,远超最低要求。

作业说"单元测试不必上传",但为了方便助教和同学验证,我们把 test/ 目录一并上传到了 GitHub,并在 README 中注明了运行方式。


七、GitHub代码签入记录

八、异常与困难

8.1 问题描述

问题一:红线初次加载不显示
在 DOMContentLoaded 时调用 moveIndicator(),此时布局尚未完成,getBoundingClientRect() 返回 0,红线宽度为 0。

问题二:Git 合并冲突
两人分别改动了 index.html 和 ui.js,pull request 时出现多处 <<<<<<< 冲突标记。

问题三:详情页返回后按钮偏左
从详情页返回首页时,showView("homeView") 刚把视图从 display: none 切到 flex,布局未稳定就调用 moveIndicator,导致位置错误。

8.2 做过的尝试

  • 尝试用 setTimeout(0):仍然过早
  • 尝试 setTimeout(100):基本可用,但不够严谨
  • 最终用 双层 requestAnimationFrame 或 setTimeout(100) + 手动调一次
setTimeout(() => {
  const indicator = document.querySelector(".tab-indicator");
  if (indicator) {
    indicator.style.transition = "none";
    moveIndicator();
    void indicator.offsetWidth;
    indicator.style.transition = "";
  }
}, 100);

8.3 是否解决

已解决。红线初次加载即出现在正确位置,切换与返回时平滑移动。Git 冲突通过手动保留双方有效代码 + 删除所有冲突标记解决。

8.4 收获

  • 理解了浏览器渲染时序:DOMContentLoaded 不等于布局完成,需要 requestAnimationFrame 或 setTimeout 让出主线程
  • 学会了Git 冲突解决流程:定位标记 → 取舍代码 → 删除标记 → 提交 → push
  • 掌握了分层架构的好处:core.js 不碰 DOM,所以可以脱离浏览器单独跑单元测试

九、评价队友

张楠(102401204)

值得学习的地方:

  • 单元测试写得很细致,覆盖了正常、边界、异常多种情况
  • 类别筛选栏的创意很好,用横向滚动 + 胶囊按钮,视觉上很清爽
  • README 文档结构清晰,方便助教和其他同学上手

需要改进的地方:

  • 一开始提交的 commit message 不够规范(有的只有“更新”两个字),后来改成 feat: 开头
  • 修改 CSS 时习惯新增而不是替换,导致红线样式重复了三段,后期需要手动清理

陈烨婷(102401205,自我评价)

  • 负责了主要页面实现和代码整合,遇到问题时习惯先查 F12 Console 报错定位
  • 需要改进的是:写代码时对布局时序问题的预判不够,红线位置问题绕了三次才彻底解决

十、总结

本项目基于第一次结对作业的原型,用纯前端技术完成了“发布 → 浏览/搜索 → 查看详情 → 联系发布者 → 更新状态”的完整流程。通过分层架构(storage / core / ui / app)让代码职责清晰,通过单元测试保证了核心逻辑的可靠性。虽然过程中遇到了 Git 冲突、布局时序、localStorage 容量等问题,但都在两人协作下逐一解决。

posted @ 2026-10-10 23:33  cytlllll  阅读(3)  评论(0)    收藏  举报