第四次软工作业

【软件工程结对作业二】校园失物招领系统的设计与实现(Web)

本文为 2026 秋《软件工程》第二次结对作业——程序实现的结项博客。
作业要求:基于第一次结对作业的原型,完成「发布信息—浏览/搜索—查看详情—联系发布者—更新状态」核心闭环,不做后台、实名认证、即时聊天与地图定位。

〇、作业链接信息

项目 链接 / 信息
成员 A(仓库拥有者)博客园 https://www.cnblogs.com/gaoyipeng
成员 B(Fork + Pull Request)博客园 https://home.cnblogs.com/u/lamelo
本篇作业博客地址 https://www.cnblogs.com/gaoyipeng/p/23253907
GitHub 仓库 https://github.com/Gaoyypp/102401330-102401331
成员 A:高一鹏(学号 102401330,GitHub:Gaoyypp);B:林明亮(学号 102401331,GitHub:lameeloo)

已在班级群结对表格中填写上述 GitHub 项目地址(deadline1 前完成)。


一、具体分工

成员 主要负责内容
成员 A(102401330) 项目脚手架与目录规范、storage.js 数据访问层、首页信息墙(搜索/筛选/统计/卡片)、发布表单页、Git 仓库与提交管理、README 编写
成员 B(102401331) utils.js 核心纯函数(校验/搜索/时间/XSS)、详情页与状态更新闭环、「我的发布」页面、Mocha 单元测试与 Node 运行器、走查测试与截图、博客整理
共同完成 需求复盘、附加特点头脑风暴、数据模型设计、代码互审(Code Review)、结对调试与最终走查

协作方式:成员 A 在 GitHub 创建主仓库,成员 B Fork 后通过 Pull Request 提交;开发中两人共用一套提交信息规范(feat/test/docs/chore: 中文描述),每完成一个可运行的功能即提交一次。


二、PSP 个人开发流程记录

开工前共同估计各阶段耗时,完工后回填实际耗时。单位:分钟。

PSP2.1 阶段 具体内容 预估耗时 实际耗时
Planning · 计划 明确作业范围,确定 Web 纯前端方案 30 35
Estimate · 估计任务时间 拆分功能点并估计总工作量 20 20
Development · 开发
Analysis · 需求分析(含学习新技术) 复盘第一次原型、确定不做的功能边界;学习 Mocha/Chai 与 localStorage 120 150
Design Spec · 生成设计文档 确定数据模型、页面流转、函数划分 40 35
Design Review · 设计复审 互审设计,补上状态闭环的入口与权限问题 20 15
Coding Standard · 代码规范 约定命名、目录、提交信息格式 20 20
Design · 具体设计 纯函数层 / 数据层 / 页面层三层设计 60 70
Coding · 具体编码 四个页面 + 七个 JS 文件 + 样式 300 360
Code Review · 代码复审 互查逻辑、XSS、边界条件 40 50
Test · 测试(自测、改代码、提交) 35 个单元测试 + 浏览器全流程走查 + 修 Bug 90 120
Reporting · 报告
Test Report · 测试报告 整理测试结果与截图 30 35
Size Measurement · 计算工作量 统计代码规模与 PSP 偏差 15 15
Postmortem & Process Improvement · 事后总结 分析超时环节与改进计划 30 30
合计 815(约 13.6 小时) 955(约 15.9 小时)

偏差分析:

  1. 需求分析 +30 分钟:单元测试部分比预想难,Mocha 的浏览器引入、Chai 断言风格花了额外时间看教程。
  2. 编码 +60 分钟:图片压缩、发布者鉴权、状态按钮文案都出现过返工(见第九节)。
  3. 测试 +30 分钟:原计划只测「正常路径」,后来按白盒方法补了边界值、异常输入、越权操作等用例,从十几个扩到 35 个。
  4. 经验:下次会把「走查异常路径」和「测试用例设计」单独估时间,而不是塞进编码里。

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

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

第一次原型作业暴露的核心问题是「重页面、轻闭环」,所以本次实现先抓住一条主线:

发布信息 → 浏览/搜索 → 查看详情 → 联系发布者 → 更新状态

