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

校园失物招领 · 结对作业博客


这个作业属于哪个课程 前往班级
这个作业要求在哪里 前往查看
这个作业的目标 基于第一次结对作业的原型,用纯前端(HTML/CSS/JS)实现校园失物招领 WEB 应用的核心功能
队员1学号姓名 102401309 叶凯乐
队员2学号姓名 102401234 黄俊凡
结对同学博客链接 叶凯乐;黄俊凡
本次作业博客 本文链接
GitHub 仓库 campus-lost-and-found

一、结对分工与过程

1.1 具体分工

成员 负责内容
叶凯乐 数据层与业务逻辑(core / storage / images / backup / comments / claims / stats)、单元测试、图片压缩与 XSS 防护
黄俊凡 页面模板与交互(views / ui / app)、hash 路由、响应式布局与导航重构、深色模式、统计页、图片全屏查看器、真实 Chrome 验收脚本

1.2 PSP 表格

PSP2.1 预估耗时(分钟) 实际耗时(分钟)
Planning(计划) 30 40
Estimate(估计任务时间) 30 30
Analysis(需求分析) 60 90
Design Spec(设计说明) 60 90
Design Review(设计复审) 30 45
Coding Standard(代码规范) 20 20
Design(具体设计) 60 80
Coding(编码) 300 640
Code Review(代码复审) 60 120
Test(测试) 120 240
Test Report(测试报告) 60 90
Size Measurement(计算工作量) 30 40
Postmortem(事后总结) 30 45
合计 890 1670

二、解题思路与设计实现

2.1 代码实现思路(文字描述)

作业要求下载后能直接用 Chrome 打开,因此我们使用 HTML、CSS 和 JavaScript 编写页面,把数据保存在浏览器中,省去安装依赖和启动服务器的步骤。

  1. 页面导航。整个应用只有一个 index.html,通过 #/home、#/search 等地址切换首页、搜索、发布和详情等页面。已保存的信息在刷新后仍可读取,页面支持浏览器前进和后退。
  2. 代码组织。校验、搜索和状态更新放在 core.js,本地存储放在 storage.js,页面模板放在 views.js,页面事件和交互处理放在 app.js。业务逻辑不依赖 DOM,测试可以直接引用网页使用的代码。
  3. 本地数据保存。用键 campus-lost-found:v1 保存 { version, ownerId, posts: [] },首次打开时生成匿名发布者标识。通过 ownerId 判断哪些信息属于「我的发布」。
  4. 使用流程。用户发布信息后,可以在列表中浏览或搜索,进入详情复制联系方式。物品找回或归还后,发布者在「我的发布」中更新状态,首页和详情页也会显示对应结果。

2.2 关键流程图

graph TD A[首页 最近发布] --> B{浏览 / 搜索} B -->|浏览| C[信息列表] B -->|关键词/类别/地点筛选| D[搜索结果] C --> E[详情页] D --> E E --> F[复制联系方式] E --> G[本人可编辑 / 标记已解决] F --> H[线下联系发布者] H --> G G --> I[状态变为已找到/已归还, 本地各页面同步]

数据流:表单输入 → core.validatePost 校验 → core.createPost 生成记录 → storage.save 写入 localStorage → 重新渲染列表。读数据时 storage.load 会先做结构校验,损坏则拒绝覆盖。

2.3 重要代码片段与解释

① 数据模型与校验(core.js)——所有字段集中定义、集中校验,非法类型、超长、未来时间、坏图片都会被拦下:

const CATEGORIES = Object.freeze([
  '证件卡片', '数码设备', '钥匙', '包袋', '衣物饰品', '生活用品', '书本文具', '其他'
]);
const LIMITS = Object.freeze({ title: 60, location: 80, description: 1000, contact: 100 });

function validatePost(input, now = new Date()) {
  const errors = {};
  if (input.type !== 'lost' && input.type !== 'found') errors.type = '请选择寻物或招领';
  // 每个必填字段:非空 + 长度上限;occurredAt 还要是合法日期且不能晚于现在
  // image 必须是 data:image/(jpeg|png|webp);base64 白名单,否则拒绝
  return errors;
}