围绕这条主线做了三个关键决策:

  1. 纯前端 + localStorage,零环境依赖。作业要求「下载所有文件后用 Chrome 打开 HTML 就能看到预期结果」,因此不引入后端、数据库和构建工具,原生 HTML/CSS/JS 即可运行;数据用浏览器 localStorage 持久化。
  2. 三层结构,把可测的逻辑从页面里抽出来:
    • js/utils.js:纯函数层,不碰 DOM 和存储——表单校验、搜索筛选排序、时间格式化、HTML 转义等,输入确定则输出确定,单元测试可以直接覆盖;
    • js/storage.js:数据访问层,封装 localStorage 的增删改查、发布者标识与状态鉴权,并支持注入内存存储(测试用);
    • 页面 JS(app/publish/detail/my.js):视图层,只负责渲染和事件绑定。
  3. 状态闭环有明确的「谁来改、从哪改、改完别人看到什么」:首次访问自动生成本机发布者标识 publisherId,只有信息的发布者能把状态从「进行中」改为「已找到/已归还」;结束后的信息在列表中置灰沉底,详情页显示「请勿重复联系」横幅,也支持误操作后重新开放。

数据模型(一条信息):

{
  id: 'LF20261009185747YT1Y', // 信息编号:LF + 时间 + 随机串
  type: 'lost' | 'found',      // 寻物启事 / 失物招领
  itemName: '黑色皮质钱包',     // 物品名称
  category: 'card' | 'electronics' | 'books' | 'keys' | 'clothing' | 'other',
  location: '图书馆一楼大厅',    // 遗失/拾取地点
  eventDate: '2026-10-09',     // 遗失/拾取日期
  description: '……',           // 详细描述
  image: 'data:image/jpeg;…',  // 可选,压缩后的图片
  contactName: '测试同学',
  contact: '13912345678',
  status: 'active' | 'resolved',
  createdAt: 1728456000000,
  resolvedAt: null,
  publisherId: 'Uxxxx'         // 发布者标识(本机自动生成)
}

3.2 关键流程图与数据流图

(1)核心业务流程图

flowchart TD A[首页信息墙] --> B{要做什么?} B -->|丢了/捡到东西| C[打开发布页] B -->|找信息| D[关键词搜索 + 类型/类别/状态/地点筛选] C --> E[填写表单] E --> F{前端校验通过?} F -->|否| G[字段下方标红提示错误] --> E F -->|是| H[写入 localStorage] --> I[跳转详情页] D --> J[点击信息卡片] --> I I --> K[查看完整信息与联系方式] K --> L[一键复制联系方式并联系] L --> M{物品找回/归还?} M -->|是, 且本人发布| N[标记 已找到/已归还] N --> O[状态置为 resolved, 记录结束时间] O --> P[列表沉底置灰 + 详情页显示防重复联系横幅] M -->|否| K

(2)数据流与三层架构图

flowchart LR subgraph View[视图层 页面 JS] V1[index/app.js 首页] V2[publish/publish.js 发布] V3[detail/detail.js 详情] V4[my-posts/my.js 我的发布] end subgraph Logic[逻辑层] U[utils.js 纯函数<br/>校验/筛选/排序/转义/时间] S[storage.js 数据访问层<br/>CRUD/鉴权/种子数据] end subgraph Persist[持久化] L[(localStorage<br/>posts_v1 / user_v1)] end V1 --> U V2 --> U V3 --> U V4 --> U V1 --> S V2 --> S V3 --> S V4 --> S S --> U S --> L T[test/tests.js 35 个单元测试] --> U T --> S

(3)信息状态转换图

stateDiagram-v2 [*] --> active: 发布成功 active --> resolved: 发布者点击「已找到/已归还」(二次确认) resolved --> active: 发布者点击「重新开放」 note right of resolved 列表沉底置灰 详情页提示「请勿重复联系」 记录 resolvedAt end note

(4)发布表单校验流程

flowchart TD A[点击确认发布] --> B[collectData 收集表单] B --> C[validatePost 统一校验] C --> D{errors 为空?} D -->|否| E[错误按字段映射, 标红并显示文案] E --> F[自动滚动到第一处错误] D -->|是| G[Store.add 补全 id/状态/时间/发布者] G --> H[unshift 写入并保存] H --> I[Toast 发布成功, 跳转详情页]

3.3 重要代码片段与解释

片段一:发布信息整体校验(js/utils.js,单元测试重点)

function validatePost(data) {
  var errors = [];
  data = data || {};

  if (TYPE_KEYS.indexOf(data.type) === -1) {
    errors.push('请选择信息类型(寻物启事或失物招领)');
  }
  var itemName = String(data.itemName || '').trim();
  if (!itemName) {
    errors.push('物品名称不能为空');
  } else if (itemName.length > 30) {
    errors.push('物品名称不能超过 30 个字');
  }
  /* 类别、地点、日期、描述、联系人同理…… */

  if (!String(data.contact || '').trim()) {
    errors.push('联系方式不能为空');
  } else if (!isValidContact(data.contact)) {
    errors.push('联系方式格式不正确,请填写手机号 / QQ号 / 微信号 / 邮箱,或“微信:账号”形式');
  }
  return { valid: errors.length === 0, errors: errors };
}

解释:校验函数不直接操作 DOM,而是返回 {valid, errors} 结构。这样发布页可以把错误逐条映射回对应字段标红,测试也可以直接断言错误数量与内容。联系方式单独抽出 isValidContact,支持手机号、QQ、微信号、邮箱以及「微信:账号」这类带标签写法,贴合校园真实使用习惯。

片段二:多关键词搜索 + 组合筛选 + 排序(js/utils.js)

function filterPosts(posts, cond) {
  cond = cond || {};
  var result = (posts || []).slice();
  if (cond.type && cond.type !== 'all') {
    result = result.filter(function (p) { return p.type === cond.type; });
  }
  if (cond.category && cond.category !== 'all') {
    result = result.filter(function (p) { return p.category === cond.category; });
  }
  /* status、location 同理…… */
  if (cond.keyword && String(cond.keyword).trim()) {
    result = result.filter(function (p) { return matchKeyword(p, cond.keyword); });
  }
  // 进行中优先;同状态下新发布在前
  result.sort(function (a, b) {
    if (a.status !== b.status) return a.status === 'active' ? -1 : 1;
    return (b.createdAt || 0) - (a.createdAt || 0);
  });
  return result;
}

其中多关键词采用 AND 语义(空格分隔的每个词都要命中),搜索范围覆盖物品名称、地点、描述、联系人:

function matchKeyword(post, keyword) {
  var kw = String(keyword || '').trim().toLowerCase();
  if (!kw) return true;
  var terms = kw.split(/\s+/).filter(Boolean);
  var haystack = [post.itemName, post.location, post.description, post.contactName]
    .join(' ').toLowerCase();
  return terms.every(function (t) { return haystack.indexOf(t) !== -1; });
}

片段三:新增信息与「仅发布者可改状态」的鉴权(js/storage.js)

function add(input) {
  var result = AppUtils.validatePost(input);
  if (!result.valid) return { ok: false, errors: result.errors };
  var posts = getAll();
  var post = {
    id: AppUtils.generateId(),
    /* ……字段来自 input…… */
    status: 'active',
    createdAt: Date.now(),
    resolvedAt: null,
    publisherId: getUserId()   // 首次访问自动生成的本机标识
  };
  posts.unshift(post);
  saveAll(posts);
  return { ok: true, post: post };
}

function updateStatus(id, status) {
  if (['active', 'resolved'].indexOf(status) === -1) {
    return { ok: false, error: '状态值不合法' };
  }
  var posts = getAll();
  var target = posts.filter(function (p) { return p.id === id; })[0];
  if (!target) return { ok: false, error: '信息不存在' };
  if (target.publisherId !== getUserId()) {
    return { ok: false, error: '只有发布者可以修改状态' };
  }
  target.status = status;
  target.resolvedAt = status === 'resolved' ? Date.now() : null;
  saveAll(posts);
  return { ok: true, post: target };
}

解释:纯前端没有登录系统,我们用 localStorage 中首次访问自动生成的随机 publisherId 标识「本机发布者」,从数据层拦住非本人的状态修改;非法状态值、不存在的编号也都返回结构化错误,而不是直接崩溃。

片段四:一键复制联系方式(js/common.js,附加特点)

function fallbackCopy(text) {
  try {
    var ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed'; ta.style.opacity = '0';
    document.body.appendChild(ta);
    ta.select();
    var ok = document.execCommand('copy');
    document.body.removeChild(ta);
    return ok;
  } catch (e) { return false; }
}

window.copyText = function (text, okMsg) {
  function done() { toast(okMsg || '已复制到剪贴板', 'success'); }
  function fail() { toast('复制失败,请手动选择文本复制', 'error'); }
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(text).then(done).catch(function () {
      fallbackCopy(text) ? done() : fail();
    });
  } else {
    fallbackCopy(text) ? done() : fail();
  }
};