② localStorage 持久化(storage.js)——保存前先 validState 校验,损坏数据绝不静默覆盖;空间不足抛 STORAGE_WRITE,表单内容保留:

function save(state) {
  if (!validState(state))
    throw new Core.DomainError('STORAGE_CORRUPT', '本地数据格式不正确,未覆盖原有数据');
  try {
    storage.setItem(key, JSON.stringify(state));
  } catch (_) {
    throw new Core.DomainError('STORAGE_WRITE', '保存失败……填写内容仍然保留。');
  }
  return state;
}

③ 业务与浏览器双导出——同一个 core.js 在浏览器挂到 window.CampusCore,在 Node 里走 module.exports,测试直接测真实实现:

(function (root, factory) {
  const api = factory();
  if (typeof module === 'object' && module.exports) module.exports = api;
  else root.CampusCore = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () { /* ... */ });

三、附加特点设计与展示

3.1 附加特点

基本功能完成后,我们重点补充了四项功能,方便用户查找物品、联系发布者和保存信息。

  1. 组合筛选。只记得物品类别和大致地点时,可以同时设置类别、地点和日期范围,缩小搜索结果;也可以按发布时间或物品发生时间排序。比如查找在图书馆遗失的校园卡,就不必逐条翻看其他地点的钥匙、耳机等信息。
  2. 一键复制联系方式。详情页提供复制按钮,减少手动抄写微信号或 QQ 号的麻烦。如果浏览器不允许自动复制,页面会保留可选中的联系方式,供用户手动复制。
  3. 物品照片与默认插画。发布寻物或招领信息时,可以选择上传照片,帮助浏览者辨认物品;没有照片时,卡片按类别显示默认插画。上传图片会先压缩,减少本地存储占用。详情图支持双击放大、拖拽和滚轮缩放,方便查看物品细节。
  4. 数据备份与恢复。可以把本机数据导出为 JSON 文件,在需要时重新导入。导入时按编号合并记录,并恢复备份中的发布者标识,方便继续管理原来的发布信息。

3.2 实现思路

图片处理在 js/images.js:validateFile 做类型/大小/非空校验,fitDimensions 保持宽高比缩到最长边 1200px,preparePhoto 用 canvas drawImage 重绘后 toDataURL('image/jpeg', quality),超过存储上限就逐次降质量、再降尺寸,最多 8 次。

组合筛选由 core.filterPosts 处理,一条信息需要同时满足已选择的条件才会显示。复制功能会等待剪贴板写入成功后再提示成功,失败时提供手动复制的方式。备份功能放在 js/backup.js,导入前先检查文件格式和数据结构,通过后再合并记录。

3.3 代码片段

async function preparePhoto(file) {
  validateFile(file);                       // 类型 / 大小 / 非空
  const url = URL.createObjectURL(file);
  try {
    const image = await loadImage(url);
    if (image.naturalWidth * image.naturalHeight > MAX_PIXELS)
      throw new Error('图片分辨率过高……');
    const canvas = document.createElement('canvas');
    let maxSide = 1200, quality = 0.84;
    for (let attempt = 0; attempt < 8; attempt += 1) {
      const size = fitDimensions(image.naturalWidth, image.naturalHeight, maxSide);
      canvas.width = size.width; canvas.height = size.height;
      const context = canvas.getContext('2d');
      context.fillStyle = '#ffffff';
      context.fillRect(0, 0, size.width, size.height);
      context.drawImage(image, 0, 0, size.width, size.height);
      const result = canvas.toDataURL('image/jpeg', quality);
      if (result.length <= Core.MAX_IMAGE_LENGTH) return result;
      quality > 0.5 ? (quality -= 0.16) : ((maxSide *= 0.75), (quality = 0.72));
    }
    throw new Error('图片无法压缩到可保存的大小,请换一张图片');
  } finally { URL.revokeObjectURL(url); }
}

认领申请的确认逻辑(js/claims.js):发布者确认后,信息自动标记为已解决。以下为节选,省略了记录不存在等校验和更新时间的处理。

function decideClaim(posts, postId, claimId, decision, ownerId, now = new Date()) {
  const post = posts.find((item) => item.id === postId);
  if (post.ownerId !== ownerId) throw new Core.DomainError('FORBIDDEN', '只能处理自己的信息');
  if (!['accepted', 'rejected'].includes(decision)) throw new Core.DomainError('VALIDATION', '无效结果');
  const claim = post.claims.find((c) => c.id === claimId);
  if (claim.status !== 'pending') return posts; // 已处理则幂等返回
  return posts.map((item) => item.id === postId
    ? { ...item,
        claims: item.claims.map((c) => c.id === claimId ? { ...c, status: decision } : c),
        status: decision === 'accepted' ? 'resolved' : item.status }
    : item);
}

3.4 成果展示

首页:搜索筛选与物品列表

发布信息:填写物品资料与选择图片

发布成功页面

详情页:复制联系方式、更新状态与模拟认领

上传图片后的详情页

数据统计页面

我的发布:编辑、更新状态与撤回信息

3.5 页面走查与修改记录

几轮测试发现并修复以下问题:

轮次 发现的问题 处理结果
第 1 轮 深色模式下「我的发布」页底部仍为浅色 给 html 根元素也设置深色背景
第 2 轮 统计数值字号过大、日期卡槽与排序卡槽风格不统一、发布页空态字体偏粗深 统一字号 / 边框 / 圆角 / 占位色
第 3 轮 详情页右侧下方空缺较多 新增「温馨提示」卡片填充
第 4 轮 上传图片后溢出成为信息卡片背景 图片改用 max 约束并加溢出裁剪
第 5 轮 图片查看需浏览器式体验 重做为全屏查看器,支持拖拽与缩放
第 6 轮 合并布局重构后,深色模式部分卡片(图标按钮 / 类型选择 / 图片上传区等)配色不统一 补齐深色背景覆盖,输入聚焦不再变白
第 7 轮 发布页顶部多余返回键导致标题偏移 移除顶部返回键(表单底部仍有取消按钮)
第 8 轮 深色模式下物品插画仍为浅色块 插画加滤镜压暗,融入深色背景
第 9 轮 左侧边栏导航导致内容左偏移、卡片位置不统一 导航改为顶部横向布局

四、目录说明与使用说明

4.1 目录组织

campus-lost-and-found/
├── index.html                唯一网页入口(hash 路由)
├── css/styles.css            页面样式,桌面 / 窄屏自适应
├── js/
│   ├── core.js               表单校验、搜索、归属与状态业务逻辑(可被 Node 直接引用)
│   ├── comments.js           物品留言(评论)
│   ├── claims.js             认领申请与发布者确认
│   ├── storage.js            数据结构检查及 localStorage 持久化
│   ├── backup.js             数据导出 / 导入备份
│   ├── seed.js               8 条示例信息
│   ├── stats.js              数据统计
│   ├── ui.js                 文本转义、路由、复制与图标工具
│   ├── images.js             图片文件检查、等比缩小与压缩
│   ├── views.js              各页面 HTML 模板
│   └── app.js                页面导航、事件、表单与状态同步
├── tests/                    Node 自动化单元测试(node:test,零依赖)
├── scripts/browser-check.cjs 可选的真实 Chrome 验收脚本(Playwright)
└── package.json              仅含测试命令,网页运行不依赖 Node

4.2 如何运行

  1. 在 GitHub 选择 Code → Download ZIP 解压整个项目(或 git clone)。
  2. 找到 index.html,右键 → 打开方式 → Google Chrome(不需要装 Node、npm install 或启动本地服务器)。
  3. 首页显示 8 条标注「示例」的信息;点导航「发布信息」选择寻物/招领即可发布。
  4. 注意保留 css、js 目录与 index.html 的相对位置,不要只拷走一个 HTML。

当前版本使用浏览器本地存储,没有连接服务器,不同电脑上的数据不会自动互通。留言和认领申请也保存在当前浏览器中;发布者处理认领的过程可以通过模拟申请进行演示。

运行单元测试(仅开发时,需 Node 22+,项目零 npm 依赖):

npm test                 # 运行全部单元测试
npm run test:coverage    # 附带覆盖率报告

五、单元测试

5.1 测试工具与学习过程

我们使用 Node 内置的 node:test 和 node:assert/strict,不需要额外安装测试框架。写测试前先了解如何定义用例、比较结果和检查异常,再把这些写法用到表单校验、搜索和存储测试中。

共编写 106 个单元测试用例,分布在 8 个文件:core.test.cjs(43 个,业务逻辑、搜索与撤回)、storage.test.cjs(18 个,持久化与数据安全)、ui.test.cjs(13 个,转义/路由/剪贴板/高亮)、images.test.cjs(9 个,图片校验与缩放)、backup.test.cjs(7 个,备份导入导出)、comments.test.cjs(6 个,留言)、claims.test.cjs(6 个,认领)、stats.test.cjs(4 个,统计)。此外还有 scripts/browser-check.cjs,用 Playwright 驱动真实 Chrome 做了 30 余项端到端验收。

5.2 部分单元测试代码与说明

编写一个测试可以分为以下几步:

  1. 在 tests 目录新建以 .test.cjs 结尾的文件。
  2. 引入测试工具和要测试的模块,准备输入数据。
  3. 用 test 描述一种情况,调用函数,再用断言检查结果。
  4. 在项目目录执行 npm test。如果失败,根据输出中的用例名称和断言位置检查原因。

例如,下面是一个可以单独运行的用例,用来检查纯空格的物品名称是否会被拒绝:

const test = require('node:test');
const assert = require('node:assert/strict');
const Core = require('../js/core.js');

test('纯空格的物品名称不能通过校验', () => {
  const errors = Core.validatePost({ title: '   ' });
  assert.ok(errors.title);
});

assert.ok(errors.title) 检查是否返回了名称字段的错误。如果校验遗漏了纯空格的情况,这条断言就会失败。这里仅检查名称字段,其他字段的要求由各自的用例验证。

下面节选了搜索、编辑和存储测试,公共测试数据的定义已省略:

test('标题搜索不区分英文大小写并忽略首尾空格', () => {
  assert.deepEqual(
    Core.filterPosts(records, { keyword: '  airpods ' }).map((p) => p.id),
    ['new']
  );
});

test('本人编辑更新资料,保留唯一编号和原始发布时间', () => {
  const changed = Core.editPost(original, { ...valid, title: '白色耳机', id: 'hacked' }, 'me', now);
  assert.equal(changed.title, '白色耳机');
  assert.equal(changed.id, 'p1');          // id 不允许被覆盖
  assert.equal(changed.createdAt, original.createdAt); // 发布时间不变
  assert.equal(original.title, '蓝牙耳机'); // 原对象不被修改(不可变更新)
});

test('拒绝未知版本并保留原数据', () => {
  storage.setItem(STORAGE_KEY, JSON.stringify({ version: 2, ownerId: 'me', posts: [] }));
  assert.throws(() => repository(storage).load(), (e) => e.code === 'STORAGE_CORRUPT');
  assert.equal(storage.getItem(STORAGE_KEY), JSON.stringify({ version: 2, ownerId: 'me', posts: [] }));
});

第一条用例检查带空格的小写关键词能否找到目标记录;第二条检查编辑名称时,编号和原始发布时间是否保持不变;第三条检查读到不支持的数据版本时,程序是否报错并保留原数据。

运行结果:106 项通过,0 项失败。上面的入门示例用于说明测试写法,不另计入项目的测试数量。

5.3 构造测试数据的思路与边界考虑

  • 固定时间:测试里用 new Date(2026, 9, 8, 14, 0) 固定「当前时间」,避免用例受真实时钟影响;occurredAt 也取固定字符串,未来时间/非法日期(如 2 月 30 日)可被稳定复现。
  • 按分支准备输入:根据校验函数中的判断,分别准备正常输入和异常输入。例如将 6 个必填字段逐个置空,检查非法图片、未知数据版本、重复编号和非法时间戳是否被拒绝。这样能检查具体分支,避免只验证顺利发布的情况,但不代表已经覆盖所有可能的输入。
  • 只测纯函数与数据层:DOM 行为交给 browser-check.cjs 的真实浏览器验收,单元测试保持快且确定。

六、GitHub 提交记录

完整可见仓库

GitHub 代码提交记录


七、困难与解决

问题 尝试与解决 是否解决 收获
纯前端没有后端,如何「区分谁发布的」 用 localStorage 存匿名 ownerId,示例记录 ownerId='demo';「我的发布」只过滤当前 ownerId ✅ 明确了「本机演示」与「真实账号认证」的边界
图片存 localStorage 容易超限 canvas 等比缩到 1200px、压缩 JPEG、质量/尺寸递降重试,图片大小上限 36 万字符 ✅ 理解了浏览器存储配额与图片压缩的工程权衡
用户输入带 HTML 会注入脚本 全量 escapeHTML + 图片 base64 白名单校验,拒绝 javascript:、SVG 脚本 ✅ XSS 防御要做在「输出」和「输入校验」两端
localStorage 被改坏导致白屏 validState 保存/读取双重校验,损坏时不静默覆盖,给出备份指引 ✅ 数据完整性比「能跑」更重要
多标签页/未保存离开造成数据丢失 监听 storage 事件同步;dirty 标记 + beforeunload 提示 ✅ 状态同步要覆盖多入口场景
同一份逻辑如何同时跑在浏览器和 Node 测试 用 IIFE 双导出(浏览器挂 window、Node 走 module.exports) ✅ 模块边界清晰,测试才能直接测真实实现
详情图上传后溢出成为卡片背景 图片沿用 width/height:100% 在 grid 里解析到自然尺寸;改用 max-width/max-height:100% + overflow:hidden 裁剪 ✅ 百分比高度在 grid/flex 里解析要谨慎,约束图片用 max 更稳
深色模式页面底部发白 只给 body 设了深色背景、html 仍是浅色,内容较短时底部露出;给 html 根元素也设深色背景 ✅ 主题切换要同时覆盖 html 与 body 两级背景

八、队友评价

8.1 值得学习的地方

  • 叶凯乐 → 黄俊凡:对页面细节比较细心,除了页面跳转,也考虑了未保存就离开的提醒。检查页面时能发现配色、字体和控件样式不统一的问题,并逐步调整。
  • 黄俊凡 → 叶凯乐:数据校验写得比较集中,查问题时容易找到对应位置。测试也考虑了空输入、非法日期和保存失败等情况,这些是我容易遗漏的地方。

8.2 需要改进的地方

  • 叶凯乐 → 黄俊凡:可以更早锁定配色与卡片尺寸规范,减少后期统一返工。
  • 黄俊凡 → 叶凯乐:部分注释可更精简,前期 commit 可以拆得更小、更聚焦。

九、个人总结

这次作业我主要负责页面和交互。原型转成网页后才发现,页面画出来只是第一步,发布、搜索、查看详情和修改状态能否顺畅地连起来,还需要反复操作检查。比如上传照片后图片超出卡片、深色模式下部分背景仍然发白,这些问题都是实际使用时才注意到的。

前期没有把布局和常用控件的样式确定好,后面花了不少时间调整,也让编码和测试的耗时超出了预估。下次我会先把基本流程跑通,尽早检查不同屏幕和页面状态下的效果,再补充其他功能,减少最后集中返工的情况。

本次的界面布局暂时只做了电脑上查看的布局,手机布局尚未完成,可考虑在后续继续加入。

posted @ 2026-10-09 20:48  JFanH  阅读(6)  评论(0)    收藏  举报