解释:file:// 直接打开页面时部分浏览器会禁用 Clipboard API,因此做了 execCommand('copy') 降级;复制内容同时包含联系人、联系方式、物品名和信息编号,方便对方核对(详情页按钮一键复制整段)。


四、附加特点设计与展示

4.1 创意点与意义

# 特点 解决的问题(意义)
1 一键复制联系方式 联系失主/拾主时要手动对照输入,还常忘报是哪件物品;一键复制「联系人+方式+物品+编号」,减少出错
2 多维筛选 + 关键词高亮 + AND 搜索 群聊信息刷屏难找;按类型/类别/状态/地点组合过滤,命中关键词用 <mark> 高亮
3 图片上传本地压缩 图片直接存 localStorage 容易超出 5MB 配额;用 Canvas 压到最长边 800px、JPEG 0.72 质量
4 完整的状态闭环与权限控制 明确「谁能改、从哪改、改完别人看到什么」,结束信息沉底并提示勿重复联系,直击作业指出的痛点
5 字段级表单校验 + 错误定位 空输入、超长、未来日期、乱填联系方式都会被拦,并自动滚动到第一处错误
6 空状态引导、统计概览、相对时间、响应式布局 无结果/无发布时给明确引导;首页实时统计总数/寻物/招领/已结束;适配手机宽度
7 XSS 防护 所有用户输入渲染前先 HTML 转义,搜索高亮在转义之后进行,避免 <script> 注入

4.2 实现思路(以图片压缩和 XSS 高亮为例)

图片压缩用 FileReader 读出图片 → Image 加载 → 按最长边等比缩放到 canvas → 导出 JPEG DataURL:

window.compressImage = function (file, opt) {
  var maxSize = (opt && opt.maxSize) || 800;
  var quality = (opt && opt.quality) || 0.72;
  return new Promise(function (resolve, reject) {
    if (!file || !/^image\//.test(file.type)) return reject(new Error('请选择图片文件'));
    if (file.size > 8 * 1024 * 1024) return reject(new Error('图片不能超过 8MB'));
    var reader = new FileReader();
    reader.onload = function (e) {
      var img = new Image();
      img.onload = function () {
        var w = img.width, h = img.height;
        if (w > maxSize || h > maxSize) {
          if (w >= h) { h = Math.round(h * maxSize / w); w = maxSize; }
          else { w = Math.round(w * maxSize / h); h = maxSize; }
        }
        var canvas = document.createElement('canvas');
        canvas.width = w; canvas.height = h;
        canvas.getContext('2d').drawImage(img, 0, 0, w, h);
        resolve(canvas.toDataURL('image/jpeg', quality));
      };
      img.src = e.target.result;
    };
    reader.readAsDataURL(file);
  });
};

搜索高亮先转义再包裹 <mark>,从根上杜绝 XSS:

function highlight(text, keyword) {
  var safe = escapeHtml(text);                 // 第一步:转义
  var terms = String(keyword || '').trim().split(/\s+/).filter(Boolean).map(escapeHtml);
  terms.forEach(function (t) {
    safe = safe.replace(
      new RegExp('(' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi'),
      '<mark>$1</mark>');                      // 第二步:高亮
  });
  return safe;
}

4.3 成果展示

  1. 01-index
    ` 首页信息墙(统计卡片 + 搜索筛选 + 寻物橙/招领蓝卡片 + 已结束沉底)
  2. 02-publish
    ` 发布表单(类型大按钮、图片上传区)
  3. 03-detail-active
    ` 进行中详情页(一键复制、发布者操作区)
  4. 04-detail-resolved
    ` 已结束详情页(绿色防重复联系横幅、结束时间、重新开放)
  5. 05-my-posts
    ` 我的发布(标记已找到/已归还)
  6. 06-empty-state
    ` 搜索无结果空状态

五、目录说明与使用说明

5.1 目录如何组织

campus-lost-found/
├── index.html            # 首页:统计、搜索筛选、信息卡片列表
├── publish.html          # 发布页:寻物/招领表单、校验、图片上传
├── detail.html           # 详情页:完整信息、复制联系方式、状态更新
├── my-posts.html         # 我的发布:本人信息管理
├── css/
│   └── style.css         # 全局样式(卡片、徽章、表单、响应式)
├── js/
│   ├── utils.js          # 纯函数:校验/筛选/排序/转义/时间/统计(测试重点)
│   ├── storage.js        # 数据访问层:CRUD、发布者标识、鉴权、演示数据
│   ├── common.js         # 公共 UI:Toast、一键复制、图片压缩
│   ├── app.js            # 首页逻辑
│   ├── publish.js        # 发布页逻辑
│   ├── detail.js         # 详情页逻辑
│   └── my.js             # 我的发布逻辑
├── assets/
│   └── logo.svg          # 站点图标
├── docs/screenshots/     # 7 张运行截图
├── test/
│   ├── tests.html        # 浏览器单元测试页(Mocha+Chai,离线可开)
│   ├── tests.js          # 35 个测试用例(浏览器/Node 共用)
│   ├── node-runner.js    # 命令行零依赖运行器
│   └── lib/              # 本地化的 mocha.js / mocha.css / chai.js
├── .gitignore
└── README.md

设计原则:纯函数层(utils)不依赖 DOM,数据层(storage)可注入内存存储,因此核心业务逻辑无需浏览器即可被单元测试直接引用。

5.2 测试人员如何运行

运行网页(推荐,作业要求方式):

  1. 下载仓库全部文件(git clone 或下载 ZIP 解压),保持目录结构不变;
  2. 用 Google Chrome 直接双击根目录 index.html,无需安装任何环境、无需联网;
  3. 首次打开自动注入 6 条演示数据(其中 2 条属于本机发布者,便于体验「我的发布」与状态更新)。

运行单元测试:

  • 浏览器:双击打开 test/tests.html,顶部显示绿色通过数;
  • 命令行(可选):在项目根目录执行 node test/node-runner.js,全部通过时退出码为 0。

推荐体验路径:首页搜索「校园卡」→ 切换寻物/招领 Tab → 发布页直接点提交看校验 → 完整填写并发布 → 详情页一键复制 → 我的发布标记「已找到/已归还」→ 回首页看信息沉底 → 搜不存在的词看空状态。


六、单元测试

6.1 测试工具选型与自学简易教程

工具:Mocha(测试框架,提供 describe/it 组织结构与红绿结果)+ Chai(断言库,本项目用 assert 断言风格)。

为什么选它:作业附录推荐了 Mocha,教程多;它可以直接在浏览器里跑,适合纯前端项目;我们把框架文件下载到 test/lib/ 实现完全离线,助教断网也能打开测试页。

学习路径(廖雪峰 Mocha 教程、阮一峰《测试驱动开发》《Mocha 实例教程》,均为作业附录链接):

  1. 理解「测试框架 = 用例容器 + 结果展示,断言库 = 判断对错」;
  2. 浏览器版三步搭起来:
<!-- test/tests.html 骨架 -->
<link rel="stylesheet" href="lib/mocha.css">
<div id="mocha"></div>
<script src="lib/mocha.js"></script>
<script src="lib/chai.js"></script>
<script>mocha.setup('bdd');</script>

<script src="../js/utils.js"></script>   <!-- 被测源码 -->
<script src="../js/storage.js"></script>
<script src="tests.js"></script>         <!-- 用例 -->
<script>mocha.run();</script>
  1. 一个最小用例长这样:
describe('加减法(演示)', function () {
  it('1 + 1 应该等于 2', function () {
    assert.equal(1 + 1, 2);
  });
});
  1. 为了让测试能进每日构建自动跑,我们又写了零依赖的 test/node-runner.js:用 Node 内置 assert 实现与 Mocha 兼容的 describe/it 最小接口,浏览器和命令行共用同一份 tests.js,避免维护两套用例。命令行结果:
测试结果:通过 35 个,失败 0 个,共 35 个
✅ 全部测试通过

6.2 部分测试代码与被测函数说明

describe('【6】validatePost 发布信息整体校验', function () {
  it('完整合法数据校验通过', function () {
    var r = AppUtils.validatePost(validPost());
    assert.equal(r.valid, true);
    assert.equal(r.errors.length, 0);
  });

  it('缺少必填字段时逐条报错', function () {
    var r = AppUtils.validatePost({});
    assert.equal(r.valid, false);
    ['信息类型','物品名称','物品类别','地点','日期','详细描述','联系人称呼','联系方式']
      .forEach(function (kw) {
        assert.ok(r.errors.some(function (e) { return e.indexOf(kw) !== -1; }),
          '应包含“' + kw + '”错误');
      });
  });

  it('日期不能晚于今天、格式必须合法', function () {
    var r1 = AppUtils.validatePost(validPost({ eventDate: '2099-01-01' }));
    assert.ok(r1.errors.some(function (e) { return e.indexOf('晚于今天') !== -1; }));
    var r2 = AppUtils.validatePost(validPost({ eventDate: '10/09/2026' }));
    assert.ok(r2.errors.some(function (e) { return e.indexOf('日期格式') !== -1; }));
  });
});

describe('【9】Store 数据访问层(内存存储)', function () {
  var mem = Store.memoryStorage();
  var store = Store.createStore(mem, { postsKey: 'test_posts', userKey: 'test_user' });

  it('非发布者无权修改状态(换一个用户标识)', function () {
    var r = store.add(validPost({ itemName: '别人的校园卡' }));
    var other = Store.createStore(mem, { postsKey: 'test_posts', userKey: 'test_user_other' });
    var res = other.updateStatus(r.post.id, 'resolved');
    assert.equal(res.ok, false);
    assert.ok(res.error.indexOf('发布者') !== -1);
    assert.equal(store.getById(r.post.id).status, 'active'); // 原状态不变
  });
});
  • 第一段测 validatePost:合法基线、空对象、非法日期三类输入;
  • 第二段测 updateStatus 的越权防护:换一个用户标识去改别人的信息,必须失败且原状态不变。

6.3 测试数据构造思路(如何考虑各种情况与「刁难」)

采用白盒测试的等价类划分 + 边界值分析,并以「一条合法基线数据 + 覆盖(override)派生异常数据」组织:

function validPost(over) {
  var base = {
    type: 'lost', itemName: '校园卡', category: 'card',
    location: '图书馆三楼', eventDate: AppUtils.todayStr(),
    description: '卡面贴有哆啦A梦贴纸的校园卡,测试用描述。',
    image: '', contactName: '张同学', contact: '13800138000'
  };
  Object.keys(over || {}).forEach(k => base[k] = over[k]);
  return base;
}

35 个用例覆盖的「刁难」场景:

类型 具体场景
空值与缺失 空对象提交、各必填字段单独留空
边界长度 物品名 30/31 字、描述 4/5 字、联系人 10/11 字、联系方式上下限
非法格式 未来日期、10/09/2026 错误日期格式、abc 这类无效联系方式
多格式合法值 手机号 / QQ / 微信 / 邮箱 / 「微信:xxx」「QQ: 12345」均要通过
枚举篡改 type:'hacked'、category:'weapon' 必须被拒
搜索 多关键词 AND、命中描述/地点、无结果空数组、组合筛选、排序规则
时间 刚刚/分钟/小时/天/7 天后回退日期、未来时间戳不得出现负数
安全 <script> 经转义不可执行、高亮不得产生新标签
权限与异常 非发布者改状态、不存在的编号、非法状态值、空库才注入种子且不覆盖用户数据

自评:35 个用例覆盖了核心纯函数和数据层的正常、边界、异常与权限路径,满足本程序的测试要求;未覆盖的是浏览器事件绑定与 Canvas 压缩(强依赖 DOM),这部分通过手工全流程走查 + 截图验证(见第四节)。

07-tests
浏览器 Mocha 测试页 35/35 全绿。


七、GitHub 代码签入记录

仓库地址:https://github.com/Gaoyypp/102401330-102401331

按功能划分提交,每完成一个可运行功能即 commit 一次,提交信息遵循 Conventional Commits:

提交信息 对应功能
chore: 初始化项目目录结构与忽略规则 目录脚手架、.gitignore、站点图标
feat: 新增核心工具函数与 localStorage 数据访问层 utils.js + storage.js
test: 搭建 Mocha+Chai 测试框架并补充 35 个单元测试 test/ 全部
feat: 完成首页信息墙(统计概览、搜索筛选、卡片列表) 首页 + 全局样式 + 公共脚本
feat: 实现寻物/招领信息发布表单 发布页
feat: 实现详情页与状态更新闭环 详情页
feat: 实现我的发布页面与状态管理 我的发布
docs: 完善 README 使用说明与运行截图 README + 截图
docs: 补全结对学号与 GitHub 仓库地址 结对信息

成员 B 通过 Fork 仓库 → 修改推送 → Pull Request 的方式协作,A 审核后合并。

8f72af4b299972624b629c7d06d93988
GitHub 仓库 commits 页面(按日期分组的提交记录);
007ba1e1810a6ec2e3750dfd3e323df8
成员 B 的 Pull Request 页面(合并后状态为 Merged)。


八、遇到的困难与解决方法

困难 1:详情页把「进行中」当成了结束按钮文案

  • 问题描述:详情页发布者按钮一度显示「标记为『进行中』」,语义完全反了——进行中是当前状态,按钮应该引导结束(已找到/已归还)。
  • 尝试:最初复用了 statusLabel(post)(它在进行中返回"进行中"、结束后返回"已找到/已归还"),没区分「当前状态」和「结束动作」两个概念。
  • 是否解决:✅ 已解决。把结束动作文案按类型固定为 isLost ? '已找到' : '已归还',当前状态文案单独用 statusLabel,并补了状态文案相关单元测试防止回归。
  • 收获:状态机里「状态名」和「动作名」是两回事,命名和测试都要把它们分开。

困难 2:已结束卡片的水印和类别图标重叠

  • 问题描述:列表卡片结束后要显示「已找到」半透明水印,但水印和中间的大 Emoji 图标叠在一起显得杂乱。
  • 尝试:先调透明度,效果仍不理想;最后用 CSS :has(.badge-resolved-corner) 让结束卡片缩略图隐藏 Emoji、只保留水印,角落类型徽章不变。
  • 是否解决:✅ 已解决(见首页截图第六张卡片)。
  • 收获:界面画出来和用户看着舒服是两件事,状态变化要有明确但不干扰的视觉层级。

困难 3:纯前端没有登录,怎么保证「只有发布者能改状态」

  • 问题描述:不做实名认证和后台,但谁都能改状态会让信息墙失控。
  • 尝试:评估过输入口令、简单密码等方案,都增加使用负担;最终采用首次访问自动生成 publisherId 存 localStorage 的方案,数据层 updateStatus 强制校验归属,单元测试中专门用「第二个用户标识」验证越权会被拒绝。
  • 是否解决:✅ 已解决。同时在 README 中诚实说明这是本机标识、不具备跨设备安全性(需要后端才能做到),守住作业范围。
  • 收获:权限可以在数据层兜底;也要清楚方案边界并写进文档,不夸大。

困难 4:单元测试既要浏览器看、又想命令行自动跑

  • 问题描述:Mocha 浏览器页适合展示,但每日构建希望命令行一条命令跑完。
  • 尝试:不想维护两份用例,于是把用例写成接收 describe/it/assert 依赖的工厂形式;再写约 40 行的 node-runner.js,用 Node 内置 assert 模拟 Mocha 接口,同一份 tests.js 两处运行。
  • 是否解决:✅ 已解决,35 个用例在浏览器和 Node 下结果一致。
  • 收获:把框架依赖通过参数注入,核心用例就能跨环境复用。

困难 5:图片塞进 localStorage 可能超配额

  • 问题描述:手机拍的照片动辄 3~5MB,几张就撑爆 localStorage 约 5MB 的配额。
  • 尝试:用 Canvas 在前端把图片等比缩到最长边 800px 并转 JPEG 0.72 质量,体积降到几十 KB;同时限制 8MB 上传、只允许单张。
  • 是否解决:✅ 已解决。
  • 收获:前端存储是稀缺资源,入库前先压缩;可选功能也要考虑失败提示。

九、评价我的队友

成员 A 视角评价 B(林明亮)

  • 值得学习的地方:
    • 测试意识强,主动把用例从十几个扩充到 35 个,坚持补「越权改状态」「未来时间戳」这类刁钻场景,让我们后期改代码很有底气;
    • 做详情页时把状态闭环的每个入口(谁来改、从哪改、结束后展示什么)都列清楚才动手,逻辑很严密。
  • 需要改进的地方:
    • 一开始写代码不习惯小步提交,经常攒一大堆再 commit,后期按功能拆分提交后规范了很多;
    • 遇到前端 API 兼容性问题(如 Clipboard)有时会直接换方案,建议先查文档确认降级路径。

posted @ 2026-10-10 16:26  高一鹏  阅读(6)  评论(0)    收藏  举报